diff --git a/README.md b/README.md
index 3192471aa..4ecf0974d 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
+- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
diff --git a/app/build.gradle b/app/build.gradle
index 7db74552e..3f5de8711 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
- versionCode 135
- versionName "0.40.1"
+ versionCode 140
+ versionName "0.40.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b9b88716f..2fc406f2c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
index fbff99b63..1faebf214 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
@@ -35,8 +35,8 @@ object ServiceManager {
NostrChatroomListDataSource.account = myAccount
// Notification Elements
- NostrAccountDataSource.start()
NostrHomeDataSource.start()
+ NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index b6f30a581..7d5952379 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -417,6 +417,7 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
+ dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
@@ -445,6 +446,7 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
+ dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
index c74ed7997..aa3ce4ad5 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
@@ -286,7 +286,7 @@ open class Note(val idHex: String) {
response is PayInvoiceSuccessResponse
}
.associate {
- val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey)
+ val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
val amount = try {
if (lnInvoice == null) {
null
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
index 537b4626e..941aab60e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
@@ -14,6 +14,7 @@ class FileHeader(
val mimeType: String?,
val hash: String,
val size: Int,
+ val dim: String?,
val blurHash: String?,
val description: String? = null
) {
@@ -43,7 +44,7 @@ class FileHeader(
val hash = sha256.digest(data).toHexKey()
val size = data.size
- val blurHash = if (mimeType?.startsWith("image/") == true) {
+ val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
@@ -59,20 +60,22 @@ class FileHeader(
mBitmap.height
)
+ val dim = "${mBitmap.width}x${mBitmap.height}"
+
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
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) {
- 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 {
- BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
+ Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} 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) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
index a7262a2f2..f2656f949 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.*
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.TypedFilter
import nostr.postr.bechToBytes
@@ -50,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
},
TypedFilter(
- types = COMMON_FEED_TYPES,
+ types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
@@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
),
TypedFilter(
- types = COMMON_FEED_TYPES,
+ types = setOf(FeedType.SEARCH),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind),
search = mySearchString,
limit = 20
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt
index e25753d0b..3ecf62c9f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt
@@ -208,6 +208,7 @@ open class Event(
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
+ .registerTypeAdapter(Request::class.java, RequestDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt
index 5c91fcc80..a33344af6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt
@@ -19,6 +19,7 @@ class FileHeaderEvent(
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 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 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)
@@ -30,6 +31,7 @@ class FileHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
+ private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
@@ -41,6 +43,7 @@ class FileHeaderEvent(
description: String? = null,
hash: String? = null,
size: String? = null,
+ dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
@@ -53,6 +56,7 @@ class FileHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
+ dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt
index ae2fd5cdf..f2ba729bb 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt
@@ -20,6 +20,7 @@ class FileStorageHeaderEvent(
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 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 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)
@@ -30,6 +31,7 @@ class FileStorageHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
+ private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
@@ -41,6 +43,7 @@ class FileStorageHeaderEvent(
description: String? = null,
hash: String? = null,
size: String? = null,
+ dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
@@ -53,6 +56,7 @@ class FileStorageHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
+ dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt
index ff21118f2..966ed230d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt
@@ -1,10 +1,15 @@
package com.vitorpamplona.amethyst.service.model
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.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
+import java.lang.reflect.Type
import java.util.Date
class LnZapPaymentRequestEvent(
@@ -18,11 +23,15 @@ class LnZapPaymentRequestEvent(
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 {
- 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) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@@ -39,7 +48,7 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
- val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
+ val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = Utils.encrypt(
serializedRequest,
@@ -59,11 +68,37 @@ class LnZapPaymentRequestEvent(
// REQUEST OBJECTS
-abstract class Request(val method: String, val params: Params)
-abstract class Params
+abstract class Request(var method: String? = null)
// PayInvoice Call
+class PayInvoiceParams(var invoice: String? = null)
-class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
- class PayInvoiceParams(val invoice: String) : Params()
+class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
+
+ companion object {
+ fun create(bolt11: String): PayInvoiceMethod {
+ return PayInvoiceMethod(PayInvoiceParams(bolt11))
+ }
+ }
+}
+
+class RequestDeserializer :
+ JsonDeserializer {
+ @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(jsonObject, PayInvoiceMethod::class.java)
+ }
+ return null
+ }
+
+ companion object {
+ }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
index e60c431fc..808caf46f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19
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.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -92,6 +93,8 @@ class MainActivity : FragmentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
+ // starts muted every time
+ muted.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt
index c47da78fb..6514c5683 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt
@@ -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 =
- contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
+ contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt
index 86fd92b32..880192257 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt
@@ -61,11 +61,13 @@ open class NewMediaModel : ViewModel() {
contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
- ?: viewModelScope.launch {
- imageUploadingError.emit("Failed to upload the image / video")
- isUploadingImage = false
- uploadingPercentage.value = 0.00f
- uploadingDescription.value = null
+ ?: run {
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ isUploadingImage = false
+ uploadingPercentage.value = 0.00f
+ uploadingDescription.value = null
+ }
}
} else {
uploadingPercentage.value = 0.1f
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
index 1ca8ca51b..477ca586d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
@@ -31,6 +31,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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 mediaType = resolver.getType(uri) ?: ""
- postViewModel.load(account, uri, mediaType)
-
- LaunchedEffect(Unit) {
+ LaunchedEffect(uri) {
+ val mediaType = resolver.getType(uri) ?: ""
+ postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
@@ -101,7 +101,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
.fillMaxWidth()
.verticalScroll(scroolState)
) {
- ImageVideoPost(postViewModel)
+ ImageVideoPost(postViewModel, account)
}
}
}
@@ -110,7 +110,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
}
@Composable
-fun ImageVideoPost(postViewModel: NewMediaModel) {
+fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
val fileServers = listOf(
@@ -178,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
) {
TextSpinner(
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,
explainers = fileServerExplainers,
onSelect = {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
index f833be657..31d03d4a5 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
@@ -51,7 +51,7 @@ import java.net.URL
import java.util.regex.Pattern
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
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
index e60b543ec..c4305a3c6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
@@ -1,16 +1,47 @@
package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
+import android.view.View
import android.view.ViewGroup
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.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.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.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
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.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.core.net.toUri
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.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
-import com.google.android.exoplayer2.util.EventLogger
import com.vitorpamplona.amethyst.VideoCache
import java.io.File
+public var muted = mutableStateOf(true)
+
@Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(localFile.toUri(), description, onDialog)
@@ -56,6 +88,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
+ volume = 0f
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} 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(
- AndroidView(
- modifier = Modifier.fillMaxWidth(),
- factory = {
- StyledPlayerView(context).apply {
- player = exoPlayer
- layoutParams = FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.WRAP_CONTENT
- )
- resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
- onDialog?.let { innerOnDialog ->
- setFullscreenButtonClickListener {
+ BoxWithConstraints() {
+ AndroidView(
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 70.dp)
+ .align(Alignment.Center)
+ .onVisibilityChanges { visible ->
+ if (visible) {
+ exoPlayer.play()
+ } else {
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 ->
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, 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)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
index ec0d94d49..3f522c35c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -63,68 +64,81 @@ import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
+import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
+import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.io.File
import java.security.MessageDigest
abstract class ZoomableContent(
- val description: String? = null
+ val description: String? = null,
+ val dim: String? = null
)
abstract class ZoomableUrlContent(
val url: String,
description: String? = null,
val hash: String? = null,
+ dim: String? = null,
val uri: String? = null
-) : ZoomableContent(description)
+) : ZoomableContent(description, dim)
class ZoomableUrlImage(
url: String,
description: String? = null,
hash: String? = null,
- val bluehash: String? = null,
+ val blurhash: String? = null,
+ dim: String? = null,
uri: String? = null
-) : ZoomableUrlContent(url, description, hash, uri)
+) : ZoomableUrlContent(url, description, hash, dim, uri)
class ZoomableUrlVideo(
url: String,
description: String? = null,
hash: String? = null,
+ dim: String? = null,
uri: String? = null
-) : ZoomableUrlContent(url, description, hash, uri)
+) : ZoomableUrlContent(url, description, hash, dim, uri)
abstract class ZoomablePreloadedContent(
+ val localFile: File,
description: String? = null,
+ val mimeType: String? = null,
val isVerified: Boolean? = null,
+ dim: String? = null,
val uri: String
-) : ZoomableContent(description)
+) : ZoomableContent(description, dim)
-class ZoomableBitmapImage(
- val localFile: File,
- val mimeType: String? = null,
+class ZoomableLocalImage(
+ localFile: File,
+ mimeType: String? = null,
description: String? = null,
- val bluehash: String? = null,
+ val blurhash: String? = null,
+ dim: String? = null,
isVerified: Boolean? = null,
uri: String
-) : ZoomablePreloadedContent(description, isVerified, uri)
+) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
-class ZoomableBytesVideo(
- val localFile: File,
- val mimeType: String? = null,
+class ZoomableLocalVideo(
+ localFile: File,
+ mimeType: String? = null,
description: String? = null,
+ dim: String? = null,
isVerified: Boolean? = null,
uri: String
-) : ZoomablePreloadedContent(description, isVerified, uri)
+) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
@@ -144,45 +158,12 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List = listOf(content)) {
val clipboardManager = LocalClipboardManager.current
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
- // store the dialog open or close state
- var imageState by remember {
- mutableStateOf(null)
- }
-
- var verifiedHash by remember {
- mutableStateOf(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
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
@@ -192,17 +173,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List
RoundedCornerShape(15.dp)
)
+ val ratio = aspectRatio(content.dim)
+ if (ratio != null) {
+ mainImageModifier = mainImageModifier.aspectRatio(ratio)
+ }
+
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
)
- } else if (content is ZoomableBitmapImage) {
- mainImageModifier = mainImageModifier.combinedClickable(
- onClick = { dialogOpen = true },
- onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
- )
- } else if (content is ZoomableBytesVideo) {
+ } else if (content is ZoomablePreloadedContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
@@ -213,40 +194,30 @@ fun ZoomableContentView(content: ZoomableContent, images: List
}
}
- if (content is ZoomableUrlImage) {
- Box(contentAlignment = Alignment.Center) {
- AsyncImage(
- model = content.url,
- contentDescription = content.description,
- contentScale = ContentScale.FillWidth,
- modifier = mainImageModifier,
- onLoading = {
- imageState = it
- },
- onSuccess = {
- imageState = it
- }
- )
+ when (content) {
+ is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
+ is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
+ is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
+ is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
+ }
- if (imageState is AsyncImagePainter.State.Success) {
- HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
- }
+ if (dialogOpen) {
+ ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
+ }
+}
- AnimatedVisibility(
- visible = imageState !is AsyncImagePainter.State.Success,
- exit = fadeOut(animationSpec = tween(200))
- ) {
- if (content.bluehash != null) {
- DisplayBlueHash(content, mainImageModifier)
- } else {
- DisplayUrlWithLoadingSymbol(content)
- }
- }
- }
- } else if (content is ZoomableUrlVideo) {
- VideoView(content.url, content.description) { dialogOpen = true }
- } else if (content is ZoomableBitmapImage) {
- Box() {
+@Composable
+private fun LocalImageView(
+ content: ZoomableLocalImage,
+ mainImageModifier: Modifier
+) {
+ // store the dialog open or close state
+ var imageState by remember {
+ mutableStateOf(null)
+ }
+
+ Box(contentAlignment = Alignment.Center) {
+ if (content.localFile.exists()) {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
@@ -259,30 +230,124 @@ fun ZoomableContentView(content: ZoomableContent, images: List
imageState = it
}
)
-
- if (imageState is AsyncImagePainter.State.Success) {
- HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
- }
}
- if (imageState !is AsyncImagePainter.State.Success) {
- if (content.bluehash != null) {
- DisplayBlueHash(content, mainImageModifier)
+ if (imageState is AsyncImagePainter.State.Success) {
+ HashVerificationSymbol(content.isVerified, 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 {
- 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(null)
}
- if (dialogOpen) {
- ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
+ var verifiedHash by remember {
+ mutableStateOf(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
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
+ var cnt by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ withContext(Dispatchers.IO) {
+ delay(200)
+ cnt = content
+ }
+ }
+
+ cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
+}
+
+@Composable
+private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
if (content is ZoomableUrlContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
} else {
@@ -321,38 +386,20 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
}
@Composable
-private fun DisplayBlueHash(
- content: ZoomableUrlImage,
+private fun DisplayBlurHash(
+ blurhash: String?,
+ description: String?,
modifier: Modifier
) {
- if (content.bluehash == null) return
+ if (blurhash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
- content.bluehash
+ blurhash
),
- contentDescription = content.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,
+ contentDescription = description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
@@ -388,7 +435,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List(null)
- }
-
- var verifiedHash by remember {
- mutableStateOf(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
- }
- }
+ val mainModifier = Modifier
+ .fillMaxSize()
+ .zoomable(rememberZoomState())
if (content is ZoomableUrlImage) {
- Box() {
- 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)
- }
- }
- }
+ UrlImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description)
}
- } else if (content is ZoomableBitmapImage) {
- Box() {
- AsyncImage(
- 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) {
+ } else if (content is ZoomableLocalImage) {
+ LocalImageView(content = content, mainImageModifier = mainModifier)
+ } else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.localFile, content.description)
}
@@ -512,7 +478,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
}
@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
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
index 1294cf883..290988501 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
@@ -113,6 +113,8 @@ fun ChatroomMessageCompose(
var alignment: Arrangement.Horizontal
var shape: Shape
+ val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
@@ -333,11 +335,11 @@ fun ChatroomMessageCompose(
}
Row() {
- LikeReaction(baseNote, accountViewModel)
+ LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
- ZapReaction(baseNote, accountViewModel)
+ ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
- ReplyReaction(baseNote, accountViewModel, showCounter = false) {
+ ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
onWantsToReply(baseNote)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
index 16da10c3e..8d704e699 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
@@ -873,13 +873,13 @@ fun FileHeaderDisplay(note: Note) {
withContext(Dispatchers.IO) {
val blurHash = event.blurhash()
val hash = event.hash()
+ val dimensions = event.dimensions()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
- val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent()
content = if (isImage) {
- ZoomableUrlImage(fullUrl, description, hash, blurHash, uri)
+ ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
} else {
ZoomableUrlVideo(fullUrl, description, hash, uri)
}
@@ -888,7 +888,7 @@ fun FileHeaderDisplay(note: Note) {
content?.let {
ZoomableContentView(content = it, listOf(it))
- } ?: UrlPreview(fullUrl, "$fullUrl ")
+ }
}
@Composable
@@ -911,14 +911,30 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
val bytes = eventBytes?.decode()
val blurHash = eventHeader.blurhash()
+ val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val mimeType = eventHeader.mimeType()
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 {
if (bytes != null) {
- ZoomableBytesVideo(localDir, mimeType, description, true, uri)
+ ZoomableLocalVideo(
+ localFile = localDir,
+ mimeType = mimeType,
+ description = description,
+ dim = dimensions,
+ isVerified = true,
+ uri = uri
+ )
} else {
null
}
@@ -928,7 +944,7 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
content?.let {
ZoomableContentView(content = it, listOf(it))
- } ?: BlankNote()
+ }
}
@Composable
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
index 310c2c7df..cc3591e3e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
@@ -71,6 +71,8 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
+ val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+
var wantsToReplyTo by remember {
mutableStateOf(null)
}
@@ -91,23 +93,23 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
- ReplyReaction(baseNote, accountViewModel) {
+ ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
- BoostReaction(baseNote, accountViewModel) {
+ BoostReaction(baseNote, grayTint, accountViewModel) {
wantsToQuote = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
- LikeReaction(baseNote, accountViewModel)
+ LikeReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
- ZapReaction(baseNote, accountViewModel)
+ ZapReaction(baseNote, grayTint, accountViewModel)
}
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
fun ReplyReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
showCounter: Boolean = true,
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) {
Text(
" ${showCount(replies.size)}",
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
public fun BoostReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note
-
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -234,13 +231,14 @@ public fun BoostReaction(
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ color = grayTint
)
}
@Composable
fun LikeReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp
@@ -248,7 +246,6 @@ fun LikeReaction(
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -292,7 +289,7 @@ fun LikeReaction(
Text(
" ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ color = grayTint
)
}
@@ -300,6 +297,7 @@ fun LikeReaction(
@OptIn(ExperimentalFoundationApi::class)
fun ZapReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
@@ -315,19 +313,21 @@ fun ZapReaction(
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
+ var zapAmount by remember { mutableStateOf(null) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
+
+ zapAmount = account.calculateZappedAmount(zappedNote)
}
}
@@ -455,26 +455,23 @@ fun ZapReaction(
}
}
- var zapAmount by remember { mutableStateOf(null) }
-
- LaunchedEffect(key1 = zapsState) {
- withContext(Dispatchers.IO) {
- zapAmount = account.calculateZappedAmount(zappedNote)
- }
- }
-
Text(
showAmount(zapAmount),
fontSize = 14.sp,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
+ color = grayTint,
modifier = textModifier
)
}
@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 grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.size(iconSize),
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
index dcf3f2a4f..1b78c427c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
@@ -42,7 +42,8 @@ fun FeedView(
navController: NavController,
routeForLastRead: String?,
scrollStateKey: String? = null,
- scrollToTop: Boolean = false
+ scrollToTop: Boolean = false,
+ enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
@@ -50,7 +51,13 @@ fun FeedView(
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column {
Crossfade(
targetState = feedState,
@@ -88,7 +95,9 @@ fun FeedView(
}
}
- PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ if (enablePullRefresh) {
+ PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt
index c13e23f53..ab8e591a2 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@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()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { 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))
+ }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt
index e24453a3f..cee08ccf8 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt
@@ -96,7 +96,11 @@ class RelayFeedViewModel : ViewModel() {
@OptIn(ExperimentalMaterialApi::class)
@Composable
-fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) {
+fun RelayFeedView(
+ viewModel: RelayFeedViewModel,
+ accountViewModel: AccountViewModel,
+ enablePullRefresh: Boolean = true
+) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@@ -114,7 +118,13 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column() {
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))
+ }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt
similarity index 99%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt
index 13aa6ff46..3ac83010f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf()
+
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
object ScrollStateKeys {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
index 79da216b4..d2f8078cd 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@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()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { 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))
+ }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt
index b23bb3623..4a9ab3c11 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt
@@ -760,7 +760,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavCon
Column(
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(
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(
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(
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(
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(
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(
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(
modifier = Modifier.padding(vertical = 0.dp)
) {
- RelayFeedView(feedViewModel, accountViewModel)
+ RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false)
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
index 5337064d8..3a69e0a2d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
@@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
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.service.NostrGlobalDataSource
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.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -127,14 +130,62 @@ fun SearchScreen(
}
}
+class SearchBarViewModel : ViewModel() {
+ var searchValue by mutableStateOf("")
+ val searchResults = mutableStateOf>(emptyList())
+ val searchResultsNotes = mutableStateOf>(emptyList())
+ val searchResultsChannels = mutableStateOf>(emptyList())
+ val hashtagResults = mutableStateOf>(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)
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
- var searchValue by remember { mutableStateOf("") }
- val searchResults = remember { mutableStateOf>(emptyList()) }
- val searchResultsNotes = remember { mutableStateOf>(emptyList()) }
- val searchResultsChannels = remember { mutableStateOf>(emptyList()) }
- val hashtagResults = remember { mutableStateOf>(emptyList()) }
+ val searchBarViewModel: SearchBarViewModel = viewModel()
+
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -143,25 +194,16 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
- val isTrailingIconVisible by remember {
- derivedStateOf {
- searchValue.isNotBlank()
- }
- }
-
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel(CoroutineChannel.CONFLATED)
}
LaunchedEffect(db) {
- if (searchValue.length > 1) {
- withContext(Dispatchers.IO) {
- searchResults.value = LocalCache.findUsersStartingWith(searchValue)
- searchResultsNotes.value =
- LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }
- .reversed()
- searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
+ withContext(Dispatchers.IO) {
+ if (searchBarViewModel.isSearching()) {
+ println("Search Active")
+ searchBarViewModel.invalidateData()
}
}
}
@@ -174,15 +216,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.distinctUntilChanged()
.debounce(300)
.collectLatest {
- hashtagResults.value = findHashtags(it)
-
- if (it.length >= 4) {
+ if (it.length >= 2) {
onlineSearch.search(it.trim())
}
- searchResults.value = LocalCache.findUsersStartingWith(it)
- searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
- searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
+ searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
@@ -205,9 +243,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
verticalAlignment = Alignment.CenterVertically
) {
TextField(
- value = searchValue,
+ value = searchBarViewModel.searchValue,
onValueChange = {
- searchValue = it
+ searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
@@ -234,14 +272,10 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
},
trailingIcon = {
- if (isTrailingIconVisible) {
+ if (searchBarViewModel.isTrailingIconVisible) {
IconButton(
onClick = {
- searchValue = ""
- searchResults.value = emptyList()
- searchResultsChannels.value = emptyList()
- searchResultsNotes.value = emptyList()
-
+ searchBarViewModel.clean()
onlineSearch.clear()
}
) {
@@ -260,7 +294,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
- if (searchValue.isNotBlank()) {
+ if (searchBarViewModel.isSearching()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
@@ -269,17 +303,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
),
state = listState
) {
- itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
+ itemsIndexed(searchBarViewModel.hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(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)
}
- itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
+ itemsIndexed(searchBarViewModel.searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
ChannelName(
channelIdHex = item.idHex,
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)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
index 1be4f09dd..adba2b7ea 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
@@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
@@ -368,9 +367,9 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navContr
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote
}*/
- LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
- ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
- ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp)
+ LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
+ ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
+ ViewCountReaction(baseNote.idHex, grayTint = MaterialTheme.colors.onBackground, iconSize = 40.dp, barChartSize = 39.dp)
}
}
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
new file mode 100644
index 000000000..843b2972b
--- /dev/null
+++ b/app/src/main/res/values-ta/strings.xml
@@ -0,0 +1,286 @@
+
+
+ QR குறியீட்டைச் சுட்டிக்காட்டவும்
+ QR ஐக் காட்டவும்
+ சுயவிவரப் படம்
+ QR ஐ ஸ்கேன் செய்யவும்
+ பரவாயில்லை, காட்டவும்
+ குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர்
+ குறிப்பு கிடைக்கவில்லை
+ Channel Image
+ குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை
+ செய்தியை மறைகுறியாக்க முடியவில்லை
+ குழுப் படம்
+ வெளிப்படையான உள்ளடக்கம்
+ ஸ்பேம்
+ ஆள்மாறாட்டம்
+ சட்டவிரோத நடத்தை
+ தெரியாத
+ ரிலே ஐகான்
+ எழுதியவர் தெரியவில்லை
+ உரையை நகலெடுக்கவும்
+ எழுதியவர் IDஐ நகலெடுக்கவும்
+ குறிப்பின் IDஐ நகலெடுக்கவும்
+ ஒளிபரப்பு
+ நீக்குவதற்கு கோரிக்கை செய்
+ பயனரை முடக்கு + மறை
+ ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்
+ ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்
+ வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்
+ சட்டவிரோத நடத்தையைப் புகாரளிக்கவும்
+ பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ ஜாப் தொகை அமைப்பு இல்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்
+ ஜாப்களை அனுப்ப ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ ஜாப்கள்
+ பார்வை எண்ணிக்கை
+ உயர்த்து
+ உயர்த்தப்பட்டது
+ மேற்கோள் காட்டு
+ புதிய தொகை (ஸாட்களில்)
+ சேர்
+ "பதில் பெறுநர் "
+ மற்றும்
+ "அலை வரிசையில் "
+ சுயவிவர பேனர்
+ " பின்பற்றப் படுவோர்"
+ " பின்பற்றுவோர்"
+ சுயவிவரம்
+ பாதுகாப்பு வடிப்பான்கள்
+ வெளியேறு
+ மேலும் காட்டு
+ லைட்நிங் விலைப்பட்டியல்
+ செலுத்து
+ லைட்நிங் இனாம்
+ பெறுபவருக்கான குறிப்பு
+ மிக்க நன்றி!
+ தொகை (ஸாட்களில்)
+ ஸாட்கள் அனுப்பவும்
+ "%1$s க்கான முன்னோட்டத்தை புரிந்து கொள்வதில் பிழை: %2$s"
+ " %1$s க்கான முன்னோட்ட அட்டைப் படம்"
+ புதிய அலைவரிசை
+ அலைவரிசைப் பெயர்
+ எனது அருமையான குழு
+ பட Url
+ தகவல்
+ "எங்களைப் பற்றி.. "
+ உங்கள் மனதில் என்ன உள்ளது?
+ குறிப்பு
+ சேமி
+ உருவாக்கு
+ ரத்து
+ படத்தை பதிவேற்றுதல் தோல்வி
+ ரிலே முகவரி
+ குறிப்புகள்
+ பைட்டுகள்
+ பிழைகள்
+ முகப்பு ஊட்டம்
+ தனிப்பட்ட செய்தி ஊட்டம்
+ பொது செய்தி ஊட்டம்
+ முழுதளாவிய ஊட்டம்
+ தேடுதல் ஊட்டம்
+ ஒரு ரிலேவை சேர்
+ காட்சிப் பெயர்
+ எனது காட்சிப் பெயர்
+ பயனர் பெயர்
+ எனது பயனர் பெயர்
+ என்னைப் பற்றி
+ சுயவிவரப் படம் URL
+ பேனர் URL
+ வலைத்தள URL
+ LN முகவரி
+ LN URL (காலாவதியானது)
+ படம் கேலரியில் சேமிக்கப் பட்டது
+ படத்தை சேமிப்பது தோல்வியடைந்தது
+ படத்தை பதிவேற்று
+ பதிவேற்றம்...
+ பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை
+ "இங்கு பதிலளிக்கவும்.. "
+ குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்
+ அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு
+ அலைவரிசை பற்றிய தகவல்களை மாற்றும்
+ சேர்
+ தெரிந்த
+ புதிய கோரிக்கைகள்
+ முடக்கப்பட்ட பயனர்கள்
+ புதிய நூல்கள்
+ உரையாடல்கள்
+ குறிப்புகள்
+ பதில்கள்
+ "பின்தொடர படுவோர்"
+ "புகார்கள்"
+ மேலும்
+ " ரிலேகள்"
+ இணையதளம்
+ லைட்னிங் முகவரி
+ Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது
+ கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும்
+ பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது
+ கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்
+ நேரடி செய்தியை அனுப்பவும்
+ பயனரின்தகவலை திருத்துகிறது
+ பின்தொடர்
+ பதிலுக்கு பின்தொடரவும்
+ தடையை நீக்கு
+ பயனர் IDயை நகலெடுக்கவும்
+ பயனரைத் தடைசெய்க
+ "npub, hex, பயனர்பெயர் "
+ அழி
+ பயன்பாட்டு லோகோ
+ nsec / npub / hex தனிப்பட்ட சாவி
+ கடவுச்சொல்லை காட்டவும்
+ கடவுச்சொல்லை மறைக்கவும்
+ தவறான சாவி
+ "பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: "
+ பயன்பாட்டு விதிமுறைகளை
+ விதிமுறைகளை ஏற்றுக்கொள்வது தேவை
+ சாவி தேவை
+ உள்நுழை
+ புதிய சாவியை உருவாக்குங்கள்
+ ஊட்டம் ஏற்றப்படுகிறது
+ "பதில்களை ஏற்றுவதில் பிழை: "
+ மீண்டும் முயற்சி செய்
+ ஊட்டம் காலியாக உள்ளது.
+ புதுப்பி
+ உருவாக்கப்பட்டது
+ விளக்கம்:
+ மற்றும் படம்
+ மாற்றப்பட்ட அரட்டை பெயர்
+ விளக்கம்
+ மற்றும் படம்
+ வெளியேறு
+ பின்தொடர்வதை நிறுத்து
+ அலைவரிசை உருவாக்கப் பட்டது
+ "அலைவரிசை தகவல் மாற்றப்பட்டது"
+ பொது அரட்டை
+ குறிப்புகள் பெறப்பட்டன
+ அகற்று
+ தானியங்கி
+ மொழிபெயர்க்கப்பட்டுள்ளது மூலம்:
+
+ முதலில் %1$s இல் காண்பி
+ எப்போதும் %1$s க்கு மொழிபெயர்க்கவும்
+ %1$s இலிருந்து ஒருபோதும் மொழிபெயர்க்க வேண்டாம்
+ ஒருபோதும் இல்லை
+ இப்போது
+ h
+ m
+ d
+ நிர்வாணம்
+ அவதூறு / வெறுக்கத்தக்க பேச்சு
+ வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும்
+ நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும்
+ மற்றவைகள்
+ அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும்
+ அனைத்து புதியதையும் படித்ததாக குறிக்கவும்
+ அனைத்தையும் படித்ததாக குறிக்கவும்
+ காப்பு சாவிகள்
+
+ ## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள்
+ \n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம்.
+ \n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**.
+ \ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள்.
+ \ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம்.
+
+ ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது
+ எனது ரகசிய சாவியை நகலெடுக்கவும்
+ அங்கீகரிப்பு தோல்வியுற்றது
+ பிழை
+ " %1$s ஆல் உருவாக்கப்பட்டது"
+ " %1$s க்கான பேட்ஜ் விருது படம்"
+ நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்
+ பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:
+ குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது
+ கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது
+ கிளிப்போர்டுக்கு குறிப்பு ஐடி (@note1) நகலெடுக்கப் பட்டது
+ உரையைத் தேர்ந்தெடுக்கவும்
+ "தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்."
+ புதிய கணக்கைச் சேர்க்கவும்
+ கணக்குகள்
+ கணக்கைத் தேர்ந்தெடுக்கவும்
+ புதிய கணக்கைச் சேர்க்கவும்
+ செயலில் உள்ள கணக்கு
+ தனிப்பட்ட சாவி உள்ளது
+ படிக்க மட்டும், தனிப்பட்ட சாவி இல்லை
+ பின்
+ தேர்ந்தெடுக்கவும்
+ உலாவி இணைப்பைப் பகிரவும்
+ பகிர்
+ ஆசிரியர் ஐடி
+ குறிப்பு ஐடி
+ உரையை நகலெடுக்கவும்
+ அழி
+ பின்தொடர்வதை நிறுத்து
+ பின்தொடர்
+ நீக்க கோரிக்கை செய்
+ நீங்கள் தற்போது இணைக்கப்பட்டுள்ள ரிலேக்களிலிருந்து உங்கள் குறிப்பை நீக்குமாறு அமேதிஸ்ட் கோரும். உங்கள் குறிப்பு அந்த ரிலேக்களிலிருந்து அல்லது அது சேமிக்கப்பட்டுள்ள பிற ரிலேக்களிலிருந்து நிரந்தரமாக நீக்கப்படும் என்பதற்கு எந்த உத்தரவாதமும் இல்லை.
+ தடைசெய்
+ அழி
+ தடைசெய்
+ புகார்
+ அழி
+ மீண்டும் காட்ட வேண்டாம்
+ ஸ்பேம் அல்லது மோசடிகள்
+ அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை
+ தீங்கிழைக்கும் ஆள்மாறாட்டம்
+ நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்
+ சட்டவிரோத நடத்தை
+ ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.
+ பயனரைத் தடுத்து மறைக்கவும்
+ தகாதது என பதிவுசெய்
+ இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.
+ உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…
+ கூடுதல் சூழல்
+ காரணம்
+ ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்…
+ புகார் அளிக்கவும்
+ தடு மற்றும் புகாரளி
+ தடு
+ புக்மார்க்குகள்
+ தனிப்பட்ட புக்மார்க்குகள்
+ பொது புக்மார்க்குகள்
+ தனிப்பட்ட புக்மார்க்குகளில் சேர்க்கவும்
+ பொது புக்மார்க்குகளில் சேர்க்கவும்
+ தனிப்பட்ட புக்மார்க்குகளிலிருந்து அகற்று
+ பொது புக்மார்க்குகளிலிருந்து அகற்று
+ பணப்பை இணைப்பு சேவை
+ பயன்பாட்டை விட்டு வெளியேறாமல் ஜாப்களை செலுத்த ஒரு நாஸ்டர் ரகசியத்தை அங்கீகரிக்கிறது. ரகசியத்தை பாதுகாப்பாக வைத்து, முடிந்தால் தனியார் ரிலேவைப் பயன்படுத்துங்கள்
+ பணப்பை இணைப்பு பொது சாவி
+ பணப்பை இணைப்பு ரிலே
+ பணப்பை இணைப்பு ரகசியம்
+ ரகசிய சாவியைக் காட்டு
+ nsec / hex தனிப்பட்ட சாவி
+ உறுதிமொழி தொகை (ஸாட்களில்)
+
+ தேவையான புலங்கள்:
+ ஜாப் பெறுநர்கள்
+ முதன்மை வாக்கெடுப்பு விளக்கம்…
+ விருப்பம் %s
+ வாக்கெடுப்பு விருப்ப விளக்கம்
+ விருப்ப புலங்கள்:
+ குறைந்தபட்ச ஜாப்
+ அதிகபட்ச ஜாப்
+ ஒருமித்த கருத்து
+ (0–100)%
+ நிறைவு செய்வது
+ நாட்களில்
+ புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது
+ ஜாப் தொகை
+ இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது
+ "நிகழ்வு %1$s ஐத் தேடி"
+ ஒரு பொது செய்தியைச் சேர்க்கவும்
+ ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்
+ விலைப்பட்டியல் செய்தியைச் சேர்க்கவும்
+ உங்கள் எல்லா வேலைகளுக்கும் நன்றி!
+ உருவாக்கி சேர்க்கவும்
+ வாக்கெடுப்பு ஆசிரியர்கள் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது.
+ குறிப்பு பதிவு செய்யப்பட்ட பின்னர் படம் மாற்றப்படவில்லை
+ படம் மாறிவிட்டது. மாற்றத்தை ஆசிரியர் பார்த்திருக்க மாட்டார்
+ படத்தைச் சேர்க்கவும்
+ வீடியோ சேர்க்கவும்
+ ஆவணத்தைச் சேர்க்கவும்
+ உருவாக்கி சேர்க்கவும்
+ உள்ளடக்கங்களின் விளக்கம்
+ சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7fd0d6d94..5f2094740 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -337,10 +337,10 @@
Uploads to Nostr.build. Nostr.build can change your image at any time
Verifiable Imgur (NIP-94)
- Protects from Imgur changing your image/video after you post
+ Protects from Imgur modifying the content afterwards. This is a new NIP: other clients might not see the image
Verifiable NostrImg (NIP-94)
- Protects from NostrImg changing your image after you post
+ Protects from NostrImg modifying the content afterwards. This is a new NIP: other clients might not see the image
Your relays (NIP-95)
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