kopia lustrzana https://github.com/vitorpamplona/amethyst
Merge remote-tracking branch 'upstream/main' into nostrbuild
commit
b043e2da2c
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<!-- Used for SDK < 29 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
|
@ -46,6 +47,12 @@
|
|||
<data android:scheme="nostr" />
|
||||
</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">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
|
|
@ -35,8 +35,8 @@ object ServiceManager {
|
|||
NostrChatroomListDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
|
|
|
@ -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!!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) },
|
||||
|
|
|
@ -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) },
|
||||
|
|
|
@ -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<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.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) {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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-]+)*\\/?)(.*)")
|
||||
|
|
|
@ -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<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.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<ZoomableContent> = 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<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
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
|
@ -192,17 +173,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||
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<ZoomableContent>
|
|||
}
|
||||
}
|
||||
|
||||
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<AsyncImagePainter.State?>(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<ZoomableContent>
|
|||
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<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
|
||||
var verifiedHash by remember {
|
||||
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
|
||||
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) {
|
||||
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<ZoomableConte
|
|||
val myContent = allImages[pagerState.currentPage]
|
||||
if (myContent is ZoomableUrlContent) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -411,100 +458,19 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
|||
|
||||
@Composable
|
||||
fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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 ->
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Note?>(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<BigDecimal?>(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<BigDecimal?>(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),
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
private val savedScrollStates = mutableMapOf<String, ScrollState>()
|
||||
|
||||
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
|
||||
|
||||
object ScrollStateKeys {
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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)
|
||||
@Composable
|
||||
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
var searchValue by remember { mutableStateOf("") }
|
||||
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 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<String>(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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">(0–100)%</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>
|
|
@ -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_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_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_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>
|
||||
|
|
Ładowanie…
Reference in New Issue