diff --git a/app/build.gradle b/app/build.gradle index d7de6c61b..1086fcee1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 144 - versionName "0.42.2" + versionCode 150 + versionName "0.43.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index c8803f82d..bb25fe513 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -4,12 +4,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.ui.actions.ImageUploader import com.vitorpamplona.amethyst.ui.actions.ImgurServer import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer +import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer import com.vitorpamplona.amethyst.ui.actions.NostrImgServer import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import java.util.Base64 @@ -21,12 +21,12 @@ class ImageUploadTesting { @Test() fun testImgurUpload() = runBlocking { - val inputStream = Base64.getDecoder().decode(image).inputStream() - - println("Uploading") + val bytes = Base64.getDecoder().decode(image) + val inputStream = bytes.inputStream() ImageUploader.uploadImage( inputStream, + bytes.size.toLong(), "image/gif", ImgurServer(), onSuccess = { url, contentType -> @@ -39,18 +39,17 @@ class ImageUploadTesting { } ) - delay(1000) + delay(5000) } @Test() - @Ignore fun testNostrBuildUpload() = runBlocking { - val inputStream = Base64.getDecoder().decode(image).inputStream() - - println("Uploading") + val bytes = Base64.getDecoder().decode(image) + val inputStream = bytes.inputStream() ImageUploader.uploadImage( inputStream, + bytes.size.toLong(), "image/gif", NostrBuildServer(), onSuccess = { url, contentType -> @@ -68,12 +67,12 @@ class ImageUploadTesting { @Test() fun testNostrImgUpload() = runBlocking { - val inputStream = Base64.getDecoder().decode(image).inputStream() - - println("Uploading") + val bytes = Base64.getDecoder().decode(image) + val inputStream = bytes.inputStream() ImageUploader.uploadImage( inputStream, + bytes.size.toLong(), "image/gif", NostrImgServer(), onSuccess = { url, contentType -> @@ -88,4 +87,27 @@ class ImageUploadTesting { 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) + } } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt index c131a8af3..3b35cdad1 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt @@ -78,7 +78,7 @@ class PrivateZapTests { if (recepientPK != null && recepientPost != null) { val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) val decodedPrivateZap = - LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) println(decodedPrivateZap?.toJson()) assertNotNull(decodedPrivateZap) @@ -127,8 +127,7 @@ class PrivateZapTests { if (recepientPK != null && recepientPost != null) { val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) - val decodedPrivateZap = - LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) println(decodedPrivateZap?.toJson()) assertNotNull(decodedPrivateZap) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 624d1bb2e..9210924ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -9,7 +9,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS 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.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event @@ -284,7 +284,7 @@ object LocalPreferences { val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) val a = Account( - Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), + Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()), followingChannels, hiddenUsers, localRelays, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7444b899d..532b4f740 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -181,44 +181,49 @@ class Account( } 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() return (pubKey == pubkeyHex) } fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? { val myNip47 = zapPaymentRequest ?: return null - return zapResponseEvent.response( - myNip47.secret?.toByteArray() ?: loggedIn.privKey!!, - myNip47.pubKeyHex.toByteArray() - ) + + val privKey = myNip47.secret?.hexToByteArray() ?: loggedIn.privKey + val pubKey = myNip47.pubKeyHex.hexToByteArray() + + if (privKey == null) return null + + return zapResponseEvent.response(privKey, pubKey) } - fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { - return zappedNote.isZappedBy(userProfile(), this) == true + fun calculateIfNoteWasZappedByAccount(zappedNote: Note?): Boolean { + return zappedNote?.isZappedBy(userProfile(), this) == true } fun calculateZappedAmount(zappedNote: Note?): BigDecimal { - return zappedNote?.zappedAmount( - zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!, - zapPaymentRequest?.pubKeyHex?.toByteArray() - ) ?: BigDecimal.ZERO + val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey + val pubKey = zapPaymentRequest?.pubKeyHex?.hexToByteArray() + return zappedNote?.zappedAmount(privKey, pubKey) ?: BigDecimal.ZERO } fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) { if (!isWriteable()) return 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) wcListener.start() LocalCache.consume(event, zappedNote) { // After the response is received. - val privKey = nip47.secret?.toByteArray() + val privKey = nip47.secret?.hexToByteArray() 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) { var pubkeyToUse = event.pubKey - val recepientPK = event.recipientPubKey() + val recepientPK = event.verifiedRecipientPubKey() if (note.author == userProfile() && recepientPK != null) { pubkeyToUse = recepientPK } - event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) + event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray()) } else if (event is LnZapRequestEvent && loggedIn.privKey != null) { decryptZapContentAuthor(note)?.content() } else { @@ -845,7 +850,7 @@ class Account( val privateKeyToUse = loggedInPrivateKey val pubkeyToUse = event.pubKey - LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse) + event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) } else { // if the sender is logged in, these are the params val altPubkeyToUse = recipientPK @@ -866,11 +871,21 @@ class Account( } if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) - if (result == null) { - Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}") + val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey() + + if (altPubKeyFromPrivate == event.pubKey) { + val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) + + if (result == null) { + Log.w( + "Private ZAP Decrypt", + "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}" + ) + } + result + } else { + null } - result } else { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt index eed0507d5..a9ab40817 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt @@ -29,7 +29,7 @@ fun ByteArray.toHexKey(): HexKey { return toHex() } -fun HexKey.toByteArray(): ByteArray { +fun HexKey.hexToByteArray(): ByteArray { return Hex.decode(this) } @@ -39,7 +39,7 @@ fun HexKey.toDisplayHexKey(): String { fun decodePublicKey(key: String): ByteArray { val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex?.toByteArray() + val pubKeyParsed = parsed?.hex?.hexToByteArray() return if (key.startsWith("nsec")) { Persona(privKey = key.bechToBytes()).pubKey diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2ecfcf63e..20ca49cd8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -359,7 +359,7 @@ object LocalCache { // Already processed this event. 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()}") @@ -407,7 +407,7 @@ object LocalCache { if (deleteNote.event is PrivateDmEvent) { 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) { author.removeMessage(recipient, deleteNote) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index 636ee52b9..25498f702 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -6,7 +6,7 @@ import kotlin.time.measureTimedValue class ThreadAssembler { - fun searchRoot(note: Note, testedNotes: MutableSet = mutableSetOf()): Note? { + private fun searchRoot(note: Note, testedNotes: MutableSet = mutableSetOf()): Note? { if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note testedNotes.add(note) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 13a0cdd93..392cc17f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -181,7 +181,7 @@ class User(val pubkeyHex: String) { } @Synchronized - fun getOrCreatePrivateChatroom(user: User): Chatroom { + private fun getOrCreatePrivateChatroom(user: User): Chatroom { return privateChatrooms[user] ?: run { val privateChatroom = Chatroom(setOf()) privateChatrooms = privateChatrooms + Pair(user, privateChatroom) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index b94f35e91..8e6ae470d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -59,7 +59,6 @@ object BlurHashRequester { .Builder(context) .data("bluehash:$encodedMessage") .fetcherFactory(BlurHashFetcher.Factory) - .crossfade(100) .build() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 941aab60e..dbd5ec342 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -4,8 +4,8 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Log import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.ui.actions.ImageDownloader import io.trbl.blurhash.BlurHash -import java.net.URL import java.security.MessageDigest import kotlin.math.roundToInt @@ -19,11 +19,15 @@ class FileHeader( val description: String? = null ) { 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 { - val imageData = URL(fileUrl).readBytes() + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) - prepare(imageData, fileUrl, mimeType, description, onReady, onError) + if (imageData != null) { + prepare(imageData, fileUrl, mimeType, description, onReady, onError) + } else { + onError() + } } catch (e: Exception) { Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") onError() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 23e2196e4..44222d800 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -1,6 +1,5 @@ package com.vitorpamplona.amethyst.service -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES @@ -10,13 +9,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { var user: User? = null - fun loadUserProfile(userId: String?) { - if (userId != null) { - user = LocalCache.getOrCreateUser(userId) - } else { - user = null - } - + fun loadUserProfile(user: User?) { + this.user = user resetFilters() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt index 27a39b4d8..24d705835 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt @@ -1,7 +1,7 @@ package com.vitorpamplona.amethyst.service.model 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.service.nip19.Tlv 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 { val kind = kind.toByteArray() - val author = pubKeyHex.toByteArray() + val author = pubKeyHex.hexToByteArray() val dTag = dTag.toByteArray(Charsets.UTF_8) val relay = relay?.toByteArray(Charsets.UTF_8) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt index 6269f63c0..c16abc20e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt @@ -10,13 +10,9 @@ class BadgeAwardEvent( content: String, sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) + fun awardees() = taggedUsers() - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } + fun awardDefinition() = taggedAddresses() companion object { const val kind = 8 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt index e7c974812..42f559877 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt @@ -10,13 +10,13 @@ class BadgeDefinitionEvent( content: String, sig: HexKey ) : 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 name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) companion object { const val kind = 30009 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index 9b03abaa9..93f576f05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service.model +import android.util.Log import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.tagSearch import com.vitorpamplona.amethyst.service.nip19.Nip19 @@ -41,17 +42,18 @@ open class BaseTextNoteEvent( val key = matcher2.group(3) // bech32 val additionalChars = matcher2.group(4) // additional chars - val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + try { + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) - if (parsed != null) { - try { + if (parsed != null) { val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } if (tag != null && tag[0] == "p") { returningList.add(tag[1]) } - } catch (e: Exception) { } + } catch (e: Exception) { + Log.w("Unable to parse cited users that matched a NIP19 regex", e) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 0be651a7d..8accaaf8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -38,6 +38,8 @@ open class Event( 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 taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } @@ -83,7 +85,7 @@ open class Event( override fun getReward(): BigDecimal? { 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) { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt index dd236c96c..6b1940e47 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey -import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.model.hexToByteArray import nostr.postr.Utils abstract class GeneralListEvent( @@ -24,7 +24,7 @@ abstract class GeneralListEvent( fun plainContent(privKey: ByteArray): String? { return try { - val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) + val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray()) return Utils.decrypt(content, sharedSecret) } catch (e: Exception) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt index 966ed230d..4946fb4c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -6,7 +6,7 @@ import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException 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 nostr.postr.Utils import java.lang.reflect.Type @@ -21,9 +21,17 @@ class LnZapPaymentRequestEvent( sig: HexKey ) : 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 lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? { + if (lnInvoice != null) { + return lnInvoice + } + return try { val sharedSecret = Utils.getSharedSecret(privKey, pubkey) @@ -31,7 +39,9 @@ class LnZapPaymentRequestEvent( 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) { Log.w("BookmarkList", "Error decrypting the message ${e.message}") null @@ -53,7 +63,7 @@ class LnZapPaymentRequestEvent( val content = Utils.encrypt( serializedRequest, privateKey, - walletServicePubkey.toByteArray() + walletServicePubkey.hexToByteArray() ) val tags = mutableListOf>() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index 42f71f7c5..5d8ec2e95 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -19,10 +19,14 @@ class LnZapPaymentResponseEvent( sig: HexKey ) : 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 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 { val sharedSecret = Utils.getSharedSecret(privKey, pubKey) @@ -39,16 +43,21 @@ class LnZapPaymentResponseEvent( } } - fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try { - if (content.isNotEmpty()) { - val decrypted = decrypt(privKey, pubKey) - gson.fromJson(decrypted, Response::class.java) - } else { + fun response(privKey: ByteArray, pubKey: ByteArray): Response? { + if (response != null) response + + return try { + if (content.isNotEmpty()) { + val decrypted = decrypt(privKey, pubKey) + response = gson.fromJson(decrypted, Response::class.java) + response + } else { + null + } + } catch (e: Exception) { + Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) null } - } catch (e: Exception) { - Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) - null } companion object { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 0fc12d565..8586849a6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -20,12 +20,37 @@ class LnZapRequestEvent( sig: HexKey ) : 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 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 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 { const val kind = 9734 @@ -59,7 +84,7 @@ class LnZapRequestEvent( } else if (zapType == LnZapEvent.ZapType.PRIVATE) { var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt) 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)) content = "" // make sure public content is empty, as the content is encrypted privkey = encryptionPrivateKey // sign event with generated privkey @@ -92,7 +117,7 @@ class LnZapRequestEvent( } else if (zapType == LnZapEvent.ZapType.PRIVATE) { var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt) 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)) content = "" privkey = encryptionPrivateKey @@ -109,7 +134,7 @@ class LnZapRequestEvent( 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) val iv = ByteArray(16) SecureRandom().nextBytes(iv) @@ -128,7 +153,7 @@ class LnZapRequestEvent( 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) if (sharedSecret.size != 16 && sharedSecret.size != 32) { throw IllegalArgumentException("Invalid shared secret size") @@ -150,61 +175,5 @@ class LnZapRequestEvent( 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": // TODO -} -*/ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt index cb1f8aef7..a9f639a36 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.google.gson.reflect.TypeToken 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 nostr.postr.Utils import java.util.Date @@ -21,7 +21,7 @@ class MuteListEvent( fun plainContent(privKey: ByteArray): String? { return try { - val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) + val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray()) return Utils.decrypt(content, sharedSecret) } catch (e: Exception) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index c45c27848..c10837f3a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -21,7 +21,11 @@ class PrivateDmEvent( * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * 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. @@ -31,6 +35,11 @@ class PrivateDmEvent( */ 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? { return try { val sharedSecret = Utils.getSharedSecret(privKey, pubKey) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index 6aa8dd754..4a42c1adf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -1,7 +1,7 @@ package com.vitorpamplona.amethyst.service.nip19 import android.util.Log -import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.model.hexToByteArray import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Bech32 import nostr.postr.bechToBytes @@ -147,8 +147,8 @@ object Nip19 { public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String { val kind = kind?.toByteArray() - val author = author?.toByteArray() - val idHex = idHex.toByteArray() + val author = author?.hexToByteArray() + val idHex = idHex.hexToByteArray() val relay = relay?.toByteArray(Charsets.UTF_8) var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt new file mode 100644 index 000000000..7e2657522 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt @@ -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 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index 17365e2b6..4f0d840af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -1,21 +1,16 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.ContentResolver -import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import android.util.Log import android.webkit.MimeTypeMap import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.HttpClient import okhttp3.* import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.asRequestBody import okio.BufferedSink import okio.source -import java.io.File -import java.io.FileOutputStream import java.io.IOException import java.io.InputStream @@ -28,43 +23,51 @@ object ImageUploader { fun uploadImage( uri: Uri, server: ServersAvailable, - context: Context, contentResolver: ContentResolver, onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(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) { "Can't open the image input stream" } - val myServer = if (server == ServersAvailable.IMGUR) { - ImgurServer() - } else if (server == ServersAvailable.NOSTRIMG) { - NostrImgServer() - } else if (server == ServersAvailable.NOSTR_BUILD) { - NostrBuildServer() - } else { - ImgurServer() + val myServer = when (server) { + ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> { + ImgurServer() + } + ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> { + NostrImgServer() + } + ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> { + NostrBuildServer() + } + ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> { + NostrFilesDevServer() + } + else -> { + ImgurServer() + } } - val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri - if (file != null) { - uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError) - } + uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError) } fun uploadImage( - file: File, inputStream: InputStream, + length: Long, contentType: String?, server: FileServer, - serverType: ServersAvailable, onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { - val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" - val fileName = randomChars() val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" @@ -72,35 +75,23 @@ object ImageUploader { val requestBody: RequestBody val requestBuilder = Request.Builder() - if (serverType == ServersAvailable.NOSTR_BUILD) { - requestBuilder.addHeader("Content-Type", "multipart/form-data") - requestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart( - "fileToUpload", - "$fileName.$extension", - file.asRequestBody(contentType?.toMediaType()) + requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + server.inputParameterName(contentType), + "$fileName.$extension", - ) - .build() - } else { - requestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart( - category, - "$fileName.$extension", + object : RequestBody() { + override fun contentType() = contentType?.toMediaType() - object : RequestBody() { - override fun contentType(): MediaType? = - contentType?.toMediaType() + override fun contentLength() = length - override fun writeTo(sink: BufferedSink) { - inputStream.source().use(sink::writeAll) - } + override fun writeTo(sink: BufferedSink) { + inputStream.source().use(sink::writeAll) } - ) - .build() - } + } + ) + .build() server.clientID()?.let { requestBuilder.addHeader("Authorization", it) @@ -117,7 +108,7 @@ object ImageUploader { try { check(response.isSuccessful) response.body.use { body -> - val url = server.parseUrlFromSucess(body.string()) + val url = server.parseUrlFromSuccess(body.string()) checkNotNull(url) { "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 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 } @@ -180,12 +140,16 @@ abstract class FileServer { class NostrImgServer : FileServer() { 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 url = tree?.get("data")?.get("link")?.asText() return url } + override fun inputParameterName(contentType: String?): String { + return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + } + 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" } - override fun parseUrlFromSucess(body: String): String? { + override fun parseUrlFromSuccess(body: String): String? { val tree = jacksonObjectMapper().readTree(body) val url = tree?.get("data")?.get("link")?.asText() return url } + override fun inputParameterName(contentType: String?): String { + return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + } + override fun clientID() = "Client-ID e6aea87296f3f96" } class NostrBuildServer : FileServer() { 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() 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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index ac812b2c2..e8a6398ec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -11,10 +11,8 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.service.FileHeader import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import java.net.URL open class NewMediaModel : ViewModel() { var account: Account? = null @@ -46,6 +44,8 @@ open class NewMediaModel : ViewModel() { selectedServer = ServersAvailable.NOSTRIMG_NIP_94 } else if (selectedServer == ServersAvailable.NOSTR_BUILD) { 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( uri = uri, server = serverToUse, - context = context, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> createNIP94Record(imageUrl, mimeType, description) @@ -115,18 +114,12 @@ open class NewMediaModel : ViewModel() { uploadingDescription.value = "Server Processing" // Images don't seem to be ready immediately after upload - if (mimeType?.startsWith("image/") == true) { - delay(2000) - } else { - delay(15000) - } + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) uploadingDescription.value = "Downloading" uploadingPercentage.value = 0.60f - try { - val imageData = URL(imageUrl).readBytes() - + if (imageData != null) { uploadingPercentage.value = 0.80f uploadingDescription.value = "Hashing" @@ -154,8 +147,8 @@ open class NewMediaModel : ViewModel() { } } ) - } catch (e: Exception) { - Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + } else { + Log.e("ImageDownload", "Couldn't download image from server") cancel() uploadingPercentage.value = 0.00f uploadingDescription.value = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index c1489f126..955e0a4fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -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 fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { 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.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.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)) ) @@ -191,9 +199,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { ) } - if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || - postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 || - postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 || + if (isNIP94Server(postViewModel.selectedServer) || postViewModel.selectedServer == ServersAvailable.NIP95 ) { Row( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 370bf8728..620e393c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -588,9 +588,11 @@ enum class ServersAvailable { IMGUR, NOSTR_BUILD, NOSTRIMG, + NOSTRFILES_DEV, IMGUR_NIP_94, NOSTRIMG_NIP_94, NOSTR_BUILD_NIP_94, + NOSTRFILES_DEV_NIP_94, 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.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.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.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.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)) ) @@ -742,9 +746,7 @@ fun ImageVideoDescription( ) } - if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || - selectedServer == ServersAvailable.IMGUR_NIP_94 || - selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 || + if (isNIP94Server(selectedServer) || selectedServer == ServersAvailable.NIP95 ) { Row( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 42590becc..4b12675d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -19,7 +19,6 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @@ -145,10 +144,9 @@ open class NewPostViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, server = server, - context = context, contentResolver = contentResolver, 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) } else { isUploadingImage = false @@ -288,13 +286,6 @@ open class NewPostViewModel : ViewModel() { fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) { viewModelScope.launch(Dispatchers.IO) { // Images don't seem to be ready immediately after upload - - if (mimeType?.startsWith("image/") == true) { - delay(2000) - } else { - delay(5000) - } - FileHeader.prepare( imageUrl, mimeType, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 80abdf2cf..269f22871 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -171,7 +171,6 @@ class NewUserMetadataViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, server = account.defaultFileServer, - context = context, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> onUploading(false) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index f9117a13e..c94931a40 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -5,15 +5,23 @@ import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.navigation.NavController 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.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun ClickableRoute( @@ -21,56 +29,190 @@ fun ClickableRoute( navController: NavController ) { if (nip19.type == Nip19.Type.USER) { - val userBase = LocalCache.getOrCreateUser(nip19.hex) - - val userState by userBase.live().metadata.observeAsState() - val user = userState?.user ?: return - - CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController) + DisplayUser(nip19, navController) } 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} " + ) + } +} - if (noteBase == null) { - Text( - "@${nip19.hex}${nip19.additionalChars} " +@Composable +private fun DisplayEvent( + nip19: Nip19.Return, + navController: NavController +) { + var noteBase by remember { mutableStateOf(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 { - val noteState by noteBase.live().metadata.observeAsState() - val note = noteState?.note ?: return - - CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController) + CreateClickableText( + note.idDisplayNote(), + nip19.additionalChars, + "Event/${nip19.hex}", + navController + ) } - } else if (nip19.type == Nip19.Type.NOTE) { - val noteBase = LocalCache.getOrCreateNote(nip19.hex) - val noteState by noteBase.live().metadata.observeAsState() + } + + if (noteBase == null) { + Text( + "@${nip19.hex}${nip19.additionalChars} " + ) + } +} + +@Composable +private fun DisplayNote( + nip19: Nip19.Return, + navController: NavController +) { + var noteBase by remember { mutableStateOf(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) + 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) + CreateClickableText( + note.idDisplayNote(), + nip19.additionalChars, + "Room/${note.author?.pubkeyHex}", + navController + ) } 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 { - 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) { - 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) { + Text( + "@${nip19.hex}${nip19.additionalChars} " + ) + } +} + +@Composable +private fun DisplayAddress( + nip19: Nip19.Return, + navController: NavController +) { + var noteBase by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = nip19.hex) { + withContext(Dispatchers.IO) { + noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex) } - } else { + } + + 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(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( "@${nip19.hex}${nip19.additionalChars} " ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 3b62e83bd..ba0d79464 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -42,11 +42,13 @@ fun ExpandableRichTextViewer( ) { var showFullText by remember { mutableStateOf(false) } - // 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 firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val whereToCut = remember(content) { + // 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 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) { content diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 31d03d4a5..bc02931e5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -75,6 +75,14 @@ fun isValidURL(url: String?): Boolean { val richTextDefaults = RichTextStyle().resolveDefaults() +fun isMarkdown(content: String): Boolean { + return content.startsWith("> ") || + content.startsWith("# ") || + content.contains("##") || + content.contains("__") || + content.contains("```") +} + @Composable fun RichTextViewer( content: String, @@ -85,58 +93,39 @@ fun RichTextViewer( accountViewModel: AccountViewModel, navController: NavController ) { + val isMarkdown = remember { isMarkdown(content) } + Column(modifier = modifier) { - if (content.startsWith("> ") || - content.startsWith("# ") || - 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 - ) - } + if (isMarkdown) { + RenderContentAsMarkdown(content, backgroundColor) } else { + RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController) + } + } +} + +class RichTextViewerState( + val content: String, + val urlSet: LinkedHashSet, + val imagesForPager: Map, + val imageList: List +) + +@Composable +private fun RenderRegular( + content: String, + tags: List>?, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + var processedState by remember { + mutableStateOf(null) + } + + LaunchedEffect(key1 = content) { + withContext(Dispatchers.IO) { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } val imagesForPager = urlSet.mapNotNull { fullUrl -> @@ -151,67 +140,71 @@ fun RichTextViewer( }.associateBy { it.url } val imageList = imagesForPager.values.toList() - // FlowRow doesn't work well with paragraphs. So we need to split them - content.split('\n').forEach { paragraph -> - FlowRow() { - val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ') - s.forEach { word: String -> - if (canPreview) { - // Explicit URL - val img = imagesForPager[word] - if (img != null) { - ZoomableContentView(img, imageList) - } else if (urlSet.contains(word)) { - UrlPreview(word, "$word ") - } else if (word.startsWith("lnbc", true)) { - MayBeInvoicePreview(word) - } else if (word.startsWith("lnurl", true)) { - MayBeWithdrawal(word) - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) { - ClickablePhone(word) - } else if (isBechLink(word)) { - BechLink( + processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList) + } + } + + // FlowRow doesn't work well with paragraphs. So we need to split them + processedState?.let { state -> + content.split('\n').forEach { paragraph -> + FlowRow() { + val s = if (isArabic(paragraph)) { + paragraph.trim().split(' ') + .reversed() + } else { + paragraph.trim().split(' ') + } + s.forEach { word: String -> + if (canPreview) { + // Explicit URL + val img = state.imagesForPager[word] + if (img != null) { + ZoomableContentView(img, state.imageList) + } else if (state.urlSet.contains(word)) { + UrlPreview(word, "$word ") + } else if (word.startsWith("lnbc", true)) { + MayBeInvoicePreview(word) + } else if (word.startsWith("lnurl", true)) { + MayBeWithdrawal(word) + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + ClickableEmail(word) + } else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) { + ClickablePhone(word) + } else if (isBechLink(word)) { + BechLink( + word, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } else if (word.startsWith("#")) { + if (tagIndex.matcher(word).matches() && tags != null) { + TagLink( word, + tags, canPreview, backgroundColor, accountViewModel, navController ) - } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { - TagLink( - word, - tags, - canPreview, - backgroundColor, - accountViewModel, - navController - ) - } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, navController) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else if (noProtocolUrlValidator.matcher(word).matches()) { - val matcher = noProtocolUrlValidator.matcher(word) - matcher.find() - val url = matcher.group(1) // url - val additionalChars = matcher.group(4) ?: "" // additional chars + } else if (hashTagsPattern.matcher(word).matches()) { + HashTag(word, navController) + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } else if (noProtocolUrlValidator.matcher(word).matches()) { + val matcher = noProtocolUrlValidator.matcher(word) + matcher.find() + val url = matcher.group(1) // url + val additionalChars = matcher.group(4) ?: "" // additional chars - if (url != null) { - ClickableUrl(url, "https://$url") - Text("$additionalChars ") - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") } else { Text( text = "$word ", @@ -219,69 +212,74 @@ fun RichTextViewer( ) } } else { - if (urlSet.contains(word)) { - ClickableUrl("$word ", word) - } else if (word.startsWith("lnurl", true)) { - val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) - if (lnWithdrawal != null) { - ClickableWithdrawal(withdrawalString = lnWithdrawal) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - ClickablePhone(word) - } else if (isBechLink(word)) { - BechLink( - word, - canPreview, - backgroundColor, - accountViewModel, - navController - ) - } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { - TagLink( - word, - tags, - canPreview, - backgroundColor, - accountViewModel, - navController - ) - } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, navController) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else if (noProtocolUrlValidator.matcher(word).matches()) { - val matcher = noProtocolUrlValidator.matcher(word) - matcher.find() - val url = matcher.group(1) // url - val additionalChars = matcher.group(4) ?: "" // additional chars - - if (url != null) { - ClickableUrl(url, "https://$url") - Text("$additionalChars ") - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } else { + if (state.urlSet.contains(word)) { + ClickableUrl("$word ", word) + } else if (word.startsWith("lnurl", true)) { + val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) + if (lnWithdrawal != null) { + ClickableWithdrawal(withdrawalString = lnWithdrawal) } else { Text( text = "$word ", style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) ) } + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + ClickableEmail(word) + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + ClickablePhone(word) + } else if (isBechLink(word)) { + BechLink( + word, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } else if (word.startsWith("#")) { + if (tagIndex.matcher(word).matches() && tags != null) { + TagLink( + word, + tags, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } else if (hashTagsPattern.matcher(word).matches()) { + HashTag(word, navController) + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } else if (noProtocolUrlValidator.matcher(word).matches()) { + val matcher = noProtocolUrlValidator.matcher(word) + matcher.find() + val url = matcher.group(1) // url + val additionalChars = matcher.group(4) ?: "" // additional chars + + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) } } } @@ -291,21 +289,137 @@ fun RichTextViewer( } @Composable -private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? { - if (parsedNip19.type == Nip19.Type.USER) { - val userHex = parsedNip19.hex - val userBase = LocalCache.getOrCreateUser(userHex) +private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) { + 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 userState by userBase.live().metadata.observeAsState() - val displayName = userState?.user?.bestDisplayName() - if (displayName !== null) { - return displayName + var markdownWithSpecialContent by remember { mutableStateOf(null) } + var nip19References by remember { mutableStateOf>(emptyList()) } + var refresh by remember { mutableStateOf(0) } + + 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(null) } + var baseNote by remember { mutableStateOf(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 + ) } } - return null } -@Composable +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 + } +} + +private fun returnNIP19References(content: String): List { + val listOfReferences = mutableListOf() + 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 +} + private fun returnMarkdownWithSpecialContent(content: String): String { var returnContent = "" content.split('\n').forEach { paragraph -> @@ -319,7 +433,7 @@ private fun returnMarkdownWithSpecialContent(content: String): String { } else if (isBechLink(word)) { val parsedNip19 = Nip19.uriToRoute(word) returnContent += if (parsedNip19 !== null) { - val displayName = getDisplayNameFromUserNip19(parsedNip19) + val displayName = getDisplayNameFromNip19(parsedNip19) if (displayName != null) { "[@$displayName](nostr://$word) " } else { @@ -359,9 +473,9 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> 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), RoundedCornerShape(15.dp) ), - parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) - .compositeOver(backgroundColor), + parentBackgroundColor = backgroundColor, isQuotedNote = true, navController = navController ) @@ -530,8 +643,7 @@ fun TagLink(word: String, tags: List>, canPreview: Boolean, backgro MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp) ), - parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) - .compositeOver(backgroundColor), + parentBackgroundColor = backgroundColor, isQuotedNote = true, navController = navController ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt index dedb0cdd4..648922a35 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -92,7 +92,6 @@ object Robohash { .data("robohash:$message") .fetcherFactory(HashImageFetcher.Factory) .size(robotSize) - .crossfade(100) .build() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt index fd012bc0b..14fb7bd9f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext 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.model.UrlCachedPreviewer import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch @Composable fun UrlPreview(url: String, urlText: String) { @@ -28,11 +29,12 @@ fun UrlPreview(url: String, urlText: String) { val context = LocalContext.current var urlPreviewState by remember { mutableStateOf(default) } + val scope = rememberCoroutineScope() // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). LaunchedEffect(url) { if (urlPreviewState == UrlPreviewState.Loading) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { UrlCachedPreviewer.previewInfo( url, object : IUrlPreviewCallback { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index c4305a3c6..7a6441fcb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -60,8 +60,10 @@ import java.io.File public var muted = mutableStateOf(true) @Composable -fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { - VideoView(localFile.toUri(), description, onDialog) +fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { + if (localFile != null) { + VideoView(localFile.toUri(), description, onDialog) + } } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index c2139a7f0..5c34d567f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -75,7 +75,6 @@ import com.vitorpamplona.amethyst.ui.theme.Nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable import java.io.File @@ -112,7 +111,7 @@ class ZoomableUrlVideo( ) : ZoomableUrlContent(url, description, hash, dim, uri) abstract class ZoomablePreloadedContent( - val localFile: File, + val localFile: File?, description: String? = null, val mimeType: String? = null, val isVerified: Boolean? = null, @@ -121,7 +120,7 @@ abstract class ZoomablePreloadedContent( ) : ZoomableContent(description, dim) class ZoomableLocalImage( - localFile: File, + localFile: File?, mimeType: String? = null, description: String? = null, val blurhash: String? = null, @@ -131,7 +130,7 @@ class ZoomableLocalImage( ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) class ZoomableLocalVideo( - localFile: File, + localFile: File?, mimeType: String? = null, description: String? = null, dim: String? = null, @@ -210,7 +209,9 @@ private fun LocalImageView( mutableStateOf(null) } - val ratio = aspectRatio(content.dim) + val ratio = remember { + aspectRatio(content.dim) + } BoxWithConstraints(contentAlignment = Alignment.Center) { val myModifier = mainImageModifier.also { @@ -220,7 +221,7 @@ private fun LocalImageView( } val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth - if (content.localFile.exists()) { + if (content.localFile != null && content.localFile.exists()) { AsyncImage( model = content.localFile, 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() } } @@ -270,7 +271,9 @@ private fun UrlImageView( mutableStateOf(null) } - val ratio = aspectRatio(content.dim) + val ratio = remember { + aspectRatio(content.dim) + } LaunchedEffect(key1 = content.url, key2 = imageState) { if (imageState is AsyncImagePainter.State.Success) { @@ -337,9 +340,10 @@ private fun aspectRatio(dim: String?): Float? { @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { var cnt by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { delay(200) cnt = content } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index aa2921dce..74a104ae4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -1,21 +1,23 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note object ChannelFeedFilter : AdditiveFeedFilter() { lateinit var account: Account - lateinit var channel: Channel + var channelId: String? = null - fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) { - account = accountLoggedIn - channel = LocalCache.getOrCreateChannel(channelId) + fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) { + this.account = accountLoggedIn + this.channelId = channelId } // returns the last Note of each user. override fun feed(): List { + val processingChannel = channelId ?: return emptyList() + val channel = LocalCache.getOrCreateChannel(processingChannel) + return channel.notes .values .filter { account.isAcceptable(it) } @@ -24,6 +26,9 @@ object ChannelFeedFilter : AdditiveFeedFilter() { } override fun applyFilter(collection: Set): Set { + val processingChannel = channelId ?: return emptySet() + val channel = LocalCache.getOrCreateChannel(processingChannel) + return collection .filter { it.idHex in channel.notes.keys && account.isAcceptable(it) } .toSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index f96c68681..3cdb265f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -3,21 +3,22 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User object ChatroomFeedFilter : AdditiveFeedFilter() { var account: Account? = null - var withUser: User? = null + var withUser: String? = null fun loadMessagesBetween(accountIn: Account, userId: String) { account = accountIn - withUser = LocalCache.checkGetOrCreateUser(userId) + withUser = userId } // returns the last Note of each user. override fun feed(): List { + val processingUser = withUser ?: return emptyList() + val myAccount = account - val myUser = withUser + val myUser = LocalCache.checkGetOrCreateUser(processingUser) if (myAccount == null || myUser == null) return emptyList() @@ -32,8 +33,10 @@ object ChatroomFeedFilter : AdditiveFeedFilter() { } override fun applyFilter(collection: Set): Set { + val processingUser = withUser ?: return emptySet() + val myAccount = account - val myUser = withUser + val myUser = LocalCache.checkGetOrCreateUser(processingUser) if (myAccount == null || myUser == null) return emptySet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt index db26758a1..b2c0829fa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt @@ -9,9 +9,9 @@ object UserProfileBookmarksFeedFilter : FeedFilter() { lateinit var account: Account var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { + fun loadUserProfile(accountLoggedIn: Account, user: User?) { account = accountLoggedIn - user = LocalCache.users[userId] + this.user = user } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index 27ac42c82..681865c78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -9,9 +8,9 @@ object UserProfileConversationsFeedFilter : FeedFilter() { var account: Account? = null var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { + fun loadUserProfile(accountLoggedIn: Account, user: User?) { account = accountLoggedIn - user = LocalCache.checkGetOrCreateUser(userId) + this.user = user } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt index 2fe984757..d29148a21 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt @@ -8,9 +8,9 @@ object UserProfileFollowersFeedFilter : FeedFilter() { lateinit var account: Account var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { + fun loadUserProfile(accountLoggedIn: Account, user: User?) { account = accountLoggedIn - user = LocalCache.users[userId] + this.user = user } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt index 4afb38c7a..fbe4f54ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt @@ -8,9 +8,9 @@ object UserProfileFollowsFeedFilter : FeedFilter() { lateinit var account: Account var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { + fun loadUserProfile(accountLoggedIn: Account, user: User?) { account = accountLoggedIn - user = LocalCache.users[userId] + this.user = user } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 138e08217..327b40917 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -9,9 +9,9 @@ object UserProfileNewThreadFeedFilter : FeedFilter() { var account: Account? = null var user: User? = null - fun loadUserProfile(accountLoggedIn: Account, userId: String) { + fun loadUserProfile(accountLoggedIn: Account, user: User) { account = accountLoggedIn - user = LocalCache.checkGetOrCreateUser(userId) + this.user = user } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt index 025143a49..50716ac5b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt @@ -1,14 +1,13 @@ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User object UserProfileReportsFeedFilter : FeedFilter() { var user: User? = null - fun loadUserProfile(userId: String) { - user = LocalCache.checkGetOrCreateUser(userId) + fun loadUserProfile(user: User?) { + this.user = user } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt index 2e6b6ee7d..70cb680ed 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt @@ -1,6 +1,5 @@ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.zaps.UserZaps @@ -8,8 +7,8 @@ import com.vitorpamplona.amethyst.service.model.zaps.UserZaps object UserProfileZapsFeedFilter : FeedFilter>() { var user: User? = null - fun loadUserProfile(userId: String) { - user = LocalCache.checkGetOrCreateUser(userId) + fun loadUserProfile(user: User?) { + this.user = user } override fun feed(): List> { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 95048de58..26f872111 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlin.time.ExperimentalTime val bottomNavigationItems = listOf( @@ -159,15 +158,16 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac val notif = notifState.value ?: return var hasNewItems by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() LaunchedEffect(key1 = notif) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) } } LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { hasNewItems = route.hasNewItems(account, notif.cache, db) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 4415e4648..3cc324086 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource @@ -87,7 +88,7 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac @Composable fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultStoriesFollowList, true) { listName -> + FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName -> account.changeDefaultStoriesFollowList(listName) } } @@ -96,7 +97,7 @@ fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod @Composable fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultHomeFollowList, false) { listName -> + FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName -> account.changeDefaultHomeFollowList(listName) } } @@ -226,7 +227,7 @@ private fun LoggedInUserPictureDrawer( } @Composable -fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit) { +fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) { // Notification val dbState = LocalCache.live.observeAsState() val db = dbState.value ?: return @@ -244,7 +245,7 @@ fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit followLists = defaultOptions + LocalCache.addressables.mapNotNull { val event = (it.value.event as? PeopleListEvent) // 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()) } else { null @@ -254,7 +255,7 @@ fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit } SimpleTextSpinner( - placeholder = followLists.firstOrNull { it.first == listName }?.first ?: KIND3_FOLLOWS, + placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option", options = followNames.value, onSelect = { onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 5e4fef8b2..3f71245ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,11 +35,10 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.vitorpamplona.amethyst.NotificationCache 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.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -48,8 +48,8 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL val context = LocalContext.current.applicationContext - val noteEvent = note?.event var popupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() if (note == null) { BlankNote(Modifier, isInnerNote) @@ -57,7 +57,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL var isNew by remember { mutableStateOf(false) } LaunchedEffect(key1 = likeSetCard) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead) NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt()) @@ -71,20 +71,17 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL } Column( - modifier = Modifier.background(backgroundColor).combinedClickable( - onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { - note.channel()?.let { - navController.navigate("Channel/${it.idHex}") - } - } - }, - onLongClick = { popupExpanded = true } - ) + modifier = Modifier + .background(backgroundColor) + .combinedClickable( + onClick = { + routeFor( + note, + accountViewModel.userProfile() + )?.let { navController.navigate(it) } + }, + onLongClick = { popupExpanded = true } + ) ) { Row( modifier = Modifier @@ -104,7 +101,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL Icon( imageVector = Icons.Default.MilitaryTech, null, - modifier = Modifier.size(25.dp).align(Alignment.TopEnd), + modifier = Modifier + .size(25.dp) + .align(Alignment.TopEnd), tint = MaterialTheme.colors.primary ) } @@ -115,7 +114,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL Text( stringResource(R.string.new_badge_award_notif), fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 5.dp).weight(1f) + modifier = Modifier + .padding(bottom = 5.dp) + .weight(1f) ) Text( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index e25b5ba26..98aa24cdb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -74,7 +74,7 @@ fun HiddenNote(reports: Set, loggedIn: User, modifier: Modifier = Modifier FlowRow(modifier = Modifier.padding(top = 10.dp)) { reports.forEach { NoteAuthorPicture( - note = it, + baseNote = it, navController = navController, userAccount = loggedIn, size = 35.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt index da4d768e0..107e02fe1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt @@ -28,7 +28,6 @@ import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache 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.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers @@ -68,15 +67,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { - note.channel()?.let { - navController.navigate("Channel/${it.idHex}") - } - } + routeFor(note, account.userProfile())?.let { navController.navigate(it) } }, onLongClick = { popupExpanded = true } ) @@ -109,7 +100,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro FlowRow() { boostSetCard.boostEvents.forEach { NoteAuthorPicture( - note = it, + baseNote = it, navController = navController, userAccount = account.userProfile(), size = 35.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index 246b1fef2..b4711e887 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch @Composable fun ChatroomCompose( @@ -64,6 +65,8 @@ fun ChatroomCompose( val notificationCacheState = NotificationCache.live.observeAsState() val notificationCache = notificationCacheState.value ?: return + val scope = rememberCoroutineScope() + if (note?.event == null) { BlankNote(Modifier) } else if (note.channel() != null) { @@ -86,7 +89,7 @@ fun ChatroomCompose( var hasNewMessages by remember { mutableStateOf(false) } LaunchedEffect(key1 = notificationCache, key2 = note) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { note.createdAt()?.let { timestamp -> hasNewMessages = timestamp > notificationCache.cache.load("Channel/${chan.idHex}") @@ -131,7 +134,7 @@ fun ChatroomCompose( } else { val replyAuthorBase = (note.event as? PrivateDmEvent) - ?.recipientPubKey() + ?.verifiedRecipientPubKey() ?.let { LocalCache.getOrCreateUser(it) } var userToComposeOn = note.author!! @@ -148,7 +151,7 @@ fun ChatroomCompose( var hasNewMessages by remember { mutableStateOf(false) } LaunchedEffect(key1 = notificationCache, key2 = note) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { noteEvent?.let { hasNewMessages = it.createdAt() > notificationCache.cache.load( "Room/${userToComposeOn.pubkeyHex}" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 290988501..21e7bb60c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.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) } val context = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() if (note?.event == null) { BlankNote(Modifier) @@ -135,7 +137,7 @@ fun ChatroomMessageCompose( LaunchedEffect(key1 = routeForLastRead) { routeForLastRead?.let { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { val lastTime = NotificationCache.load(it) val createdAt = note.createdAt() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt index 2d347b9f7..9436512a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt @@ -28,7 +28,6 @@ import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache 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.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers @@ -68,15 +67,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { - note.channel()?.let { - navController.navigate("Channel/${it.idHex}") - } - } + routeFor(note, account.userProfile())?.let { navController.navigate(it) } }, onLongClick = { popupExpanded = true } ) @@ -109,7 +100,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route FlowRow() { likeSetCard.likeEvents.forEach { NoteAuthorPicture( - note = it, + baseNote = it, navController = navController, userAccount = account.userProfile(), size = 35.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 8deb5bcb1..094f6f033 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,13 +27,10 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.vitorpamplona.amethyst.NotificationCache 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.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -40,20 +38,25 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal val noteState by messageSetCard.note.live().metadata.observeAsState() val note = noteState?.note - val noteEvent = note?.event var popupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + if (note == null) { BlankNote(Modifier, isInnerNote) } else { var isNew by remember { mutableStateOf(false) } LaunchedEffect(key1 = messageSetCard.createdAt()) { - withContext(Dispatchers.IO) { - isNew = + scope.launch(Dispatchers.IO) { + val newIsNew = messageSetCard.createdAt() > NotificationCache.load(routeForLastRead) NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt()) + + if (newIsNew != isNew) { + isNew = newIsNew + } } } @@ -66,30 +69,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent is ChannelMessageEvent) { - 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 - } - } + routeFor(note, accountViewModel.userProfile())?.let { navController.navigate(it) } }, 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)) { NoteCompose( - baseNote = note, + baseNote = messageSetCard.note, routeForLastRead = null, isBoostedNote = true, addMarginTop = false, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 95433ce74..7b454639a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box 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.material.Icon import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt import androidx.compose.runtime.Composable @@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.Note 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.PrivateDmEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -56,19 +57,24 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return - val noteEvent = note?.event var popupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + if (note == null) { BlankNote(Modifier, false) } else { var isNew by remember { mutableStateOf(false) } LaunchedEffect(key1 = multiSetCard.createdAt()) { - withContext(Dispatchers.IO) { - isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) + scope.launch(Dispatchers.IO) { + val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt) + + if (newIsNew != isNew) { + isNew = newIsNew + } } } @@ -83,32 +89,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun .background(backgroundColor) .combinedClickable( onClick = { - if (noteEvent is ChannelMessageEvent) { - 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 - } - } + routeFor(note, account.userProfile())?.let { navController.navigate(it) } }, onLongClick = { popupExpanded = true } ) @@ -187,15 +168,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } Row(Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .width(65.dp) - .padding(0.dp) - ) { - } + Spacer(modifier = Modifier.width(65.dp)) NoteCompose( - baseNote = note, + baseNote = multiSetCard.note, routeForLastRead = null, modifier = Modifier.padding(top = 5.dp), isBoostedNote = true, @@ -225,7 +201,7 @@ fun AuthorGalleryZaps( Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { authorNotes.forEach { - AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel) + AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel) } } } @@ -234,7 +210,6 @@ fun AuthorGalleryZaps( @Composable private fun AuthorPictureAndComment( zapRequest: Note, - zapEvent: Note, navController: NavController, accountUser: User, accountViewModel: AccountViewModel @@ -242,16 +217,19 @@ private fun AuthorPictureAndComment( val author = zapRequest.author ?: return var content by remember { mutableStateOf>(Pair(author, null)) } + val scope = rememberCoroutineScope() LaunchedEffect(key1 = zapRequest.idHex) { - (zapRequest.event as? LnZapRequestEvent)?.let { - val decryptedContent = accountViewModel.decryptZap(zapRequest) - if (decryptedContent != null) { - val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) - content = Pair(author, decryptedContent.content) - } else { - if (!zapRequest.event?.content().isNullOrBlank()) { - content = Pair(author, zapRequest.event?.content()) + scope.launch(Dispatchers.IO) { + (zapRequest.event as? LnZapRequestEvent)?.let { + val decryptedContent = accountViewModel.decryptZap(zapRequest) + if (decryptedContent != null) { + val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + content = Pair(author, decryptedContent.content) + } else { + if (!zapRequest.event?.content().isNullOrBlank()) { + content = Pair(author, zapRequest.event?.content()) + } } } } @@ -274,10 +252,14 @@ private fun AuthorPictureAndComment( Modifier } - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = modifier.clickable { + navController.navigate("User/${author.pubkeyHex}") + }, + verticalAlignment = Alignment.CenterVertically + ) { FastNoteAuthorPicture( author = author, - navController = navController, userAccount = accountUser, size = 35.dp ) @@ -309,12 +291,16 @@ fun AuthorGallery( Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { - authorNotes.forEach { + authorNotes.take(50).forEach { val author = it.author if (author != null) { AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel) } } + + if (authorNotes.size > 50) { + Text(" and ${authorNotes.size - 50} others") + } } } } @@ -322,7 +308,6 @@ fun AuthorGallery( @Composable fun FastNoteAuthorPicture( author: User, - navController: NavController, userAccount: User, size: Dp, pictureModifier: Modifier = Modifier @@ -331,14 +316,12 @@ fun FastNoteAuthorPicture( val user = userState?.user ?: return val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount + UserPicture( userHex = user.pubkeyHex, userPicture = user.profilePicture(), showFollowingMark = showFollowingMark, size = size, - modifier = pictureModifier, - onClick = { - navController.navigate("User/${user.pubkeyHex}") - } + modifier = pictureModifier ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index cebfe1fdd..31dc03ec1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -92,64 +92,75 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie val userState by baseUser.live().metadata.observeAsState() val user = userState?.user ?: return - val uri = LocalUriHandler.current - user.nip05()?.let { nip05 -> - if (nip05.split("@").size == 2) { + val parts = nip05.split("@") + if (parts.size == 2) { + val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) + Column(modifier = columnModifier) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (nip05.split("@")[0] != "_") { - Text( - text = AnnotatedString(nip05.split("@")[0]), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) - if (nip05Verified == null) { - Icon( - tint = Color.Yellow, - imageVector = Icons.Default.Downloading, - contentDescription = "Downloading", - modifier = Modifier - .size(14.dp) - .padding(top = 1.dp) - ) - } else if (nip05Verified == true) { - Icon( - painter = painterResource(R.drawable.ic_verified), - "NIP-05 Verified", - tint = Nip05.copy(0.52f), - modifier = Modifier - .size(14.dp) - .padding(top = 1.dp) - ) - } else { - Icon( - tint = Color.Red, - imageVector = Icons.Default.Report, - contentDescription = "Invalid Nip05", - modifier = Modifier - .size(14.dp) - .padding(top = 1.dp) - ) - } - - ClickableText( - text = AnnotatedString(nip05.split("@")[1]), - onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)), - maxLines = 1, - overflow = TextOverflow.Visible - ) - } + DisplayNIP05(parts[0], parts[1], nip05Verified) } } } } +@Composable +private fun DisplayNIP05( + user: String, + domain: String, + nip05Verified: Boolean? +) { + val uri = LocalUriHandler.current + + Row(verticalAlignment = Alignment.CenterVertically) { + if (user != "_") { + Text( + text = AnnotatedString(user), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (nip05Verified == null) { + Icon( + tint = Color.Yellow, + imageVector = Icons.Default.Downloading, + contentDescription = "Downloading", + modifier = Modifier + .size(14.dp) + .padding(top = 1.dp) + ) + } else if (nip05Verified == true) { + Icon( + painter = painterResource(R.drawable.ic_verified), + "NIP-05 Verified", + tint = Nip05.copy(0.52f), + modifier = Modifier + .size(14.dp) + .padding(top = 1.dp) + ) + } else { + Icon( + tint = Color.Red, + imageVector = Icons.Default.Report, + contentDescription = "Invalid Nip05", + modifier = Modifier + .size(14.dp) + .padding(top = 1.dp) + ) + } + + ClickableText( + text = AnnotatedString(domain), + onClick = { runCatching { uri.openUri("https://$domain") } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)), + maxLines = 1, + overflow = TextOverflow.Visible + ) + } +} + @Composable fun DisplayNip05ProfileStatus(user: User) { val uri = LocalUriHandler.current diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index eff2915bd..d552c92a3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -3,19 +3,50 @@ package com.vitorpamplona.amethyst.ui.note import android.content.Intent import android.graphics.Bitmap import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.* +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.darkColors import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.runtime.* +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,6 +73,7 @@ import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.get import androidx.navigation.NavController import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R @@ -50,15 +82,46 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.ui.components.* +import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent +import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.EventInterface +import com.vitorpamplona.amethyst.service.model.FileHeaderEvent +import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent +import com.vitorpamplona.amethyst.service.model.HighlightEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.model.ReactionEvent +import com.vitorpamplona.amethyst.service.model.ReportEvent +import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.CreateClickableText +import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableContent +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage +import com.vitorpamplona.amethyst.ui.components.ZoomableLocalVideo +import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage +import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo +import com.vitorpamplona.amethyst.ui.components.imageExtensions import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import nostr.postr.toNpub import java.io.File import java.math.BigDecimal import java.net.URL @@ -99,7 +162,7 @@ fun NoteCompose( Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable fun NoteComposeInner( baseNote: Note, @@ -114,9 +177,6 @@ fun NoteComposeInner( accountViewModel: AccountViewModel, navController: NavController ) { - val defaultBackgroundColor = MaterialTheme.colors.background - val defaultPrimaryColor = MaterialTheme.colors.primary - val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return val loggedIn = account.userProfile() @@ -130,20 +190,19 @@ fun NoteComposeInner( var popupExpanded by remember { mutableStateOf(false) } var showHiddenNote by remember { mutableStateOf(false) } - val context = LocalContext.current.applicationContext - - var moreActionsExpanded by remember { mutableStateOf(false) } - - var isAcceptable by remember { mutableStateOf(true) } - var canPreview by remember { mutableStateOf(true) } + var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } LaunchedEffect(key1 = noteReportsState) { withContext(Dispatchers.IO) { - canPreview = note?.author === loggedIn || + val newCanPreview = note?.author === loggedIn || (note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) || !noteForReports.hasAnyReports() - isAcceptable = account.isAcceptable(noteForReports) + val newIsAcceptable = account.isAcceptable(noteForReports) + + if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) { + isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview) + } } } @@ -158,7 +217,7 @@ fun NoteComposeInner( ), isBoostedNote ) - } else if (!isAcceptable && !showHiddenNote) { + } else if (!isAcceptableAndCanPreview.first && !showHiddenNote) { if (!account.isHidden(noteForReports.author!!)) { HiddenNote( account.getRelevantReports(noteForReports), @@ -178,63 +237,47 @@ fun NoteComposeInner( } else if (noteEvent is FileStorageHeaderEvent) { FileStorageHeaderDisplay(note) } else { - var backgroundColor by remember { mutableStateOf(defaultBackgroundColor) } + var isNew by remember { mutableStateOf(false) } + + val scope = rememberCoroutineScope() LaunchedEffect(key1 = routeForLastRead) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { routeForLastRead?.let { val lastTime = NotificationCache.load(it) val createdAt = note.createdAt() - val isNew = if (createdAt != null) { + if (createdAt != null) { NotificationCache.markAsRead(it, createdAt) - createdAt > lastTime - } else { - false - } - backgroundColor = if (isNew) { - val newColor = defaultPrimaryColor.copy(0.12f) - if (parentBackgroundColor != null) { - newColor.compositeOver(parentBackgroundColor) - } else { - newColor.compositeOver(defaultBackgroundColor) + val newIsNew = createdAt > lastTime + if (newIsNew != isNew) { + isNew = newIsNew } - } else { - parentBackgroundColor ?: defaultBackgroundColor } } } } + val backgroundColor = if (isNew) { + val newColor = MaterialTheme.colors.primary.copy(0.12f) + if (parentBackgroundColor != null) { + newColor.compositeOver(parentBackgroundColor) + } else { + newColor.compositeOver(MaterialTheme.colors.background) + } + } else { + parentBackgroundColor ?: MaterialTheme.colors.background + } + Column( - modifier = modifier.combinedClickable( - onClick = { - if (noteEvent is ChannelMessageEvent) { - baseChannel?.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}") - } - }, - onLongClick = { popupExpanded = true } - ) + modifier = modifier + .combinedClickable( + onClick = { + routeFor(note, loggedIn)?.let { navController.navigate(it) } + }, + onLongClick = { popupExpanded = true } + ) .background(backgroundColor) ) { Row( @@ -250,281 +293,88 @@ fun NoteComposeInner( } Column( - modifier = Modifier.padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp) + modifier = Modifier + .padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isQuotedNote) { - NoteAuthorPicture(note, navController, loggedIn, 25.dp) - Spacer(Modifier.padding(horizontal = 5.dp)) - NoteUsernameDisplay(note, Modifier.weight(1f)) - } else { - NoteUsernameDisplay(note, Modifier.weight(1f)) - } + FirstUserInfoRow( + baseNote = baseNote, + showAuthorPicture = isQuotedNote, + account = account, + accountViewModel = accountViewModel, + navController = navController + ) - if (noteEvent is RepostEvent) { - Text( - " ${stringResource(id = R.string.boosted)}", - fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } else { - DisplayFollowingHashtagsInPost(noteEvent, account, navController) - } - - Text( - timeAgo(note.createdAt(), context = context), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - maxLines = 1 + if (noteEvent !is RepostEvent && !makeItShort && !isQuotedNote) { + SecondUserInfoRow( + note, + account, + navController ) - - IconButton( - modifier = Modifier.size(24.dp), - onClick = { moreActionsExpanded = true } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - - NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel) - } - } - - if (noteEvent !is RepostEvent && note.author != null && !makeItShort && !isQuotedNote) { - Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f)) - - val baseReward = noteEvent.getReward() - if (baseReward != null) { - DisplayReward(baseReward, baseNote, account, navController) - } - - val pow = noteEvent.getPoWRank() - if (pow > 20) { - DisplayPoW(pow) - } - } } Spacer(modifier = Modifier.height(2.dp)) - if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { - val replyingDirectlyTo = note.replyTo?.lastOrNull() - if (replyingDirectlyTo != null && unPackReply) { - NoteCompose( - baseNote = replyingDirectlyTo, - isQuotedNote = true, - modifier = Modifier - .padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ), - unPackReply = false, - makeItShort = true, - parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor), - accountViewModel = accountViewModel, - navController = navController - ) - } else { - ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController) - } - Spacer(modifier = Modifier.height(5.dp)) - } else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { - val sortedMentions = noteEvent.mentions() - .mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { loggedIn.isFollowingCached(it) } - - note.channel()?.let { - ReplyInformationChannel(note.replyTo, sortedMentions, it, navController) - } - Spacer(modifier = Modifier.height(5.dp)) + if (!makeItShort) { + ReplyRow( + note, + unPackReply, + backgroundColor, + account, + accountViewModel, + navController + ) } - if (noteEvent is ReactionEvent || noteEvent is RepostEvent) { - note.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - navController = navController + when (noteEvent) { + is ReactionEvent -> { + RenderReaction(note, backgroundColor, accountViewModel, navController) + } + + is RepostEvent -> { + RenderRepost(note, backgroundColor, accountViewModel, navController) + } + + is ReportEvent -> { + RenderReport(note) + } + + is LongTextNoteEvent -> { + RenderLongFormContent(note, loggedIn, accountViewModel, navController) + } + + is BadgeAwardEvent -> { + RenderBadgeAward(note, backgroundColor, accountViewModel, navController) + } + + is PrivateDmEvent -> { + RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) + } + + is HighlightEvent -> { + RenderHighlight(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) + } + + is PollNoteEvent -> { + RenderPoll( + note, + makeItShort, + isAcceptableAndCanPreview.second, + backgroundColor, + accountViewModel, + navController ) } - // Reposts have trash in their contents. - if (noteEvent is ReactionEvent) { - val refactorReactionText = - if (noteEvent.content == "+") "❤" else noteEvent.content - - Text( - text = refactorReactionText + else -> { + RenderTextEvent( + note, + makeItShort, + isAcceptableAndCanPreview.second, + backgroundColor, + accountViewModel, + navController ) } - } else if (noteEvent is ReportEvent) { - val reportType = (noteEvent.reportedPost() + noteEvent.reportedAuthor()).map { - when (it.reportType) { - ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) - ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) - ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) - ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) - ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) - ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) - } - }.toSet().joinToString(", ") - - Text( - text = reportType - ) - - Divider( - modifier = Modifier.padding(top = 40.dp), - thickness = 0.25.dp - ) - } else if (noteEvent is LongTextNoteEvent) { - LongFormHeader(noteEvent, note, loggedIn) - - ReactionsRow(note, accountViewModel, navController) - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = 0.25.dp - ) - } else if (noteEvent is BadgeAwardEvent && !note.replyTo.isNullOrEmpty()) { - Text(text = stringResource(R.string.award_granted_to)) - - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - noteEvent.awardees() - .map { LocalCache.getOrCreateUser(it) } - .forEach { - UserPicture( - user = it, - navController = navController, - userAccount = loggedIn, - size = 35.dp - ) - } - } - - note.replyTo?.firstOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = false, - isQuotedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - navController = navController - ) - } - - ReactionsRow(note, accountViewModel, navController) - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = 0.25.dp - ) - } else if (noteEvent is PrivateDmEvent && - noteEvent.recipientPubKey() != loggedIn.pubkeyHex && - note.author !== loggedIn - ) { - val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } - - TranslatableRichTextViewer( - stringResource( - id = R.string.private_conversation_notification, - "@${note.author?.pubkeyNpub()}", - "@${recipient?.pubkeyNpub()}" - ), - canPreview = !makeItShort, - Modifier.fillMaxWidth(), - noteEvent.tags(), - backgroundColor, - accountViewModel, - navController - ) - - if (!makeItShort) { - ReactionsRow(note, accountViewModel, navController) - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = 0.25.dp - ) - } else if (noteEvent is HighlightEvent) { - DisplayHighlight( - noteEvent.quote(), - noteEvent.author(), - noteEvent.inUrl(), - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - navController - ) - - if (!makeItShort) { - ReactionsRow(note, accountViewModel, navController) - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = 0.25.dp - ) - } else { - val eventContent = accountViewModel.decrypt(note) - - if (eventContent != null) { - if (makeItShort && note.author == loggedIn) { - Text( - text = eventContent, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } else { - TranslatableRichTextViewer( - eventContent, - canPreview = canPreview && !makeItShort, - Modifier.fillMaxWidth(), - noteEvent.tags(), - backgroundColor, - accountViewModel, - navController - ) - - DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) - } - - if (noteEvent is PollNoteEvent) { - PollNote( - note, - canPreview = canPreview && !makeItShort, - backgroundColor, - accountViewModel, - navController - ) - } - } - - if (!makeItShort) { - ReactionsRow(note, accountViewModel, navController) - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = 0.25.dp - ) } NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) @@ -534,9 +384,536 @@ fun NoteComposeInner( } } +fun routeFor(note: Note, loggedIn: User): String? { + val noteEvent = note.event + + if (noteEvent is ChannelMessageEvent || noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) { + note.channel()?.let { + return "Channel/${it.idHex}" + } + } else if (noteEvent is PrivateDmEvent) { + val replyAuthorBase = + (note.event as? PrivateDmEvent) + ?.verifiedRecipientPubKey() + ?.let { LocalCache.getOrCreateUser(it) } + + var userToComposeOn = note.author!! + + if (replyAuthorBase != null) { + if (note.author == loggedIn) { + userToComposeOn = replyAuthorBase + } + } + + return "Room/${userToComposeOn.pubkeyHex}" + } else { + return "Note/${note.idHex}" + } + + return null +} + +@Composable +private fun RenderTextEvent( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event ?: return + + val eventContent = remember { accountViewModel.decrypt(note) } + val modifier = remember { Modifier.fillMaxWidth() } + + if (eventContent != null) { + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } else { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = noteEvent.tags(), + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + } + } + + if (!makeItShort) { + ReactionsRow(note, accountViewModel, navController) + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +private fun RenderPoll( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event as? PollNoteEvent ?: return + val eventContent = noteEvent.content() + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } else { + TranslatableRichTextViewer( + eventContent, + canPreview = canPreview && !makeItShort, + Modifier.fillMaxWidth(), + noteEvent.tags(), + backgroundColor, + accountViewModel, + navController + ) + + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + + PollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + navController + ) + } + + if (!makeItShort) { + ReactionsRow(note, accountViewModel, navController) + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +private fun RenderHighlight( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event as? HighlightEvent ?: return + + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + + if (!makeItShort) { + ReactionsRow(note, accountViewModel, navController) + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +private fun RenderPrivateMessage( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event as? PrivateDmEvent ?: return + val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + + if (withMe) { + val eventContent = remember { accountViewModel.decrypt(note) } + + if (eventContent != null) { + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } else { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = noteEvent.tags(), + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + } + } + } else { + val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" + + TranslatableRichTextViewer( + stringResource( + id = R.string.private_conversation_notification, + "@${note.author?.pubkeyNpub()}", + "@$recipient" + ), + canPreview = !makeItShort, + Modifier.fillMaxWidth(), + noteEvent.tags(), + backgroundColor, + accountViewModel, + navController + ) + } + + if (!makeItShort) { + ReactionsRow(note, accountViewModel, navController) + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +private fun RenderBadgeAward( + note: Note, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + if (note.replyTo.isNullOrEmpty()) return + + val noteEvent = note.event as? BadgeAwardEvent ?: return + var awardees by remember { mutableStateOf>(listOf()) } + + val account = accountViewModel.userProfile() + + Text(text = stringResource(R.string.award_granted_to)) + + LaunchedEffect(key1 = note) { + withContext(Dispatchers.IO) { + awardees = noteEvent.awardees().mapNotNull { hex -> + LocalCache.checkGetOrCreateUser(hex) + }.sortedBy { account.isFollowing(it) }.reversed() + } + } + + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + awardees.take(100).forEach { user -> + Row( + modifier = Modifier.size(size = 35.dp).clickable { + navController.navigate("User/${user.pubkeyHex}") + }, + verticalAlignment = Alignment.CenterVertically + ) { + UserPicture( + baseUser = user, + baseUserAccount = accountViewModel.userProfile(), + size = 35.dp + ) + } + } + + if (awardees.size > 100) { + Text(" and ${awardees.size - 100} others") + } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = false, + isQuotedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + } + + ReactionsRow(note, accountViewModel, navController) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +private fun RenderReaction( + note: Note, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + note.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + } + + // Reposts have trash in their contents. + val refactorReactionText = + if (note.event?.content() == "+") "❤" else note.event?.content() ?: "" + + Text( + text = refactorReactionText + ) +} + +@Composable +private fun RenderRepost( + note: Note, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + note.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + } +} + +@Composable +private fun RenderLongFormContent( + note: Note, + loggedIn: User, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event as? LongTextNoteEvent ?: return + + LongFormHeader(noteEvent, note, loggedIn) + + ReactionsRow(note, accountViewModel, navController) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +private fun RenderReport(note: Note) { + val noteEvent = note.event as? ReportEvent ?: return + + val reportType = (noteEvent.reportedPost() + noteEvent.reportedAuthor()).map { + when (it.reportType) { + ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) + ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) + ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) + ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) + ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) + ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) + } + }.toSet().joinToString(", ") + + Text( + text = reportType + ) + + Divider( + modifier = Modifier.padding(top = 40.dp), + thickness = 0.25.dp + ) +} + +@Composable +private fun ReplyRow( + note: Note, + unPackReply: Boolean, + backgroundColor: Color, + account: Account, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event + + if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) { + val replyingDirectlyTo = note.replyTo?.lastOrNull() + if (replyingDirectlyTo != null && unPackReply) { + NoteCompose( + baseNote = replyingDirectlyTo, + isQuotedNote = true, + modifier = Modifier + .padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ), + unPackReply = false, + makeItShort = true, + parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) + .compositeOver(backgroundColor), + accountViewModel = accountViewModel, + navController = navController + ) + } else { + ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController) + } + + Spacer(modifier = Modifier.height(5.dp)) + } else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) { + note.channel()?.let { + ReplyInformationChannel(note.replyTo, noteEvent.mentions(), it, account, navController) + } + + Spacer(modifier = Modifier.height(5.dp)) + } +} + +@Composable +private fun SecondUserInfoRow( + note: Note, + account: Account, + navController: NavController +) { + val noteEvent = note.event ?: return + val noteAuthor = note.author ?: return + + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status(noteAuthor, Modifier.weight(1f)) + + val baseReward = noteEvent.getReward() + if (baseReward != null) { + DisplayReward(baseReward, note, account, navController) + } + + val pow = noteEvent.getPoWRank() + if (pow > 20) { + DisplayPoW(pow) + } + } +} + +@Composable +private fun FirstUserInfoRow( + baseNote: Note, + showAuthorPicture: Boolean, + account: Account, + accountViewModel: AccountViewModel, + navController: NavController +) { + var moreActionsExpanded by remember { mutableStateOf(false) } + val eventNote = baseNote.event ?: return + val time = baseNote.createdAt() ?: return + val loggedIn = account.userProfile() + + Row(verticalAlignment = Alignment.CenterVertically) { + if (showAuthorPicture) { + NoteAuthorPicture(baseNote, navController, loggedIn, 25.dp) + Spacer(Modifier.padding(horizontal = 5.dp)) + NoteUsernameDisplay(baseNote, Modifier.weight(1f)) + } else { + NoteUsernameDisplay(baseNote, Modifier.weight(1f)) + } + + if (eventNote is RepostEvent) { + Text( + " ${stringResource(id = R.string.boosted)}", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } else { + DisplayFollowingHashtagsInPost(eventNote, account, navController) + } + + TimeAgo(time) + + IconButton( + modifier = Modifier.size(24.dp), + onClick = { moreActionsExpanded = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + NoteDropDownMenu( + baseNote, + moreActionsExpanded, + { moreActionsExpanded = false }, + accountViewModel + ) + } + } +} + +@Composable +fun TimeAgo(time: Long) { + val context = LocalContext.current + + var timeStr by remember { mutableStateOf("") } + + LaunchedEffect(key1 = time) { + withContext(Dispatchers.IO) { + timeStr = timeAgo(time, context = context) + } + } + + Text( + timeStr, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1 + ) +} + @Composable private fun DrawAuthorImages(baseNote: Note, loggedIn: User, navController: NavController) { - val baseChannel = baseNote.channel() + val baseChannel = remember { baseNote.channel() } Column(Modifier.width(55.dp)) { // Draws the boosted picture outside the boosted card. @@ -630,25 +1007,37 @@ fun DisplayHighlight( accountViewModel: AccountViewModel, navController: NavController ) { - val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n") + val quote = + remember { + highlight + .split("\n") + .map { "> *${it.removeSuffix(" ")}*" } + .joinToString("\n") + } - if (quote != null) { - TranslatableRichTextViewer( - quote, - canPreview = canPreview && !makeItShort, - Modifier.fillMaxWidth(), - emptyList(), - backgroundColor, - accountViewModel, - navController - ) + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + Modifier.fillMaxWidth(), + emptyList(), + backgroundColor, + accountViewModel, + navController + ) + + var userBase by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + if (authorHex != null) { + userBase = LocalCache.checkGetOrCreateUser(authorHex) + } + } } FlowRow() { authorHex?.let { authorHex -> - val userBase = LocalCache.checkGetOrCreateUser(authorHex) - - if (userBase != null) { + userBase?.let { userBase -> val userState by userBase.live().metadata.observeAsState() val user = userState?.user @@ -687,7 +1076,7 @@ fun DisplayFollowingHashtagsInPost( ) { var firstTag by remember { mutableStateOf(null) } - LaunchedEffect(key1 = noteEvent) { + LaunchedEffect(key1 = noteEvent.id()) { withContext(Dispatchers.IO) { firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet()) } @@ -826,7 +1215,24 @@ fun DisplayReward( fun BadgeDisplay(baseNote: Note) { val background = MaterialTheme.colors.background val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return - var backgroundFromImage by remember { mutableStateOf(background) } + + val image = remember { badgeData.image() } + val name = remember { badgeData.name() } + val description = remember { badgeData.description() } + + var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } + var imageResult by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = imageResult) { + withContext(Dispatchers.IO) { + imageResult?.let { + val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) + val colorFromImage = Color(backgroundColor) + val textBackground = if (colorFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground + backgroundFromImage = Pair(colorFromImage, textBackground) + } + } + } Row( modifier = Modifier @@ -837,26 +1243,25 @@ fun BadgeDisplay(baseNote: Note) { MaterialTheme.colors.primary.copy(alpha = 0.32f), CutCornerShape(20) ) - .background(backgroundFromImage) + .background(backgroundFromImage.first) ) { Column { - badgeData.image()?.let { + image.let { AsyncImage( model = it, contentDescription = stringResource( R.string.badge_award_image_for, - it + name ?: "" ), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth(), onSuccess = { - val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) - backgroundFromImage = Color(backgroundColor) + imageResult = it } ) } - badgeData.name()?.let { + name?.let { Text( text = it, style = MaterialTheme.typography.body1, @@ -864,11 +1269,11 @@ fun BadgeDisplay(baseNote: Note) { modifier = Modifier .fillMaxWidth() .padding(start = 10.dp, end = 10.dp), - color = if (backgroundFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground + color = backgroundFromImage.second ) } - badgeData.description()?.let { + description?.let { Text( text = it, style = MaterialTheme.typography.caption, @@ -919,20 +1324,23 @@ fun FileStorageHeaderDisplay(baseNote: Note) { val appContext = LocalContext.current.applicationContext val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return - val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + var fileNote by remember { mutableStateOf(null) } - val noteState by fileNote.live().metadata.observeAsState() - val note = noteState?.note + LaunchedEffect(key1 = eventHeader.id) { + withContext(Dispatchers.IO) { + fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } + } + } - val eventBytes = (note?.event as? FileStorageEvent) + val noteState = fileNote?.live()?.metadata?.observeAsState() + val note = noteState?.value?.note var content by remember { mutableStateOf(null) } - LaunchedEffect(key1 = eventHeader.id, key2 = noteState) { + LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) { withContext(Dispatchers.IO) { val uri = "nostr:" + baseNote.toNEvent() - val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex) - val bytes = eventBytes?.decode() + val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) } val blurHash = eventHeader.blurhash() val dimensions = eventHeader.dimensions() val description = eventHeader.content @@ -949,18 +1357,14 @@ fun FileStorageHeaderDisplay(baseNote: Note) { uri = uri ) } else { - if (bytes != null) { - ZoomableLocalVideo( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - isVerified = true, - uri = uri - ) - } else { - null - } + ZoomableLocalVideo( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + isVerified = true, + uri = uri + ) } } } @@ -1164,13 +1568,13 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) { @Composable fun NoteAuthorPicture( - note: Note, + baseNote: Note, navController: NavController, userAccount: User, size: Dp, pictureModifier: Modifier = Modifier ) { - NoteAuthorPicture(note, userAccount, size, pictureModifier) { + NoteAuthorPicture(baseNote, userAccount, size, pictureModifier) { navController.navigate("User/${it.pubkeyHex}") } } @@ -1223,6 +1627,7 @@ fun UserPicture( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun UserPicture( baseUser: User, @@ -1240,27 +1645,38 @@ fun UserPicture( val showFollowingMark = accountUser.isFollowingCached(user) || user == accountUser - UserPicture( - userHex = user.pubkeyHex, - userPicture = user.profilePicture(), - showFollowingMark = showFollowingMark, - size = size, - modifier = modifier, - onClick = onClick?.let { { it(user) } }, - onLongClick = onLongClick?.let { { it(user) } } - ) + Row( + modifier = Modifier + .run { + if (onClick != null && onLongClick != null) { + this.combinedClickable( + onClick = { onClick(user) }, + onLongClick = { onLongClick(user) } + ) + } else if (onClick != null) { + this.clickable(onClick = { onClick(user) }) + } else { + this + } + } + ) { + UserPicture( + userHex = user.pubkeyHex, + userPicture = user.profilePicture(), + showFollowingMark = showFollowingMark, + size = size, + modifier = modifier + ) + } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun UserPicture( userHex: String, userPicture: String?, showFollowingMark: Boolean, size: Dp, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, - onLongClick: (() -> Unit)? = null + modifier: Modifier = Modifier ) { Box( Modifier @@ -1276,16 +1692,6 @@ fun UserPicture( .height(size) .clip(shape = CircleShape) .background(MaterialTheme.colors.background) - .run { - if (onClick != null && onLongClick != null) { - this.combinedClickable(onClick = onClick, onLongClick = onLongClick) - } else if (onClick != null) { - this.clickable(onClick = onClick) - } else { - this - } - } - ) if (showFollowingMark) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index b5a4a91f2..d5cbcd80b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -92,18 +92,20 @@ private fun OptionNote( var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) } LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) { - val myTally = pollViewModel.optionVoteTally(optionNumber) - val color = if ( - pollViewModel.consensusThreshold != null && - myTally >= pollViewModel.consensusThreshold!! - ) { - Color.Green.copy(alpha = 0.32f) - } else { - defaultColor - } + withContext(Dispatchers.IO) { + val myTally = pollViewModel.optionVoteTally(optionNumber) + val color = if ( + pollViewModel.consensusThreshold != null && + myTally >= pollViewModel.consensusThreshold!! + ) { + Color.Green.copy(alpha = 0.32f) + } else { + defaultColor + } - if (myTally > optionTally.first || color != optionTally.second) { - optionTally = Pair(myTally, color) + if (myTally > optionTally.first || color != optionTally.second) { + optionTally = Pair(myTally, color) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index cc3591e3e..77cf78c93 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -61,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.math.BigDecimal import java.math.RoundingMode import kotlin.math.roundToInt @@ -319,21 +318,22 @@ fun ZapReaction( var zappingProgress by remember { mutableStateOf(0f) } var wasZappedByLoggedInUser by remember { mutableStateOf(false) } - var zapAmount by remember { mutableStateOf(null) } + var zapAmountTxt by remember { mutableStateOf("") } LaunchedEffect(key1 = zapsState) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) } - zapAmount = account.calculateZappedAmount(zappedNote) + zapAmountTxt = showAmount(account.calculateZappedAmount(zappedNote)) } } Row( verticalAlignment = CenterVertically, - modifier = Modifier.size(iconSize) + modifier = Modifier + .size(iconSize) .combinedClickable( role = Role.Button, interactionSource = remember { MutableInteractionSource() }, @@ -456,7 +456,7 @@ fun ZapReaction( } Text( - showAmount(zapAmount), + zapAmountTxt, fontSize = 14.sp, color = grayTint, modifier = textModifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 8c2686973..18446bf03 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -5,6 +5,7 @@ import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -17,13 +18,23 @@ import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) { - val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } + var dupMentions by remember { mutableStateOf?>(null) } - ReplyInformation(replyTo, dupMentions, account) { - navController.navigate("User/${it.pubkeyHex}") + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } + } + } + + if (dupMentions != null) { + ReplyInformation(replyTo, dupMentions, account) { + navController.navigate("User/${it.pubkeyHex}") + } } } @@ -102,6 +113,34 @@ fun ReplyInformation(replyTo: List?, dupMentions: List?, account: Ac } } +@Composable +fun ReplyInformationChannel(replyTo: List?, mentions: List, channel: Channel, account: Account, navController: NavController) { + var sortedMentions by remember { mutableStateOf?>(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 fun ReplyInformationChannel(replyTo: List?, mentions: List?, channel: Channel, navController: NavController) { ReplyInformationChannel( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index ddb394276..836d1aa8b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -28,41 +28,55 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { val userState by baseUser.live().metadata.observeAsState() 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( - user.bestDisplayName() ?: "", + bestDisplayName, fontWeight = FontWeight.Bold ) Text( - "@${(user.bestUsername() ?: "")}", + "@$bestUserName", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = weight + modifier = modifier ) - } else if (user.bestDisplayName() != null) { + } else if (bestDisplayName != null) { Text( - user.bestDisplayName() ?: "", + bestDisplayName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = weight + modifier = modifier ) - } else if (user.bestUsername() != null) { + } else if (bestUserName != null) { Text( - "@${(user.bestUsername() ?: "")}", + "@$bestUserName", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = weight + modifier = modifier ) } else { Text( - user.pubkeyDisplayHex(), + npubDisplay, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = weight + modifier = modifier ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 9801bf21c..aa881b1f0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.math.BigDecimal @Composable @@ -95,7 +94,7 @@ fun ZapNoteCompose(baseNote: Pair, accountViewModel: AccountViewMode var zapAmount by remember { mutableStateOf(null) } LaunchedEffect(key1 = noteZap) { - withContext(Dispatchers.IO) { + coroutineScope.launch(Dispatchers.IO) { zapAmount = (noteZap.event as? LnZapEvent)?.amount } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt index 8e9c2ed0d..d0c8d7b78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt @@ -29,7 +29,6 @@ import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache 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.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -70,15 +69,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { - note.channel()?.let { - navController.navigate("Channel/${it.idHex}") - } - } + routeFor(note, account.userProfile())?.let { navController.navigate(it) } }, onLongClick = { popupExpanded = true } ) @@ -113,7 +104,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor FlowRow() { zapSetCard.zapEvents.forEach { NoteAuthorPicture( - note = it.key, + baseNote = it.key, navController = navController, userAccount = account.userProfile(), size = 35.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 7d0094295..70aa994f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -94,7 +94,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, FlowRow() { zapSetCard.zapEvents.forEach { NoteAuthorPicture( - note = it.key, + baseNote = it.key, navController = navController, userAccount = account.userProfile(), size = 35.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 7bee6e873..bf25694a2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager 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.nip19.Nip19 import fr.acinq.secp256k1.Hex @@ -43,7 +43,7 @@ class AccountStateViewModel() : ViewModel() { fun startUI(key: String, useProxy: Boolean, proxyPort: Int) { val pattern = Pattern.compile(".+@.+\\.[a-z]+") 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 account = diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index fb3797759..b7d1344bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen +import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween 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.ZapUserSetCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue @OptIn(ExperimentalMaterialApi::class) @Composable @@ -85,6 +88,7 @@ fun CardFeedView( } } +@OptIn(ExperimentalTime::class) @Composable private fun FeedLoaded( state: CardFeedState.Loaded, @@ -114,61 +118,64 @@ private fun FeedLoaded( state = listState ) { itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> - when (item) { - is NoteCard -> NoteCompose( - item.note, - isBoostedNote = false, - accountViewModel = accountViewModel, - navController = navController, - routeForLastRead = routeForLastRead - ) - is ZapSetCard -> ZapSetCompose( - item, - isInnerNote = false, - accountViewModel = accountViewModel, - navController = navController, - routeForLastRead = routeForLastRead - ) - is ZapUserSetCard -> ZapUserSetCompose( - item, - isInnerNote = false, - accountViewModel = accountViewModel, - navController = navController, - routeForLastRead = routeForLastRead - ) - is LikeSetCard -> LikeSetCompose( - item, - isInnerNote = false, - accountViewModel = accountViewModel, - navController = navController, - routeForLastRead = routeForLastRead - ) - is BoostSetCard -> BoostSetCompose( - item, - isInnerNote = false, - accountViewModel = accountViewModel, - navController = navController, - routeForLastRead = routeForLastRead - ) - is MultiSetCard -> MultiSetCompose( - item, - accountViewModel = accountViewModel, - navController = navController, - routeForLastRead = routeForLastRead - ) - is BadgeCard -> BadgeCompose( - item, - accountViewModel = accountViewModel, - navController = navController, - routeForLastRead = routeForLastRead - ) - is MessageSetCard -> MessageSetCompose( - messageSetCard = item, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - navController = navController - ) + val (value, elapsed) = measureTimedValue { + when (item) { + is NoteCard -> NoteCompose( + item.note, + isBoostedNote = false, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) + is ZapSetCard -> ZapSetCompose( + item, + isInnerNote = false, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) + is ZapUserSetCard -> ZapUserSetCompose( + item, + isInnerNote = false, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) + is LikeSetCard -> LikeSetCompose( + item, + isInnerNote = false, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) + is BoostSetCard -> BoostSetCompose( + item, + isInnerNote = false, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) + is MultiSetCard -> MultiSetCompose( + item, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) + is BadgeCard -> BadgeCompose( + item, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead + ) + is MessageSetCard -> MessageSetCompose( + messageSetCard = item, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + navController = navController + ) + } } + Log.d("Time", "${item.javaClass.simpleName} Feed in $elapsed ${item.id()}") } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index cf9a85301..defff2d01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -175,7 +175,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - fun refreshFromOldState(newItems: Set) { + private fun refreshFromOldState(newItems: Set) { val oldNotesState = _feedContent.value val thisAccount = (localFilter as? NotificationFeedFilter)?.account diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index a62afa8f1..8238ad33e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -104,7 +104,7 @@ private fun FeedLoaded( } else { val replyAuthorBase = (note.event as? PrivateDmEvent) - ?.recipientPubKey() + ?.verifiedRecipientPubKey() ?.let { LocalCache.getOrCreateUser(it) } var userToComposeOn = note.author!! diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 045ce13d8..0fbcd2ecc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -249,7 +249,7 @@ fun NoteMaster( }) ) { NoteAuthorPicture( - note = baseNote, + baseNote = baseNote, navController = navController, userAccount = account.userProfile(), size = 55.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 4a9ab3c11..fffd2a126 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -100,23 +100,42 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigDecimal -@OptIn(ExperimentalFoundationApi::class) @Composable fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { + if (userId == null) return + + var userBase by remember { mutableStateOf(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 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) - 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) + NostrUserProfileDataSource.loadUserProfile(user) val lifeCycleOwner = LocalLifecycleOwner.current @@ -124,7 +143,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { println("Profidle Start") - NostrUserProfileDataSource.loadUserProfile(userId) + NostrUserProfileDataSource.loadUserProfile(user) NostrUserProfileDataSource.start() } if (event == Lifecycle.Event.ON_PAUSE) { diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c47fda57e..64568685b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -333,5 +333,11 @@ Saját csomópontjaid (NIP-95) 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 + + + + Követek Lista + Mindenki akit követek + Globális diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07ee6bf1c..17dd6e0e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -341,6 +341,10 @@ nostr.build - trusted Nostr.build can modify the file + nostrfiles.dev - trusted + Nostrfiles.dev can modify the file + + Verifiable Imgur (NIP-94) Checks if Imgur modified the file. New NIP: other clients might not see it @@ -350,6 +354,10 @@ Verifiable Nostr.build (NIP-94) Checks if Nostr.build modified the file. New NIP: other clients might not see it + Verifiable Nostrfiles.dev (NIP-94) + Checks if Nostrfiles.dev modified the file. New NIP: other clients might not see it + + Your relays (NIP-95) Files are hosted by your relays. New NIP: check if they support diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 89c1e38de..e5d99a649 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -4,6 +4,7 @@ import android.util.LruCache import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Tasks import com.google.mlkit.nl.languageid.LanguageIdentification +import com.google.mlkit.nl.languageid.LanguageIdentificationOptions import com.google.mlkit.nl.translate.TranslateLanguage import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.Translator @@ -20,7 +21,8 @@ class ResultOrError( ) 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 tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE) diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 8100a3e9b..4c6e17a6c 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import java.util.Locale @Composable @@ -57,8 +58,10 @@ fun TranslatableRichTextViewer( val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() val account = accountState?.account ?: return + val scope = rememberCoroutineScope() + LaunchedEffect(accountState) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { LanguageTranslatorService.autoTranslate( content, account.dontTranslateFrom, @@ -70,8 +73,6 @@ fun TranslatableRichTextViewer( showOriginal = preference == task.result.sourceLang } translatedTextState.value = task.result - } else { - translatedTextState.value = ResultOrError(content, null, null, null) } } }