Merge remote-tracking branch 'upstream/main' into nostrbuild

This commit is contained in:
Believethehype
2023-05-03 00:27:39 +02:00
32 changed files with 912 additions and 369 deletions
+1
View File
@@ -54,6 +54,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Markdown Support - [x] Markdown Support
- [x] Relay Authentication (NIP-42) - [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95) - [x] Content stored in relays themselves (NIP-95)
- [ ] Image/Video Capture in the app
- [ ] Local Database - [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 135 versionCode 140
versionName "0.40.1" versionName "0.40.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
+7
View File
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 --> <!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -46,6 +47,12 @@
<data android:scheme="nostr" /> <data android:scheme="nostr" />
</intent-filter> </intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst"> <intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -35,8 +35,8 @@ object ServiceManager {
NostrChatroomListDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount
// Notification Elements // Notification Elements
NostrAccountDataSource.start()
NostrHomeDataSource.start() NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start() NostrChatroomListDataSource.start()
// More Info Data Sources // More Info Data Sources
@@ -417,6 +417,7 @@ class Account(
mimeType = headerInfo.mimeType, mimeType = headerInfo.mimeType,
hash = headerInfo.hash, hash = headerInfo.hash,
size = headerInfo.size.toString(), size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash, blurhash = headerInfo.blurHash,
description = headerInfo.description, description = headerInfo.description,
privateKey = loggedIn.privKey!! privateKey = loggedIn.privKey!!
@@ -445,6 +446,7 @@ class Account(
mimeType = headerInfo.mimeType, mimeType = headerInfo.mimeType,
hash = headerInfo.hash, hash = headerInfo.hash,
size = headerInfo.size.toString(), size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash, blurhash = headerInfo.blurHash,
description = headerInfo.description, description = headerInfo.description,
privateKey = loggedIn.privKey!! privateKey = loggedIn.privKey!!
@@ -286,7 +286,7 @@ open class Note(val idHex: String) {
response is PayInvoiceSuccessResponse response is PayInvoiceSuccessResponse
} }
.associate { .associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey) val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
val amount = try { val amount = try {
if (lnInvoice == null) { if (lnInvoice == null) {
null null
@@ -14,6 +14,7 @@ class FileHeader(
val mimeType: String?, val mimeType: String?,
val hash: String, val hash: String,
val size: Int, val size: Int,
val dim: String?,
val blurHash: String?, val blurHash: String?,
val description: String? = null val description: String? = null
) { ) {
@@ -43,7 +44,7 @@ class FileHeader(
val hash = sha256.digest(data).toHexKey() val hash = sha256.digest(data).toHexKey()
val size = data.size val size = data.size
val blurHash = if (mimeType?.startsWith("image/") == true) { val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options() val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888 opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
@@ -59,20 +60,22 @@ class FileHeader(
mBitmap.height mBitmap.height
) )
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) { if (aspectRatio > 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()) Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
} else if (aspectRatio < 1) { } else if (aspectRatio < 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9) Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
} else { } else {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4) Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
} }
} else { } else {
null Pair(null, null)
} }
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description)) onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError() onError()
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes import nostr.postr.bechToBytes
@@ -50,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
) )
}, },
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = setOf(FeedType.SEARCH),
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(MetadataEvent.kind), kinds = listOf(MetadataEvent.kind),
search = mySearchString, search = mySearchString,
@@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
) )
), ),
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = setOf(FeedType.SEARCH),
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind),
search = mySearchString, search = mySearchString,
limit = 20 limit = 20
) )
@@ -208,6 +208,7 @@ open class Event(
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer()) .registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer()) .registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer()) .registerTypeAdapter(Response::class.java, ResponseDeserializer())
.registerTypeAdapter(Request::class.java, RequestDeserializer())
.create() .create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient) fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
@@ -19,6 +19,7 @@ class FileHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
@@ -30,6 +31,7 @@ class FileHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm" private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m" private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size" private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x" private const val HASH = "x"
private const val MAGNET_URI = "magnet" private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i" private const val TORRENT_INFOHASH = "i"
@@ -41,6 +43,7 @@ class FileHeaderEvent(
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
size: String? = null, size: String? = null,
dimensions: String? = null,
blurhash: String? = null, blurhash: String? = null,
magnetURI: String? = null, magnetURI: String? = null,
torrentInfoHash: String? = null, torrentInfoHash: String? = null,
@@ -53,6 +56,7 @@ class FileHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) }, mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) }, hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) }, size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) }, blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) }, magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
@@ -20,6 +20,7 @@ class FileStorageHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
@@ -30,6 +31,7 @@ class FileStorageHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm" private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m" private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size" private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x" private const val HASH = "x"
private const val MAGNET_URI = "magnet" private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i" private const val TORRENT_INFOHASH = "i"
@@ -41,6 +43,7 @@ class FileStorageHeaderEvent(
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
size: String? = null, size: String? = null,
dimensions: String? = null,
blurhash: String? = null, blurhash: String? = null,
magnetURI: String? = null, magnetURI: String? = null,
torrentInfoHash: String? = null, torrentInfoHash: String? = null,
@@ -53,6 +56,7 @@ class FileStorageHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) }, mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) }, hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) }, size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) }, blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) }, magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
@@ -1,10 +1,15 @@
package com.vitorpamplona.amethyst.service.model package com.vitorpamplona.amethyst.service.model
import android.util.Log import android.util.Log
import com.google.gson.JsonDeserializationContext
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.HexKey
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date import java.util.Date
class LnZapPaymentRequestEvent( class LnZapPaymentRequestEvent(
@@ -18,11 +23,15 @@ class LnZapPaymentRequestEvent(
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray): String? { fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
return try { return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
return Utils.decrypt(content, sharedSecret) val jsonText = Utils.decrypt(content, sharedSecret)
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
} catch (e: Exception) { } catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}") Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null null
@@ -39,7 +48,7 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000 createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent { ): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey) val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice)) val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = Utils.encrypt( val content = Utils.encrypt(
serializedRequest, serializedRequest,
@@ -59,11 +68,37 @@ class LnZapPaymentRequestEvent(
// REQUEST OBJECTS // REQUEST OBJECTS
abstract class Request(val method: String, val params: Params) abstract class Request(var method: String? = null)
abstract class Params
// PayInvoice Call // PayInvoice Call
class PayInvoiceParams(var invoice: String? = null)
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) { class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
class PayInvoiceParams(val invoice: String) : Params()
companion object {
fun create(bolt11: String): PayInvoiceMethod {
return PayInvoiceMethod(PayInvoiceParams(bolt11))
}
}
}
class RequestDeserializer :
JsonDeserializer<Request?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Request? {
val jsonObject = json.asJsonObject
val method = jsonObject.get("method")?.asString
if (method == "pay_invoice") {
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
}
return null
}
companion object {
}
} }
@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47 import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -92,6 +93,8 @@ class MainActivity : FragmentActivity() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// starts muted every time
muted.value = true
// Only starts after login // Only starts after login
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
@@ -126,8 +126,14 @@ object ImageSaver {
) )
} }
val masterUri = if (contentType.startsWith("image")) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri = val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) { checkNotNull(uri) {
"Can't insert the new content" "Can't insert the new content"
} }
@@ -61,11 +61,13 @@ open class NewMediaModel : ViewModel() {
contentResolver.openInputStream(uri)?.use { contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description) createNIP95Record(it.readBytes(), contentType, description)
} }
?: viewModelScope.launch { ?: run {
imageUploadingError.emit("Failed to upload the image / video") viewModelScope.launch {
isUploadingImage = false imageUploadingError.emit("Failed to upload the image / video")
uploadingPercentage.value = 0.00f isUploadingImage = false
uploadingDescription.value = null uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
} }
} else { } else {
uploadingPercentage.value = 0.1f uploadingPercentage.value = 0.1f
@@ -31,6 +31,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -45,10 +46,9 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
val scroolState = rememberScrollState() val scroolState = rememberScrollState()
val mediaType = resolver.getType(uri) ?: "" LaunchedEffect(uri) {
postViewModel.load(account, uri, mediaType) val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
LaunchedEffect(Unit) {
postViewModel.imageUploadingError.collect { error -> postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show() Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
} }
@@ -101,7 +101,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scroolState) .verticalScroll(scroolState)
) { ) {
ImageVideoPost(postViewModel) ImageVideoPost(postViewModel, account)
} }
} }
} }
@@ -110,7 +110,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
} }
@Composable @Composable
fun ImageVideoPost(postViewModel: NewMediaModel) { fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val fileServers = listOf( val fileServers = listOf(
@@ -178,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
) { ) {
TextSpinner( TextSpinner(
label = stringResource(id = R.string.file_server), label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == postViewModel.selectedServer }?.second ?: fileServers[0].second, placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions, options = fileServerOptions,
explainers = fileServerExplainers, explainers = fileServerExplainers,
onSelect = { onSelect = {
@@ -51,7 +51,7 @@ import java.net.URL
import java.util.regex.Pattern import java.util.regex.Pattern
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
// Group 1 = url, group 4 additional chars // Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)") val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
@@ -1,16 +1,47 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.net.Uri import android.net.Uri
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -23,10 +54,11 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.util.EventLogger
import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.VideoCache
import java.io.File import java.io.File
public var muted = mutableStateOf(true)
@Composable @Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(localFile.toUri(), description, onDialog) VideoView(localFile.toUri(), description, onDialog)
@@ -56,6 +88,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
volume = 0f
if (videoUri.scheme?.startsWith("file") == true) { if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media) setMediaItem(media)
} else { } else {
@@ -69,28 +102,49 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
} }
} }
exoPlayer.addAnalyticsListener(EventLogger()) LaunchedEffect(key1 = muted.value) {
exoPlayer.volume = if (muted.value) 0f else 1f
}
DisposableEffect( DisposableEffect(
AndroidView( BoxWithConstraints() {
modifier = Modifier.fillMaxWidth(), AndroidView(
factory = { modifier = Modifier
StyledPlayerView(context).apply { .fillMaxWidth()
player = exoPlayer .defaultMinSize(minHeight = 70.dp)
layoutParams = FrameLayout.LayoutParams( .align(Alignment.Center)
ViewGroup.LayoutParams.MATCH_PARENT, .onVisibilityChanges { visible ->
ViewGroup.LayoutParams.WRAP_CONTENT if (visible) {
) exoPlayer.play()
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH } else {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause() exoPlayer.pause()
innerOnDialog(it) }
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
} }
} }
} }
)
MuteButton(muted, Modifier) {
muted.value = !muted.value
} }
) }
) { ) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
@@ -109,3 +163,76 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
} }
} }
} }
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
val view = LocalView.current
var isVisible: Boolean? by remember { mutableStateOf(null) }
LaunchedEffect(isVisible) {
onVisibilityChanges(isVisible == true)
}
onGloballyPositioned { coordinates ->
isVisible = coordinates.isCompletelyVisible(view)
}
}
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
if (!isAttached) return false
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = android.graphics.Rect()
if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all.
return false
}
val bounds = boundsInWindow()
// Make sure we are completely in bounds.
return bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left &&
bounds.right <= globalRootRect.right &&
bounds.bottom <= globalRootRect.bottom
}
@Composable
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
Box(
modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (muted.value) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
} else {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
}
}
}
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -63,68 +64,81 @@ import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
import coil.imageLoader import coil.imageLoader
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.Nip05 import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable import net.engawapg.lib.zoomable.zoomable
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
abstract class ZoomableContent( abstract class ZoomableContent(
val description: String? = null val description: String? = null,
val dim: String? = null
) )
abstract class ZoomableUrlContent( abstract class ZoomableUrlContent(
val url: String, val url: String,
description: String? = null, description: String? = null,
val hash: String? = null, val hash: String? = null,
dim: String? = null,
val uri: String? = null val uri: String? = null
) : ZoomableContent(description) ) : ZoomableContent(description, dim)
class ZoomableUrlImage( class ZoomableUrlImage(
url: String, url: String,
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
val bluehash: String? = null, val blurhash: String? = null,
dim: String? = null,
uri: String? = null uri: String? = null
) : ZoomableUrlContent(url, description, hash, uri) ) : ZoomableUrlContent(url, description, hash, dim, uri)
class ZoomableUrlVideo( class ZoomableUrlVideo(
url: String, url: String,
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
dim: String? = null,
uri: String? = null uri: String? = null
) : ZoomableUrlContent(url, description, hash, uri) ) : ZoomableUrlContent(url, description, hash, dim, uri)
abstract class ZoomablePreloadedContent( abstract class ZoomablePreloadedContent(
val localFile: File,
description: String? = null, description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null, val isVerified: Boolean? = null,
dim: String? = null,
val uri: String val uri: String
) : ZoomableContent(description) ) : ZoomableContent(description, dim)
class ZoomableBitmapImage( class ZoomableLocalImage(
val localFile: File, localFile: File,
val mimeType: String? = null, mimeType: String? = null,
description: String? = null, description: String? = null,
val bluehash: String? = null, val blurhash: String? = null,
dim: String? = null,
isVerified: Boolean? = null, isVerified: Boolean? = null,
uri: String uri: String
) : ZoomablePreloadedContent(description, isVerified, uri) ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
class ZoomableBytesVideo( class ZoomableLocalVideo(
val localFile: File, localFile: File,
val mimeType: String? = null, mimeType: String? = null,
description: String? = null, description: String? = null,
dim: String? = null,
isVerified: Boolean? = null, isVerified: Boolean? = null,
uri: String uri: String
) : ZoomablePreloadedContent(description, isVerified, uri) ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent { fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
@@ -144,45 +158,12 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) { fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state // store the dialog open or close state
var dialogOpen by remember { var dialogOpen by remember {
mutableStateOf(false) mutableStateOf(false)
} }
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
var mainImageModifier = Modifier var mainImageModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp)) .clip(shape = RoundedCornerShape(15.dp))
@@ -192,17 +173,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
RoundedCornerShape(15.dp) RoundedCornerShape(15.dp)
) )
val ratio = aspectRatio(content.dim)
if (ratio != null) {
mainImageModifier = mainImageModifier.aspectRatio(ratio)
}
if (content is ZoomableUrlContent) { if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable( mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true }, onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) } onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
) )
} else if (content is ZoomableBitmapImage) { } else if (content is ZoomablePreloadedContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
)
} else if (content is ZoomableBytesVideo) {
mainImageModifier = mainImageModifier.combinedClickable( mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true }, onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
@@ -213,40 +194,30 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} }
} }
if (content is ZoomableUrlImage) { when (content) {
Box(contentAlignment = Alignment.Center) { is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
AsyncImage( is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
model = content.url, is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
contentDescription = content.description, is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
contentScale = ContentScale.FillWidth, }
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) { if (dialogOpen) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
} }
}
AnimatedVisibility( @Composable
visible = imageState !is AsyncImagePainter.State.Success, private fun LocalImageView(
exit = fadeOut(animationSpec = tween(200)) content: ZoomableLocalImage,
) { mainImageModifier: Modifier
if (content.bluehash != null) { ) {
DisplayBlueHash(content, mainImageModifier) // store the dialog open or close state
} else { var imageState by remember {
DisplayUrlWithLoadingSymbol(content) mutableStateOf<AsyncImagePainter.State?>(null)
} }
}
} Box(contentAlignment = Alignment.Center) {
} else if (content is ZoomableUrlVideo) { if (content.localFile.exists()) {
VideoView(content.url, content.description) { dialogOpen = true }
} else if (content is ZoomableBitmapImage) {
Box() {
AsyncImage( AsyncImage(
model = content.localFile, model = content.localFile,
contentDescription = content.description, contentDescription = content.description,
@@ -259,30 +230,124 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
imageState = it imageState = it
} }
) )
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
} }
if (imageState !is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
if (content.bluehash != null) { HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
DisplayBlueHash(content, mainImageModifier) }
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, mainImageModifier)
} else { } else {
DisplayUrlWithLoadingSymbol(content) FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
} }
} }
} else if (content is ZoomableBytesVideo) {
VideoView(content.localFile, content.description) { dialogOpen = true } if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) {
BlankNote()
}
}
}
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
} }
if (dialogOpen) { var verifiedHash by remember {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }) mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
Box(contentAlignment = Alignment.Center) {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, mainImageModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
if (imageState is AsyncImagePainter.State.Error) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
}
}
private fun aspectRatio(dim: String?): Float? {
if (dim == null) return null
val parts = dim.split("x")
if (parts.size != 2) return null
return try {
val width = parts[0].toFloat()
val height = parts[1].toFloat()
width / height
} catch (e: Exception) {
null
} }
} }
@Composable @Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
delay(200)
cnt = content
}
}
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
}
@Composable
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
if (content is ZoomableUrlContent) { if (content is ZoomableUrlContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url) ClickableUrl(urlText = "${content.url} ", url = content.url)
} else { } else {
@@ -321,38 +386,20 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
} }
@Composable @Composable
private fun DisplayBlueHash( private fun DisplayBlurHash(
content: ZoomableUrlImage, blurhash: String?,
description: String?,
modifier: Modifier modifier: Modifier
) { ) {
if (content.bluehash == null) return if (blurhash == null) return
val context = LocalContext.current val context = LocalContext.current
AsyncImage( AsyncImage(
model = BlurHashRequester.imageRequest( model = BlurHashRequester.imageRequest(
context, context,
content.bluehash blurhash
), ),
contentDescription = content.description, contentDescription = description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
}
@Composable
private fun DisplayBlueHash(
content: ZoomableBitmapImage,
modifier: Modifier
) {
if (content.bluehash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
),
contentDescription = content.description,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = modifier modifier = modifier
) )
@@ -388,7 +435,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
val myContent = allImages[pagerState.currentPage] val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) { if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url) SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableBitmapImage && myContent.localFile != null) { } else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType) SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
} }
} }
@@ -411,100 +458,19 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
@Composable @Composable
fun RenderImageOrVideo(content: ZoomableContent) { fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope() val mainModifier = Modifier
val context = LocalContext.current .fillMaxSize()
.zoomable(rememberZoomState())
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
if (content is ZoomableUrlImage) { if (content is ZoomableUrlImage) {
Box() { UrlImageView(content = content, mainImageModifier = mainModifier)
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else if (content is ZoomableUrlVideo) { } else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description) VideoView(content.url, content.description)
} }
} else if (content is ZoomableBitmapImage) { } else if (content is ZoomableLocalImage) {
Box() { LocalImageView(content = content, mainImageModifier = mainModifier)
AsyncImage( } else if (content is ZoomableLocalVideo) {
model = content.localFile,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else if (content is ZoomableBytesVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.localFile, content.description) VideoView(content.localFile, content.description)
} }
@@ -512,7 +478,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
} }
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? { private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
if (content.hash == null) return null if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
@@ -113,6 +113,8 @@ fun ChatroomMessageCompose(
var alignment: Arrangement.Horizontal var alignment: Arrangement.Horizontal
var shape: Shape var shape: Shape
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
if (note.author == accountUser) { if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End alignment = Arrangement.End
@@ -333,11 +335,11 @@ fun ChatroomMessageCompose(
} }
Row() { Row() {
LikeReaction(baseNote, accountViewModel) LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))
ZapReaction(baseNote, accountViewModel) ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))
ReplyReaction(baseNote, accountViewModel, showCounter = false) { ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
onWantsToReply(baseNote) onWantsToReply(baseNote)
} }
} }
@@ -873,13 +873,13 @@ fun FileHeaderDisplay(note: Note) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val blurHash = event.blurhash() val blurHash = event.blurhash()
val hash = event.hash() val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent() val uri = "nostr:" + note.toNEvent()
content = if (isImage) { content = if (isImage) {
ZoomableUrlImage(fullUrl, description, hash, blurHash, uri) ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
} else { } else {
ZoomableUrlVideo(fullUrl, description, hash, uri) ZoomableUrlVideo(fullUrl, description, hash, uri)
} }
@@ -888,7 +888,7 @@ fun FileHeaderDisplay(note: Note) {
content?.let { content?.let {
ZoomableContentView(content = it, listOf(it)) ZoomableContentView(content = it, listOf(it))
} ?: UrlPreview(fullUrl, "$fullUrl ") }
} }
@Composable @Composable
@@ -911,14 +911,30 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex) val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
val bytes = eventBytes?.decode() val bytes = eventBytes?.decode()
val blurHash = eventHeader.blurhash() val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content val description = eventHeader.content
val mimeType = eventHeader.mimeType() val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) { content = if (mimeType?.startsWith("image") == true) {
ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri) ZoomableLocalImage(
localFile = localDir,
mimeType = mimeType,
description = description,
blurhash = blurHash,
dim = dimensions,
isVerified = true,
uri = uri
)
} else { } else {
if (bytes != null) { if (bytes != null) {
ZoomableBytesVideo(localDir, mimeType, description, true, uri) ZoomableLocalVideo(
localFile = localDir,
mimeType = mimeType,
description = description,
dim = dimensions,
isVerified = true,
uri = uri
)
} else { } else {
null null
} }
@@ -928,7 +944,7 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
content?.let { content?.let {
ZoomableContentView(content = it, listOf(it)) ZoomableContentView(content = it, listOf(it))
} ?: BlankNote() }
} }
@Composable @Composable
@@ -71,6 +71,8 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var wantsToReplyTo by remember { var wantsToReplyTo by remember {
mutableStateOf<Note?>(null) mutableStateOf<Note?>(null)
} }
@@ -91,23 +93,23 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
Row(verticalAlignment = CenterVertically) { Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ReplyReaction(baseNote, accountViewModel) { ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote wantsToReplyTo = baseNote
} }
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
BoostReaction(baseNote, accountViewModel) { BoostReaction(baseNote, grayTint, accountViewModel) {
wantsToQuote = baseNote wantsToQuote = baseNote
} }
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
LikeReaction(baseNote, accountViewModel) LikeReaction(baseNote, grayTint, accountViewModel)
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ZapReaction(baseNote, accountViewModel) ZapReaction(baseNote, grayTint, accountViewModel)
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ViewCountReaction(baseNote.idHex) ViewCountReaction(baseNote.idHex, grayTint)
} }
} }
} }
@@ -115,6 +117,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
@Composable @Composable
fun ReplyReaction( fun ReplyReaction(
baseNote: Note, baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
showCounter: Boolean = true, showCounter: Boolean = true,
iconSize: Dp = 20.dp, iconSize: Dp = 20.dp,
@@ -142,39 +145,33 @@ fun ReplyReaction(
} }
} }
) { ) {
ReplyIcon(iconSize) Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(iconSize),
tint = grayTint
)
} }
if (showCounter) { if (showCounter) {
Text( Text(
" ${showCount(replies.size)}", " ${showCount(replies.size)}",
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = grayTint
) )
} }
} }
@Composable
private fun ReplyIcon(iconSize: Dp = 15.dp) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable @Composable
public fun BoostReaction( public fun BoostReaction(
baseNote: Note, baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp, iconSize: Dp = 20.dp,
onQuotePress: () -> Unit onQuotePress: () -> Unit
) { ) {
val boostsState by baseNote.live().boosts.observeAsState() val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note val boostedNote = boostsState?.note
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -234,13 +231,14 @@ public fun BoostReaction(
Text( Text(
" ${showCount(boostedNote?.boosts?.size)}", " ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = grayTint
) )
} }
@Composable @Composable
fun LikeReaction( fun LikeReaction(
baseNote: Note, baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp, iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp heartSize: Dp = 16.dp
@@ -248,7 +246,6 @@ fun LikeReaction(
val reactionsState by baseNote.live().reactions.observeAsState() val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return val reactedNote = reactionsState?.note ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -292,7 +289,7 @@ fun LikeReaction(
Text( Text(
" ${showCount(reactedNote.reactions.size)}", " ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = grayTint
) )
} }
@@ -300,6 +297,7 @@ fun LikeReaction(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
fun ZapReaction( fun ZapReaction(
baseNote: Note, baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier, textModifier: Modifier = Modifier,
iconSize: Dp = 20.dp, iconSize: Dp = 20.dp,
@@ -315,19 +313,21 @@ fun ZapReaction(
var wantsToZap by remember { mutableStateOf(false) } var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) }
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) } var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) } var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = zapsState) { LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) { if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
} }
zapAmount = account.calculateZappedAmount(zappedNote)
} }
} }
@@ -455,26 +455,23 @@ fun ZapReaction(
} }
} }
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
zapAmount = account.calculateZappedAmount(zappedNote)
}
}
Text( Text(
showAmount(zapAmount), showAmount(zapAmount),
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = grayTint,
modifier = textModifier modifier = textModifier
) )
} }
@Composable @Composable
public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) { public fun ViewCountReaction(
idHex: String,
grayTint: Color,
iconSize: Dp = 20.dp,
barChartSize: Dp = 19.dp,
numberSize: Dp = 24.dp
) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton( IconButton(
modifier = Modifier.size(iconSize), modifier = Modifier.size(iconSize),
@@ -42,7 +42,8 @@ fun FeedView(
navController: NavController, navController: NavController,
routeForLastRead: String?, routeForLastRead: String?,
scrollStateKey: String? = null, scrollStateKey: String? = null,
scrollToTop: Boolean = false scrollToTop: Boolean = false,
enablePullRefresh: Boolean = true
) { ) {
val feedState by viewModel.feedContent.collectAsState() val feedState by viewModel.feedContent.collectAsState()
@@ -50,7 +51,13 @@ fun FeedView(
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) { val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column { Column {
Crossfade( Crossfade(
targetState = feedState, targetState = feedState,
@@ -88,7 +95,9 @@ fun FeedView(
} }
} }
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
} }
} }
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { fun LnZapFeedView(
viewModel: LnZapFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState() val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) { val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() { Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) { when (state) {
@@ -59,7 +70,9 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo
} }
} }
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
} }
} }
@@ -96,7 +96,11 @@ class RelayFeedViewModel : ViewModel() {
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) { fun RelayFeedView(
viewModel: RelayFeedViewModel,
accountViewModel: AccountViewModel,
enablePullRefresh: Boolean = true
) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
@@ -114,7 +118,13 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) { val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() { Column() {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -136,6 +146,8 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
} }
} }
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
} }
} }
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf<String, ScrollState>() private val savedScrollStates = mutableMapOf<String, ScrollState>()
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float) private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
object ScrollStateKeys { object ScrollStateKeys {
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { fun UserFeedView(
viewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState() val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) { val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() { Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) { when (state) {
@@ -59,7 +70,9 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
} }
} }
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
} }
} }
@@ -760,7 +760,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavCon
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, navController, null) FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
} }
} }
} }
@@ -780,7 +780,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, navController: Nav
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, navController, null) FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
} }
} }
} }
@@ -801,7 +801,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navControll
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, navController, null) FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
} }
} }
} }
@@ -821,7 +821,7 @@ fun TabFollows(baseUser: User, accountViewModel: AccountViewModel, navController
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
UserFeedView(feedViewModel, accountViewModel, navController) UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
} }
} }
} }
@@ -840,7 +840,7 @@ fun TabFollowers(baseUser: User, accountViewModel: AccountViewModel, navControll
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
UserFeedView(feedViewModel, accountViewModel, navController) UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
} }
} }
} }
@@ -859,7 +859,7 @@ fun TabReceivedZaps(baseUser: User, accountViewModel: AccountViewModel, navContr
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
LnZapFeedView(feedViewModel, accountViewModel, navController) LnZapFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
} }
} }
} }
@@ -878,7 +878,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, navController
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, navController, null) FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
} }
} }
} }
@@ -913,7 +913,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
RelayFeedView(feedViewModel, accountViewModel) RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false)
} }
} }
} }
@@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
@@ -55,6 +57,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChannelName import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -127,14 +130,62 @@ fun SearchScreen(
} }
} }
class SearchBarViewModel : ViewModel() {
var searchValue by mutableStateOf("")
val searchResults = mutableStateOf<List<User>>(emptyList())
val searchResultsNotes = mutableStateOf<List<Note>>(emptyList())
val searchResultsChannels = mutableStateOf<List<Channel>>(emptyList())
val hashtagResults = mutableStateOf<List<String>>(emptyList())
val isTrailingIconVisible by
derivedStateOf {
searchValue.isNotBlank()
}
fun updateSearchValue(newValue: String) {
searchValue = newValue
}
private fun runSearch() {
if (searchValue.isBlank()) {
hashtagResults.value = emptyList()
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
return
}
hashtagResults.value = findHashtags(searchValue)
searchResults.value = LocalCache.findUsersStartingWith(searchValue)
searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
}
fun clean() {
searchValue = ""
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
runSearch()
}
fun invalidateData() {
bundler.invalidate()
}
fun isSearching() = searchValue.isNotBlank()
}
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
@Composable @Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) { private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
var searchValue by remember { mutableStateOf("") } val searchBarViewModel: SearchBarViewModel = viewModel()
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val hashtagResults = remember { mutableStateOf<List<String>>(emptyList()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -143,25 +194,16 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val dbState = LocalCache.live.observeAsState() val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return val db = dbState.value ?: return
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.isNotBlank()
}
}
// Create a channel for processing search queries. // Create a channel for processing search queries.
val searchTextChanges = remember { val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED) CoroutineChannel<String>(CoroutineChannel.CONFLATED)
} }
LaunchedEffect(db) { LaunchedEffect(db) {
if (searchValue.length > 1) { withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { if (searchBarViewModel.isSearching()) {
searchResults.value = LocalCache.findUsersStartingWith(searchValue) println("Search Active")
searchResultsNotes.value = searchBarViewModel.invalidateData()
LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }
.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
} }
} }
} }
@@ -174,15 +216,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.distinctUntilChanged() .distinctUntilChanged()
.debounce(300) .debounce(300)
.collectLatest { .collectLatest {
hashtagResults.value = findHashtags(it) if (it.length >= 2) {
if (it.length >= 4) {
onlineSearch.search(it.trim()) onlineSearch.search(it.trim())
} }
searchResults.value = LocalCache.findUsersStartingWith(it) searchBarViewModel.invalidateData()
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
// makes sure to show the top of the search // makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) } scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
@@ -205,9 +243,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TextField( TextField(
value = searchValue, value = searchBarViewModel.searchValue,
onValueChange = { onValueChange = {
searchValue = it searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it) searchTextChanges.trySend(it)
} }
@@ -234,14 +272,10 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
) )
}, },
trailingIcon = { trailingIcon = {
if (isTrailingIconVisible) { if (searchBarViewModel.isTrailingIconVisible) {
IconButton( IconButton(
onClick = { onClick = {
searchValue = "" searchBarViewModel.clean()
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
onlineSearch.clear() onlineSearch.clear()
} }
) { ) {
@@ -260,7 +294,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
) )
} }
if (searchValue.isNotBlank()) { if (searchBarViewModel.isSearching()) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
@@ -269,17 +303,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
), ),
state = listState state = listState
) { ) {
itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item -> itemsIndexed(searchBarViewModel.hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(item) { HashtagLine(item) {
navController.navigate("Hashtag/$item") navController.navigate("Hashtag/$item")
} }
} }
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item -> itemsIndexed(searchBarViewModel.searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController) UserCompose(item, accountViewModel = accountViewModel, navController = navController)
} }
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item -> itemsIndexed(searchBarViewModel.searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
ChannelName( ChannelName(
channelIdHex = item.idHex, channelIdHex = item.idHex,
channelPicture = item.profilePicture(), channelPicture = item.profilePicture(),
@@ -296,7 +330,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
) )
} }
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item -> itemsIndexed(searchBarViewModel.searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
NoteCompose(item, accountViewModel = accountViewModel, navController = navController) NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
} }
} }
@@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
@@ -368,9 +367,9 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navContr
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) { BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote wantsToQuote = baseNote
}*/ }*/
LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp) LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp) ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp) ViewCountReaction(baseNote.idHex, grayTint = MaterialTheme.colors.onBackground, iconSize = 40.dp, barChartSize = 39.dp)
} }
} }
+286
View File
@@ -0,0 +1,286 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="point_to_the_qr_code">QR குறியீட்டைச் சுட்டிக்காட்டவும்</string>
<string name="show_qr">QR ஐக் காட்டவும்</string>
<string name="profile_image">சுயவிவரப் படம்</string>
<string name="scan_qr">QR ஐ ஸ்கேன் செய்யவும்</string>
<string name="show_anyway">பரவாயில்லை, காட்டவும்</string>
<string name="post_was_flagged_as_inappropriate_by">குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர்</string>
<string name="post_not_found">குறிப்பு கிடைக்கவில்லை</string>
<string name="channel_image">Channel Image</string>
<string name="referenced_event_not_found">குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை</string>
<string name="could_not_decrypt_the_message">செய்தியை மறைகுறியாக்க முடியவில்லை</string>
<string name="group_picture">குழுப் படம்</string>
<string name="explicit_content">வெளிப்படையான உள்ளடக்கம்</string>
<string name="spam">ஸ்பேம்</string>
<string name="impersonation">ஆள்மாறாட்டம்</string>
<string name="illegal_behavior">சட்டவிரோத நடத்தை</string>
<string name="unknown">தெரியாத</string>
<string name="relay_icon">ரிலே ஐகான்</string>
<string name="unknown_author">எழுதியவர் தெரியவில்லை</string>
<string name="copy_text">உரையை நகலெடுக்கவும்</string>
<string name="copy_user_pubkey">எழுதியவர் IDஐ நகலெடுக்கவும்</string>
<string name="copy_note_id">குறிப்பின் IDஐ நகலெடுக்கவும்</string>
<string name="broadcast">ஒளிபரப்பு</string>
<string name="request_deletion">நீக்குவதற்கு கோரிக்கை செய்</string>
<string name="block_hide_user">பயனரை முடக்கு + மறை</string>
<string name="report_spam_scam">ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்</string>
<string name="report_impersonation">ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்</string>
<string name="report_explicit_content">வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்</string>
<string name="report_illegal_behaviour">சட்டவிரோத நடத்தையைப் புகாரளிக்கவும்</string>
<string name="login_with_a_private_key_to_be_able_to_reply">பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_like_posts">குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="no_zap_amount_setup_long_press_to_change">ஜாப் தொகை அமைப்பு இல்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">ஜாப்களை அனுப்ப ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="zaps">ஜாப்கள்</string>
<string name="view_count">பார்வை எண்ணிக்கை</string>
<string name="boost">உயர்த்து</string>
<string name="boosted">உயர்த்தப்பட்டது</string>
<string name="quote">மேற்கோள் காட்டு</string>
<string name="new_amount_in_sats">புதிய தொகை (ஸாட்களில்)</string>
<string name="add">சேர்</string>
<string name="replying_to">"பதில் பெறுநர் "</string>
<string name="and">மற்றும்</string>
<string name="in_channel">"அலை வரிசையில் "</string>
<string name="profile_banner">சுயவிவர பேனர்</string>
<string name="following">" பின்பற்றப் படுவோர்"</string>
<string name="followers">" பின்பற்றுவோர்"</string>
<string name="profile">சுயவிவரம்</string>
<string name="security_filters">பாதுகாப்பு வடிப்பான்கள்</string>
<string name="log_out">வெளியேறு</string>
<string name="show_more">மேலும் காட்டு</string>
<string name="lightning_invoice">லைட்நிங் விலைப்பட்டியல்</string>
<string name="pay">செலுத்து</string>
<string name="lightning_tips">லைட்நிங் இனாம்</string>
<string name="note_to_receiver">பெறுபவருக்கான குறிப்பு</string>
<string name="thank_you_so_much">மிக்க நன்றி!</string>
<string name="amount_in_sats">தொகை (ஸாட்களில்)</string>
<string name="send_sats">ஸாட்கள் அனுப்பவும்</string>
<string name="error_parsing_preview_for">"%1$s க்கான முன்னோட்டத்தை புரிந்து கொள்வதில் பிழை: %2$s"</string>
<string name="preview_card_image_for">" %1$s க்கான முன்னோட்ட அட்டைப் படம்"</string>
<string name="new_channel">புதிய அலைவரிசை</string>
<string name="channel_name">அலைவரிசைப் பெயர்</string>
<string name="my_awesome_group">எனது அருமையான குழு</string>
<string name="picture_url">பட Url</string>
<string name="description">தகவல்</string>
<string name="about_us">"எங்களைப் பற்றி.. "</string>
<string name="what_s_on_your_mind">உங்கள் மனதில் என்ன உள்ளது?</string>
<string name="post">குறிப்பு</string>
<string name="save">சேமி</string>
<string name="create">உருவாக்கு</string>
<string name="cancel">ரத்து</string>
<string name="failed_to_upload_the_image">படத்தை பதிவேற்றுதல் தோல்வி</string>
<string name="relay_address">ரிலே முகவரி</string>
<string name="posts">குறிப்புகள்</string>
<string name="bytes">பைட்டுகள்</string>
<string name="errors">பிழைகள்</string>
<string name="home_feed">முகப்பு ஊட்டம்</string>
<string name="private_message_feed">தனிப்பட்ட செய்தி ஊட்டம்</string>
<string name="public_chat_feed">பொது செய்தி ஊட்டம்</string>
<string name="global_feed">முழுதளாவிய ஊட்டம்</string>
<string name="search_feed">தேடுதல் ஊட்டம்</string>
<string name="add_a_relay">ஒரு ரிலேவை சேர்</string>
<string name="display_name">காட்சிப் பெயர்</string>
<string name="my_display_name">எனது காட்சிப் பெயர்</string>
<string name="username">பயனர் பெயர்</string>
<string name="my_username">எனது பயனர் பெயர்</string>
<string name="about_me">என்னைப் பற்றி</string>
<string name="avatar_url">சுயவிவரப் படம் URL</string>
<string name="banner_url">பேனர் URL</string>
<string name="website_url">வலைத்தள URL</string>
<string name="ln_address">LN முகவரி </string>
<string name="ln_url_outdated">LN URL (காலாவதியானது)</string>
<string name="image_saved_to_the_gallery">படம் கேலரியில் சேமிக்கப் பட்டது</string>
<string name="failed_to_save_the_image">படத்தை சேமிப்பது தோல்வியடைந்தது</string>
<string name="upload_image">படத்தை பதிவேற்று</string>
<string name="uploading">பதிவேற்றம்...</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="reply_here">"இங்கு பதிலளிக்கவும்.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்</string>
<string name="copy_channel_id_note_to_the_clipboard">அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு</string>
<string name="edits_the_channel_metadata">அலைவரிசை பற்றிய தகவல்களை மாற்றும்</string>
<string name="join">சேர்</string>
<string name="known">தெரிந்த</string>
<string name="new_requests">புதிய கோரிக்கைகள்</string>
<string name="blocked_users">முடக்கப்பட்ட பயனர்கள்</string>
<string name="new_threads">புதிய நூல்கள்</string>
<string name="conversations">உரையாடல்கள்</string>
<string name="notes">குறிப்புகள்</string>
<string name="replies">பதில்கள்</string>
<string name="follows">"பின்தொடர படுவோர்"</string>
<string name="reports">"புகார்கள்"</string>
<string name="more_options">மேலும் </string>
<string name="relays">" ரிலேகள்"</string>
<string name="website">இணையதளம்</string>
<string name="lightning_address">லைட்னிங் முகவரி</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது</string>
<string name="copy_private_key_to_the_clipboard">கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும்</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது</string>
<string name="copy_public_key_npub_to_the_clipboard">கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்</string>
<string name="send_a_direct_message">நேரடி செய்தியை அனுப்பவும்</string>
<string name="edits_the_user_s_metadata">பயனரின்தகவலை திருத்துகிறது</string>
<string name="follow">பின்தொடர்</string>
<string name="follow_back">பதிலுக்கு பின்தொடரவும்</string>
<string name="unblock">தடையை நீக்கு</string>
<string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">பயனரைத் தடைசெய்க</string>
<string name="npub_hex_username">"npub, hex, பயனர்பெயர் "</string>
<string name="clear">அழி</string>
<string name="app_logo">பயன்பாட்டு லோகோ</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string>
<string name="show_password">கடவுச்சொல்லை காட்டவும்</string>
<string name="hide_password">கடவுச்சொல்லை மறைக்கவும்</string>
<string name="invalid_key">தவறான சாவி</string>
<string name="i_accept_the">"பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: "</string>
<string name="terms_of_use">பயன்பாட்டு விதிமுறைகளை</string>
<string name="acceptance_of_terms_is_required">விதிமுறைகளை ஏற்றுக்கொள்வது தேவை</string>
<string name="key_is_required">சாவி தேவை</string>
<string name="login">உள்நுழை</string>
<string name="generate_a_new_key">புதிய சாவியை உருவாக்குங்கள்</string>
<string name="loading_feed">ஊட்டம் ஏற்றப்படுகிறது</string>
<string name="error_loading_replies">"பதில்களை ஏற்றுவதில் பிழை: "</string>
<string name="try_again">மீண்டும் முயற்சி செய்</string>
<string name="feed_is_empty">ஊட்டம் காலியாக உள்ளது.</string>
<string name="refresh">புதுப்பி</string>
<string name="created">உருவாக்கப்பட்டது</string>
<string name="with_description_of">விளக்கம்:</string>
<string name="and_picture">மற்றும் படம்</string>
<string name="changed_chat_name_to">மாற்றப்பட்ட அரட்டை பெயர்</string>
<string name="description_to">விளக்கம்</string>
<string name="and_picture_to">மற்றும் படம்</string>
<string name="leave">வெளியேறு</string>
<string name="unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="channel_created">அலைவரிசை உருவாக்கப் பட்டது</string>
<string name="channel_information_changed_to">"அலைவரிசை தகவல் மாற்றப்பட்டது"</string>
<string name="public_chat">பொது அரட்டை</string>
<string name="posts_received">குறிப்புகள் பெறப்பட்டன</string>
<string name="remove">அகற்று</string>
<string name="translations_auto">தானியங்கி</string>
<string name="translations_translated_from">மொழிபெயர்க்கப்பட்டுள்ளது மூலம்:</string>
<string name="translations_to"></string>
<string name="translations_show_in_lang_first">முதலில் %1$s இல் காண்பி</string>
<string name="translations_always_translate_to_lang">எப்போதும் %1$s க்கு மொழிபெயர்க்கவும்</string>
<string name="translations_never_translate_from_lang">%1$s இலிருந்து ஒருபோதும் மொழிபெயர்க்க வேண்டாம்</string>
<string name="never">ஒருபோதும் இல்லை</string>
<string name="now">இப்போது</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">நிர்வாணம்</string>
<string name="profanity_hateful_speech">அவதூறு / வெறுக்கத்தக்க பேச்சு</string>
<string name="report_hateful_speech">வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும்</string>
<string name="report_nudity_porn">நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும்</string>
<string name="others">மற்றவைகள்</string>
<string name="mark_all_known_as_read">அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_new_as_read">அனைத்து புதியதையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_as_read">அனைத்தையும் படித்ததாக குறிக்கவும்</string>
<string name="backup_keys">காப்பு சாவிகள்</string>
<string name="account_backup_tips_md">
## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள்
\n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம்.
\n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**.
\ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள்.
\ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம்.
</string>
<string name="secret_key_copied_to_clipboard">ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது</string>
<string name="copy_my_secret_key">எனது ரகசிய சாவியை நகலெடுக்கவும்</string>
<string name="biometric_authentication_failed">அங்கீகரிப்பு தோல்வியுற்றது</string>
<string name="biometric_error">பிழை</string>
<string name="badge_created_by">" %1$s ஆல் உருவாக்கப்பட்டது"</string>
<string name="badge_award_image_for">" %1$s க்கான பேட்ஜ் விருது படம்"</string>
<string name="new_badge_award_notif">நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்</string>
<string name="award_granted_to">பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:</string>
<string name="copied_note_text_to_clipboard">குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது</string>
<string name="copied_user_id_to_clipboard">கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது</string>
<string name="copied_note_id_to_clipboard">கிளிப்போர்டுக்கு குறிப்பு ஐடி (@note1) நகலெடுக்கப் பட்டது</string>
<string name="select_text_dialog_top">உரையைத் தேர்ந்தெடுக்கவும்</string>
<string name="private_conversation_notification">"தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்."</string>
<string name="account_switch_add_account_dialog_title">புதிய கணக்கைச் சேர்க்கவும்</string>
<string name="drawer_accounts">கணக்குகள்</string>
<string name="account_switch_select_account">கணக்கைத் தேர்ந்தெடுக்கவும்</string>
<string name="account_switch_add_account_btn">புதிய கணக்கைச் சேர்க்கவும்</string>
<string name="account_switch_active_account">செயலில் உள்ள கணக்கு</string>
<string name="account_switch_has_private_key">தனிப்பட்ட சாவி உள்ளது</string>
<string name="account_switch_pubkey_only">படிக்க மட்டும், தனிப்பட்ட சாவி இல்லை</string>
<string name="back">பின்</string>
<string name="quick_action_select">தேர்ந்தெடுக்கவும்</string>
<string name="quick_action_share_browser_link">உலாவி இணைப்பைப் பகிரவும்</string>
<string name="quick_action_share">பகிர்</string>
<string name="quick_action_copy_user_id">ஆசிரியர் ஐடி</string>
<string name="quick_action_copy_note_id">குறிப்பு ஐடி</string>
<string name="quick_action_copy_text">உரையை நகலெடுக்கவும்</string>
<string name="quick_action_delete">அழி</string>
<string name="quick_action_unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="quick_action_follow">பின்தொடர்</string>
<string name="quick_action_request_deletion_alert_title">நீக்க கோரிக்கை செய்</string>
<string name="quick_action_request_deletion_alert_body">நீங்கள் தற்போது இணைக்கப்பட்டுள்ள ரிலேக்களிலிருந்து உங்கள் குறிப்பை நீக்குமாறு அமேதிஸ்ட் கோரும். உங்கள் குறிப்பு அந்த ரிலேக்களிலிருந்து அல்லது அது சேமிக்கப்பட்டுள்ள பிற ரிலேக்களிலிருந்து நிரந்தரமாக நீக்கப்படும் என்பதற்கு எந்த உத்தரவாதமும் இல்லை.</string>
<string name="quick_action_block_dialog_btn">தடைசெய்</string>
<string name="quick_action_delete_dialog_btn">அழி</string>
<string name="quick_action_block">தடைசெய்</string>
<string name="quick_action_report">புகார் </string>
<string name="quick_action_delete_button">அழி</string>
<string name="quick_action_dont_show_again_button">மீண்டும் காட்ட வேண்டாம்</string>
<string name="report_dialog_spam">ஸ்பேம் அல்லது மோசடிகள்</string>
<string name="report_dialog_profanity">அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை</string>
<string name="report_dialog_impersonation">தீங்கிழைக்கும் ஆள்மாறாட்டம்</string>
<string name="report_dialog_nudity">நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்</string>
<string name="report_dialog_illegal">சட்டவிரோத நடத்தை</string>
<string name="report_dialog_blocking_a_user">ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">பயனரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_report_btn">தகாதது என பதிவுசெய்</string>
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.</string>
<string name="report_dialog_additional_reason_placeholder">உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…</string>
<string name="report_dialog_additional_reason_label">கூடுதல் சூழல்</string>
<string name="report_dialog_select_reason_label">காரணம்</string>
<string name="report_dialog_select_reason_placeholder">ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்…</string>
<string name="report_dialog_post_report_btn">புகார் அளிக்கவும்</string>
<string name="report_dialog_title">தடு மற்றும் புகாரளி</string>
<string name="block_only">தடு</string>
<string name="bookmarks">புக்மார்க்குகள்</string>
<string name="private_bookmarks">தனிப்பட்ட புக்மார்க்குகள்</string>
<string name="public_bookmarks">பொது புக்மார்க்குகள்</string>
<string name="add_to_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளில் சேர்க்கவும்</string>
<string name="add_to_public_bookmarks">பொது புக்மார்க்குகளில் சேர்க்கவும்</string>
<string name="remove_from_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளிலிருந்து அகற்று</string>
<string name="remove_from_public_bookmarks">பொது புக்மார்க்குகளிலிருந்து அகற்று</string>
<string name="wallet_connect_service">பணப்பை இணைப்பு சேவை</string>
<string name="wallet_connect_service_explainer">பயன்பாட்டை விட்டு வெளியேறாமல் ஜாப்களை செலுத்த ஒரு நாஸ்டர் ரகசியத்தை அங்கீகரிக்கிறது. ரகசியத்தை பாதுகாப்பாக வைத்து, முடிந்தால் தனியார் ரிலேவைப் பயன்படுத்துங்கள்</string>
<string name="wallet_connect_service_pubkey">பணப்பை இணைப்பு பொது சாவி</string>
<string name="wallet_connect_service_relay">பணப்பை இணைப்பு ரிலே</string>
<string name="wallet_connect_service_secret">பணப்பை இணைப்பு ரகசியம்</string>
<string name="wallet_connect_service_show_secret">ரகசிய சாவியைக் காட்டு</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex தனிப்பட்ட சாவி</string>
<string name="pledge_amount_in_sats">உறுதிமொழி தொகை (ஸாட்களில்)</string>
<string name="post_poll"></string>
<string name="poll_heading_required">தேவையான புலங்கள்:</string>
<string name="poll_zap_recipients">ஜாப் பெறுநர்கள்</string>
<string name="poll_primary_description">முதன்மை வாக்கெடுப்பு விளக்கம்…</string>
<string name="poll_option_index">விருப்பம் %s</string>
<string name="poll_option_description">வாக்கெடுப்பு விருப்ப விளக்கம்</string>
<string name="poll_heading_optional">விருப்ப புலங்கள்:</string>
<string name="poll_zap_value_min">குறைந்தபட்ச ஜாப்</string>
<string name="poll_zap_value_max">அதிகபட்ச ஜாப்</string>
<string name="poll_consensus_threshold">ஒருமித்த கருத்து</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">நிறைவு செய்வது</string>
<string name="poll_closing_time_days">நாட்களில்</string>
<string name="poll_is_closed">புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது</string>
<string name="poll_zap_amount">ஜாப் தொகை</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="looking_for_event">"நிகழ்வு %1$s ஐத் தேடி"</string>
<string name="custom_zaps_add_a_message">ஒரு பொது செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_private">ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_nonzap">விலைப்பட்டியல் செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_example">உங்கள் எல்லா வேலைகளுக்கும் நன்றி!</string>
<string name="lightning_create_and_add_invoice">உருவாக்கி சேர்க்கவும்</string>
<string name="poll_author_no_vote">வாக்கெடுப்பு ஆசிரியர்கள் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது.</string>
<string name="hash_verification_passed">குறிப்பு பதிவு செய்யப்பட்ட பின்னர் படம் மாற்றப்படவில்லை</string>
<string name="hash_verification_failed">படம் மாறிவிட்டது. மாற்றத்தை ஆசிரியர் பார்த்திருக்க மாட்டார்</string>
<string name="content_description_add_image">படத்தைச் சேர்க்கவும்</string>
<string name="content_description_add_video">வீடியோ சேர்க்கவும்</string>
<string name="content_description_add_document">ஆவணத்தைச் சேர்க்கவும்</string>
<string name="add_content">உருவாக்கி சேர்க்கவும்</string>
<string name="content_description">உள்ளடக்கங்களின் விளக்கம்</string>
<string name="content_description_example">சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு</string>
</resources>
+2 -2
View File
@@ -337,10 +337,10 @@
<string name="upload_server_nostrbuild_explainer">Uploads to Nostr.build. Nostr.build can change your image at any time</string> <string name="upload_server_nostrbuild_explainer">Uploads to Nostr.build. Nostr.build can change your image at any time</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string> <string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Protects from Imgur changing your image/video after you post</string> <string name="upload_server_imgur_nip94_explainer">Protects from Imgur modifying the content afterwards. This is a new NIP: other clients might not see the image</string>
<string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string> <string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Protects from NostrImg changing your image after you post</string> <string name="upload_server_nostrimg_nip94_explainer">Protects from NostrImg modifying the content afterwards. This is a new NIP: other clients might not see the image</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string> <string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list</string> <string name="upload_server_relays_nip95_explainer">Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list</string>