mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
486d43dc1b | ||
|
|
6731ca59cd | ||
|
|
7d620cd89a | ||
|
|
82a2586eab | ||
|
|
5a3f912b73 | ||
|
|
bc54134e65 | ||
|
|
0f080219b2 | ||
|
|
ff54629601 | ||
|
|
8f257a7e2d | ||
|
|
9015089593 | ||
|
|
2b87930283 | ||
|
|
15d94ca9c4 | ||
|
|
44af7f958c | ||
|
|
2b2028853e | ||
|
|
66dcc03351 | ||
|
|
22ba150c4e | ||
|
|
63cc00976f | ||
|
|
cc9d4c237a | ||
|
|
446135ea2a | ||
|
|
a38bbd7168 |
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 134
|
||||
versionName "0.40.0"
|
||||
versionCode 137
|
||||
versionName "0.40.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -72,7 +72,6 @@ object ServiceManager {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
LocalCache.pruneFileStorageEvents(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.Utils
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
@@ -170,7 +172,32 @@ class Account(
|
||||
return zapPaymentRequest != null
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
|
||||
fun isNIP47Author(pubkeyHex: String?): Boolean {
|
||||
val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!
|
||||
val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
|
||||
return (pubKey == pubkeyHex)
|
||||
}
|
||||
|
||||
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
|
||||
val myNip47 = zapPaymentRequest ?: return null
|
||||
return zapResponseEvent.response(
|
||||
myNip47.secret?.toByteArray() ?: loggedIn.privKey!!,
|
||||
myNip47.pubKeyHex.toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
|
||||
return zappedNote.isZappedBy(userProfile(), this) == true
|
||||
}
|
||||
|
||||
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
|
||||
return zappedNote?.zappedAmount(
|
||||
zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!,
|
||||
zapPaymentRequest?.pubKeyHex?.toByteArray()
|
||||
) ?: BigDecimal.ZERO
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
zapPaymentRequest?.let { nip47 ->
|
||||
@@ -179,11 +206,11 @@ class Account(
|
||||
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
|
||||
wcListener.start()
|
||||
|
||||
LocalCache.consume(event) {
|
||||
LocalCache.consume(event, zappedNote) {
|
||||
// After the response is received.
|
||||
val privKey = nip47.secret?.toByteArray()
|
||||
if (privKey != null) {
|
||||
onResponse(it.response(privKey, event.pubKey.toByteArray()))
|
||||
onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +403,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? {
|
||||
fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
val data = FileStorageEvent.create(
|
||||
@@ -395,6 +422,12 @@ class Account(
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
return Pair(data, signedEvent)
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
Client.send(data)
|
||||
LocalCache.consume(data, null)
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -12,6 +13,9 @@ import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -31,7 +35,8 @@ object LocalCache {
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
|
||||
|
||||
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
|
||||
val awaitingPaymentRequests =
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
if (isValidHexNpub(key)) {
|
||||
@@ -379,6 +384,7 @@ object LocalCache {
|
||||
masterNote.removeBoost(deleteNote)
|
||||
masterNote.removeReaction(deleteNote)
|
||||
masterNote.removeZap(deleteNote)
|
||||
masterNote.removeZapPayment(deleteNote)
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
@@ -719,7 +725,21 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
|
||||
// this is an invalid event. But we don't need to keep the data in memory.
|
||||
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
|
||||
|
||||
note.loadEvent(eventNoData, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
@@ -728,12 +748,41 @@ object LocalCache {
|
||||
// Does nothing without a response callback.
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
awaitingPaymentRequests.put(event.id, onResponse)
|
||||
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
zappedNote?.addZapPayment(note, null)
|
||||
|
||||
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentResponseEvent) {
|
||||
val responseCallback = awaitingPaymentRequests[event.requestId()]
|
||||
val requestId = event.requestId()
|
||||
val pair = awaitingPaymentRequests[requestId] ?: return
|
||||
|
||||
val (zappedNote, responseCallback) = pair
|
||||
|
||||
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
requestNote?.let { request ->
|
||||
zappedNote?.addZapPayment(request, note)
|
||||
}
|
||||
|
||||
if (responseCallback != null) {
|
||||
responseCallback(event)
|
||||
@@ -781,14 +830,6 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneFileStorageEvents(account: Account) {
|
||||
val toBeRemoved = notes.filter { it.value.event is FileStorageEvent }
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.key)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
channels.forEach { it ->
|
||||
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
@@ -45,6 +46,8 @@ open class Note(val idHex: String) {
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
@@ -156,6 +159,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
@@ -174,6 +188,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
@@ -204,6 +229,14 @@ open class Note(val idHex: String) {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,12 +266,44 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
.filter { it.amount != null }
|
||||
.associate {
|
||||
it.lnInvoice() to it.amount
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val completedPayments = if (privKey != null && walletServicePubkey != null) {
|
||||
// Payments confirmed by the User's Wallet
|
||||
zapPayments
|
||||
.asSequence()
|
||||
.filter {
|
||||
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
|
||||
response is PayInvoiceSuccessResponse
|
||||
}
|
||||
.associate {
|
||||
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey)
|
||||
val amount = try {
|
||||
if (lnInvoice == null) {
|
||||
null
|
||||
} else {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
lnInvoice to amount
|
||||
}
|
||||
.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
|
||||
@@ -19,12 +19,24 @@ class FileHeader(
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
try {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -10,7 +10,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageEvent.kind),
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -86,10 +86,6 @@ open class BaseTextNoteEvent(
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
if (content.contains("Testing event")) {
|
||||
println("AAAA $key")
|
||||
}
|
||||
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ open class Event(
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
@@ -57,7 +57,7 @@ class LnZapEvent(
|
||||
return content
|
||||
}
|
||||
|
||||
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
|
||||
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LnZapPaymentResponseEvent(
|
||||
id: HexKey,
|
||||
@@ -36,7 +41,8 @@ class LnZapPaymentResponseEvent(
|
||||
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
|
||||
val decrypted = decrypt(privKey, pubKey)
|
||||
gson.fromJson(decrypted, Response::class.java)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -58,7 +64,7 @@ abstract class Response(
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceResultParams(val preimage: String)
|
||||
}
|
||||
@@ -86,3 +92,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
|
||||
OTHER // Other error.
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseDeserializer :
|
||||
JsonDeserializer<Response?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Response? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val resultType = jsonObject.get("result_type")?.asString
|
||||
|
||||
if (resultType == "pay_invoice") {
|
||||
val result = jsonObject.get("result")?.asJsonObject
|
||||
val error = jsonObject.get("error")?.asJsonObject
|
||||
if (result != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (error != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
} else {
|
||||
// tries to guess
|
||||
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::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) {
|
||||
|
||||
@@ -79,7 +79,7 @@ object ImageSaver {
|
||||
}
|
||||
|
||||
fun saveImage(
|
||||
byteArray: ByteArray,
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
@@ -87,7 +87,7 @@ object ImageSaver {
|
||||
) {
|
||||
try {
|
||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = byteArray.inputStream().source().buffer()
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -13,6 +14,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URL
|
||||
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
@@ -27,6 +29,11 @@ open class NewMediaModel : ViewModel() {
|
||||
// Images and Videos
|
||||
var galleryUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
var uploadingPercentage = mutableStateOf(0.0f)
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
@@ -40,7 +47,7 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(context: Context, onClose: () -> Unit) {
|
||||
fun upload(context: Context) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
@@ -48,23 +55,34 @@ open class NewMediaModel : ViewModel() {
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Loading"
|
||||
val contentType = contentResolver.getType(uri)
|
||||
contentResolver.openInputStream(uri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description, onClose)
|
||||
createNIP95Record(it.readBytes(), contentType, description)
|
||||
}
|
||||
?: viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
?: 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
|
||||
uploadingDescription.value = "Uploading"
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description, onClose)
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
@@ -77,6 +95,8 @@ open class NewMediaModel : ViewModel() {
|
||||
galleryUri = null
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
description = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
@@ -86,37 +106,68 @@ open class NewMediaModel : ViewModel() {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
delay(15000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it)
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
try {
|
||||
val imageData = URL(imageUrl).readBytes()
|
||||
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
val note = account?.sendHeader(it)
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.20f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
@@ -124,13 +175,26 @@ open class NewMediaModel : ViewModel() {
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
account?.sendNip95(bytes, headerInfo = it)
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.30f
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
|
||||
if (nip95 != null) {
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second)
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.00f
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
@@ -142,4 +206,7 @@ open class NewMediaModel : ViewModel() {
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,31 +28,27 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val postViewModel: NewMediaModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
delay(100)
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -85,15 +81,11 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
|
||||
onClose()
|
||||
})
|
||||
|
||||
if (postViewModel.isUploadingImage) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.upload(context) {
|
||||
onClose()
|
||||
}
|
||||
onClose()
|
||||
postViewModel.upload(context)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -109,7 +101,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
ImageVideoPost(postViewModel)
|
||||
ImageVideoPost(postViewModel, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +110,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val fileServers = listOf(
|
||||
@@ -154,7 +146,11 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second,
|
||||
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
|
||||
@@ -329,7 +329,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendNip95(bytes, headerInfo = it)
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A button to save the remote image to the gallery.
|
||||
@@ -86,14 +87,14 @@ fun SaveToGallery(url: String) {
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) {
|
||||
fun SaveToGallery(localFile: File, mimeType: String?) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
byteArray = byteArray,
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
|
||||
@@ -89,7 +89,6 @@ fun RichTextViewer(
|
||||
if (content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("**") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
) {
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
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.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.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.google.android.exoplayer2.C
|
||||
@@ -19,10 +50,20 @@ import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
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.Util
|
||||
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)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
@@ -34,6 +75,14 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
val myAudioManager =
|
||||
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
val myAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
|
||||
val exoPlayer = remember(videoUri) {
|
||||
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
|
||||
|
||||
@@ -48,35 +97,76 @@ 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
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
setAudioAttributes(myAudioAttributes, true)
|
||||
if (Util.SDK_INT >= 23) {
|
||||
myAudioManager.adjustStreamVolume(
|
||||
C.STREAM_TYPE_MUSIC,
|
||||
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
|
||||
0
|
||||
)
|
||||
)
|
||||
} else {
|
||||
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
|
||||
}
|
||||
if (videoUri.scheme?.startsWith("file") == true) {
|
||||
setMediaItem(media)
|
||||
} else {
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Box() {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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 = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton(muted, Modifier) {
|
||||
muted.value = !muted.value
|
||||
if (Util.SDK_INT >= 23) {
|
||||
myAudioManager.adjustStreamVolume(
|
||||
C.STREAM_TYPE_MUSIC,
|
||||
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
|
||||
0
|
||||
)
|
||||
} else {
|
||||
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
@@ -96,6 +186,75 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -71,6 +74,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
abstract class ZoomableContent(
|
||||
@@ -106,7 +110,7 @@ abstract class ZoomablePreloadedContent(
|
||||
) : ZoomableContent(description)
|
||||
|
||||
class ZoomableBitmapImage(
|
||||
val byteArray: ByteArray?,
|
||||
val localFile: File,
|
||||
val mimeType: String? = null,
|
||||
description: String? = null,
|
||||
val bluehash: String? = null,
|
||||
@@ -115,7 +119,7 @@ class ZoomableBitmapImage(
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
|
||||
class ZoomableBytesVideo(
|
||||
val byteArray: ByteArray,
|
||||
val localFile: File,
|
||||
val mimeType: String? = null,
|
||||
description: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
@@ -166,7 +170,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
@@ -174,12 +178,8 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
Box() {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.description,
|
||||
@@ -231,13 +231,16 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
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)
|
||||
} else {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
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) {
|
||||
@@ -245,7 +248,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.byteArray,
|
||||
model = content.localFile,
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = mainImageModifier,
|
||||
@@ -270,6 +273,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
VideoView(content.localFile, content.description) { dialogOpen = true }
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
@@ -384,8 +388,8 @@ 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.byteArray != null) {
|
||||
SaveToGallery(byteArray = myContent.byteArray, mimeType = myContent.mimeType)
|
||||
} else if (myContent is ZoomableBitmapImage && myContent.localFile != null) {
|
||||
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,7 +432,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
@@ -436,12 +440,8 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,11 +461,21 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
if (imageState !is AsyncImagePainter.State.Success) {
|
||||
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = imageState !is AsyncImagePainter.State.Success,
|
||||
exit = fadeOut(animationSpec = tween(200))
|
||||
) {
|
||||
if (content.bluehash != null) {
|
||||
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
@@ -474,7 +484,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.byteArray,
|
||||
model = content.localFile,
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
@@ -496,7 +506,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.byteArray, content.description)
|
||||
VideoView(content.localFile, content.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.math.BigDecimal
|
||||
import java.net.URL
|
||||
import kotlin.time.ExperimentalTime
|
||||
@@ -892,6 +893,7 @@ fun FileHeaderDisplay(note: Note) {
|
||||
|
||||
@Composable
|
||||
fun FileStorageHeaderDisplay(baseNote: Note) {
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
||||
|
||||
val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
@@ -906,16 +908,17 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
|
||||
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val uri = "nostr:" + baseNote.toNEvent()
|
||||
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
|
||||
val bytes = eventBytes?.decode()
|
||||
val blurHash = eventHeader.blurhash()
|
||||
val description = eventHeader.content
|
||||
val mimeType = eventHeader.mimeType()
|
||||
|
||||
content = if (mimeType?.startsWith("image") == true) {
|
||||
ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri)
|
||||
ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri)
|
||||
} else {
|
||||
if (bytes != null) {
|
||||
ZoomableBytesVideo(bytes, mimeType, description, true, uri)
|
||||
ZoomableBytesVideo(localDir, mimeType, description, true, uri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -56,8 +56,7 @@ fun PollNote(
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val pollViewModel = PollNoteViewModel()
|
||||
pollViewModel.account = account
|
||||
pollViewModel.load(zappedNote)
|
||||
pollViewModel.load(account, zappedNote)
|
||||
|
||||
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
|
||||
OptionNote(
|
||||
@@ -212,7 +211,7 @@ fun ZapVote(
|
||||
clickablePrepend: @Composable () -> Unit
|
||||
) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -253,7 +252,7 @@ fun ZapVote(
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
|
||||
} else if (accountViewModel.isLoggedUser(zappedNote.author)) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
@@ -376,7 +375,7 @@ fun ZapVote(
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
|
||||
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ class PollNoteViewModel {
|
||||
var consensusThreshold: BigDecimal? = null
|
||||
|
||||
var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||
var wasZappedByAuthor: Boolean = false
|
||||
|
||||
fun load(note: Note?) {
|
||||
fun load(acc: Account, note: Note?) {
|
||||
account = acc
|
||||
pollNote = note
|
||||
pollEvent = pollNote?.event as PollNoteEvent
|
||||
pollOptions = pollEvent?.pollOptions()
|
||||
@@ -31,13 +33,14 @@ class PollNoteViewModel {
|
||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val user = account.userProfile() ?: return false
|
||||
val note = pollNote ?: return false
|
||||
return user != note.author && !note.isZappedBy(user, account)
|
||||
return user != note.author && !wasZappedByAuthor
|
||||
}
|
||||
|
||||
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
||||
|
||||
@@ -309,7 +309,7 @@ fun ZapReaction(
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
val zapMessage = ""
|
||||
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
@@ -326,7 +326,7 @@ fun ZapReaction(
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
|
||||
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@ fun ZapReaction(
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
zapAmount = zappedNote?.zappedAmount()
|
||||
zapAmount = account.calculateZappedAmount(zappedNote)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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 scrollOffset: Int)
|
||||
|
||||
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
|
||||
|
||||
object ScrollStateKeys {
|
||||
const val GLOBAL_SCREEN = "Global"
|
||||
const val NOTIFICATION_SCREEN = "Notifications"
|
||||
const val VIDEO_SCREEN = "Video"
|
||||
val HOME_FOLLOWS = Route.Home.base + "Follows"
|
||||
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
|
||||
}
|
||||
@@ -25,16 +30,42 @@ fun rememberForeverLazyListState(
|
||||
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
|
||||
val savedValue = savedScrollStates[key]
|
||||
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
|
||||
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
|
||||
LazyListState(
|
||||
savedIndex,
|
||||
savedOffset
|
||||
savedOffset.roundToInt()
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val lastIndex = scrollState.firstVisibleItemIndex
|
||||
val lastOffset = scrollState.firstVisibleItemScrollOffset
|
||||
savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun rememberForeverPagerState(
|
||||
key: String,
|
||||
initialFirstVisibleItemIndex: Int = 0,
|
||||
initialFirstVisibleItemScrollOffset: Float = 0.0f
|
||||
): PagerState {
|
||||
val scrollState = rememberSaveable(saver = PagerState.Saver) {
|
||||
val savedValue = savedScrollStates[key]
|
||||
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
|
||||
PagerState(
|
||||
savedIndex,
|
||||
savedOffset
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val lastIndex = scrollState.currentPage
|
||||
val lastOffset = scrollState.currentPageOffsetFraction
|
||||
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType)
|
||||
}
|
||||
|
||||
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
|
||||
return account.calculateIfNoteWasZappedByAccount(zappedNote)
|
||||
}
|
||||
|
||||
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
|
||||
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
|
||||
|
||||
@@ -88,6 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
|
||||
@@ -565,7 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
onSuccess = {
|
||||
// pay directly
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(it) { response ->
|
||||
account.sendZapPaymentRequestFor(it, null) { response ->
|
||||
if (response is PayInvoiceSuccessResponse) {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.os.Build
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -22,10 +24,12 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -37,16 +41,26 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
@@ -56,10 +70,13 @@ import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||
@@ -73,6 +90,10 @@ import com.vitorpamplona.amethyst.ui.screen.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun VideoScreen(
|
||||
@@ -116,7 +137,7 @@ fun VideoScreen(
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(videoFeedView, accountViewModel, navController)
|
||||
FeedView(videoFeedView, accountViewModel, navController, ScrollStateKeys.VIDEO_SCREEN, scrollToTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +146,9 @@ fun VideoScreen(
|
||||
fun FeedView(
|
||||
videoFeedView: NostrVideoFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
navController: NavController,
|
||||
scrollStateKey: String? = null,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
val feedState by videoFeedView.feedContent.collectAsState()
|
||||
|
||||
@@ -148,7 +171,9 @@ fun FeedView(
|
||||
SlidingCarousel(
|
||||
state.feed,
|
||||
accountViewModel,
|
||||
navController
|
||||
navController,
|
||||
scrollStateKey,
|
||||
scrollToTop
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,15 +191,30 @@ fun FeedView(
|
||||
fun SlidingCarousel(
|
||||
feed: MutableState<List<Note>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
navController: NavController,
|
||||
scrollStateKey: String? = null,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
val pagerState: PagerState = remember { PagerState() }
|
||||
val pagerState = if (scrollStateKey != null) {
|
||||
rememberForeverPagerState(scrollStateKey)
|
||||
} else {
|
||||
remember { PagerState() }
|
||||
}
|
||||
|
||||
if (scrollToTop) {
|
||||
LaunchedEffect(Unit) {
|
||||
pagerState.scrollToPage(page = 0)
|
||||
}
|
||||
}
|
||||
|
||||
VerticalPager(
|
||||
pageCount = feed.value.size,
|
||||
state = pagerState,
|
||||
beyondBoundsPageCount = 1,
|
||||
modifier = Modifier.fillMaxSize(1f)
|
||||
modifier = Modifier.fillMaxSize(1f),
|
||||
key = { index ->
|
||||
feed.value.getOrNull(index)?.idHex ?: "$index"
|
||||
}
|
||||
) { index ->
|
||||
feed.value.getOrNull(index)?.let { note ->
|
||||
RenderVideoOrPictureNote(note, accountViewModel, navController)
|
||||
@@ -209,14 +249,14 @@ private fun RenderVideoOrPictureNote(
|
||||
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
|
||||
Column(Modifier.size(45.dp), verticalArrangement = Arrangement.Center) {
|
||||
NoteAuthorPicture(note, navController, loggedIn, 45.dp)
|
||||
Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) {
|
||||
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 10.dp)
|
||||
.height(45.dp)
|
||||
.height(60.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
@@ -240,6 +280,9 @@ private fun RenderVideoOrPictureNote(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) {
|
||||
RelayBadges(baseNote = note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,6 +300,42 @@ private fun RenderVideoOrPictureNote(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayBadges(baseNote: Note) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val relaysToDisplay = noteRelays
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
FlowRow() {
|
||||
relaysToDisplay.forEach {
|
||||
val url = it.removePrefix("wss://").removePrefix("ws://")
|
||||
Box(
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.padding(1.dp)
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = "https://$url/favicon.ico",
|
||||
robotSize = 15.dp,
|
||||
model = "https://$url/favicon.ico",
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = { uri.openUri("https://$url") })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
@@ -305,6 +384,26 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl
|
||||
mutableStateOf<Uri?>(null)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val postViewModel: NewMediaModel = viewModel()
|
||||
postViewModel.onceUploaded {
|
||||
scope.launch {
|
||||
// awaits an refresh on the list
|
||||
delay(250)
|
||||
val route = Route.Video.route.replace("{scrollToTop}", "true")
|
||||
navController.navigate(route) {
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start) { inclusive = false }
|
||||
restoreState = true
|
||||
}
|
||||
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
@@ -336,21 +435,53 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl
|
||||
}
|
||||
|
||||
pickedURI?.let {
|
||||
NewMediaView(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { wantsToPost = true },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
NewMediaView(
|
||||
uri = it,
|
||||
onClose = { pickedURI = null },
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.isUploadingImage) {
|
||||
ShowProgress(postViewModel)
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { wantsToPost = true },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowProgress(postViewModel: NewMediaModel) {
|
||||
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
progress = postViewModel.uploadingPercentage.value,
|
||||
modifier = Modifier
|
||||
.size(55.dp)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clip(CircleShape),
|
||||
strokeWidth = 5.dp
|
||||
)
|
||||
postViewModel.uploadingDescription.value?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
286
app/src/main/res/values-ta/strings.xml
Normal file
286
app/src/main/res/values-ta/strings.xml
Normal file
@@ -0,0 +1,286 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="point_to_the_qr_code">QR குறியீட்டைச் சுட்டிக்காட்டவும்</string>
|
||||
<string name="show_qr">QR ஐக் காட்டவும்</string>
|
||||
<string name="profile_image">சுயவிவரப் படம்</string>
|
||||
<string name="scan_qr">QR ஐ ஸ்கேன் செய்யவும்</string>
|
||||
<string name="show_anyway">பரவாயில்லை, காட்டவும்</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர்</string>
|
||||
<string name="post_not_found">குறிப்பு கிடைக்கவில்லை</string>
|
||||
<string name="channel_image">Channel Image</string>
|
||||
<string name="referenced_event_not_found">குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை</string>
|
||||
<string name="could_not_decrypt_the_message">செய்தியை மறைகுறியாக்க முடியவில்லை</string>
|
||||
<string name="group_picture">குழுப் படம்</string>
|
||||
<string name="explicit_content">வெளிப்படையான உள்ளடக்கம்</string>
|
||||
<string name="spam">ஸ்பேம்</string>
|
||||
<string name="impersonation">ஆள்மாறாட்டம்</string>
|
||||
<string name="illegal_behavior">சட்டவிரோத நடத்தை</string>
|
||||
<string name="unknown">தெரியாத</string>
|
||||
<string name="relay_icon">ரிலே ஐகான்</string>
|
||||
<string name="unknown_author">எழுதியவர் தெரியவில்லை</string>
|
||||
<string name="copy_text">உரையை நகலெடுக்கவும்</string>
|
||||
<string name="copy_user_pubkey">எழுதியவர் IDஐ நகலெடுக்கவும்</string>
|
||||
<string name="copy_note_id">குறிப்பின் IDஐ நகலெடுக்கவும்</string>
|
||||
<string name="broadcast">ஒளிபரப்பு</string>
|
||||
<string name="request_deletion">நீக்குவதற்கு கோரிக்கை செய்</string>
|
||||
<string name="block_hide_user">பயனரை முடக்கு + மறை</string>
|
||||
<string name="report_spam_scam">ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்</string>
|
||||
<string name="report_impersonation">ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்</string>
|
||||
<string name="report_explicit_content">வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்</string>
|
||||
<string name="report_illegal_behaviour">சட்டவிரோத நடத்தையைப் புகாரளிக்கவும்</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">ஜாப் தொகை அமைப்பு இல்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">ஜாப்களை அனுப்ப ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
|
||||
<string name="zaps">ஜாப்கள்</string>
|
||||
<string name="view_count">பார்வை எண்ணிக்கை</string>
|
||||
<string name="boost">உயர்த்து</string>
|
||||
<string name="boosted">உயர்த்தப்பட்டது</string>
|
||||
<string name="quote">மேற்கோள் காட்டு</string>
|
||||
<string name="new_amount_in_sats">புதிய தொகை (ஸாட்களில்)</string>
|
||||
<string name="add">சேர்</string>
|
||||
<string name="replying_to">"பதில் பெறுநர் "</string>
|
||||
<string name="and">மற்றும்</string>
|
||||
<string name="in_channel">"அலை வரிசையில் "</string>
|
||||
<string name="profile_banner">சுயவிவர பேனர்</string>
|
||||
<string name="following">" பின்பற்றப் படுவோர்"</string>
|
||||
<string name="followers">" பின்பற்றுவோர்"</string>
|
||||
<string name="profile">சுயவிவரம்</string>
|
||||
<string name="security_filters">பாதுகாப்பு வடிப்பான்கள்</string>
|
||||
<string name="log_out">வெளியேறு</string>
|
||||
<string name="show_more">மேலும் காட்டு</string>
|
||||
<string name="lightning_invoice">லைட்நிங் விலைப்பட்டியல்</string>
|
||||
<string name="pay">செலுத்து</string>
|
||||
<string name="lightning_tips">லைட்நிங் இனாம்</string>
|
||||
<string name="note_to_receiver">பெறுபவருக்கான குறிப்பு</string>
|
||||
<string name="thank_you_so_much">மிக்க நன்றி!</string>
|
||||
<string name="amount_in_sats">தொகை (ஸாட்களில்)</string>
|
||||
<string name="send_sats">ஸாட்கள் அனுப்பவும்</string>
|
||||
<string name="error_parsing_preview_for">"%1$s க்கான முன்னோட்டத்தை புரிந்து கொள்வதில் பிழை: %2$s"</string>
|
||||
<string name="preview_card_image_for">" %1$s க்கான முன்னோட்ட அட்டைப் படம்"</string>
|
||||
<string name="new_channel">புதிய அலைவரிசை</string>
|
||||
<string name="channel_name">அலைவரிசைப் பெயர்</string>
|
||||
<string name="my_awesome_group">எனது அருமையான குழு</string>
|
||||
<string name="picture_url">பட Url</string>
|
||||
<string name="description">தகவல்</string>
|
||||
<string name="about_us">"எங்களைப் பற்றி.. "</string>
|
||||
<string name="what_s_on_your_mind">உங்கள் மனதில் என்ன உள்ளது?</string>
|
||||
<string name="post">குறிப்பு</string>
|
||||
<string name="save">சேமி</string>
|
||||
<string name="create">உருவாக்கு</string>
|
||||
<string name="cancel">ரத்து</string>
|
||||
<string name="failed_to_upload_the_image">படத்தை பதிவேற்றுதல் தோல்வி</string>
|
||||
<string name="relay_address">ரிலே முகவரி</string>
|
||||
<string name="posts">குறிப்புகள்</string>
|
||||
<string name="bytes">பைட்டுகள்</string>
|
||||
<string name="errors">பிழைகள்</string>
|
||||
<string name="home_feed">முகப்பு ஊட்டம்</string>
|
||||
<string name="private_message_feed">தனிப்பட்ட செய்தி ஊட்டம்</string>
|
||||
<string name="public_chat_feed">பொது செய்தி ஊட்டம்</string>
|
||||
<string name="global_feed">முழுதளாவிய ஊட்டம்</string>
|
||||
<string name="search_feed">தேடுதல் ஊட்டம்</string>
|
||||
<string name="add_a_relay">ஒரு ரிலேவை சேர்</string>
|
||||
<string name="display_name">காட்சிப் பெயர்</string>
|
||||
<string name="my_display_name">எனது காட்சிப் பெயர்</string>
|
||||
<string name="username">பயனர் பெயர்</string>
|
||||
<string name="my_username">எனது பயனர் பெயர்</string>
|
||||
<string name="about_me">என்னைப் பற்றி</string>
|
||||
<string name="avatar_url">சுயவிவரப் படம் URL</string>
|
||||
<string name="banner_url">பேனர் URL</string>
|
||||
<string name="website_url">வலைத்தள URL</string>
|
||||
<string name="ln_address">LN முகவரி </string>
|
||||
<string name="ln_url_outdated">LN URL (காலாவதியானது)</string>
|
||||
<string name="image_saved_to_the_gallery">படம் கேலரியில் சேமிக்கப் பட்டது</string>
|
||||
<string name="failed_to_save_the_image">படத்தை சேமிப்பது தோல்வியடைந்தது</string>
|
||||
<string name="upload_image">படத்தை பதிவேற்று</string>
|
||||
<string name="uploading">பதிவேற்றம்...</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
|
||||
<string name="reply_here">"இங்கு பதிலளிக்கவும்.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு</string>
|
||||
<string name="edits_the_channel_metadata">அலைவரிசை பற்றிய தகவல்களை மாற்றும்</string>
|
||||
<string name="join">சேர்</string>
|
||||
<string name="known">தெரிந்த</string>
|
||||
<string name="new_requests">புதிய கோரிக்கைகள்</string>
|
||||
<string name="blocked_users">முடக்கப்பட்ட பயனர்கள்</string>
|
||||
<string name="new_threads">புதிய நூல்கள்</string>
|
||||
<string name="conversations">உரையாடல்கள்</string>
|
||||
<string name="notes">குறிப்புகள்</string>
|
||||
<string name="replies">பதில்கள்</string>
|
||||
<string name="follows">"பின்தொடர படுவோர்"</string>
|
||||
<string name="reports">"புகார்கள்"</string>
|
||||
<string name="more_options">மேலும் </string>
|
||||
<string name="relays">" ரிலேகள்"</string>
|
||||
<string name="website">இணையதளம்</string>
|
||||
<string name="lightning_address">லைட்னிங் முகவரி</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது</string>
|
||||
<string name="copy_private_key_to_the_clipboard">கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும்</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்</string>
|
||||
<string name="send_a_direct_message">நேரடி செய்தியை அனுப்பவும்</string>
|
||||
<string name="edits_the_user_s_metadata">பயனரின்தகவலை திருத்துகிறது</string>
|
||||
<string name="follow">பின்தொடர்</string>
|
||||
<string name="follow_back">பதிலுக்கு பின்தொடரவும்</string>
|
||||
<string name="unblock">தடையை நீக்கு</string>
|
||||
<string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string>
|
||||
<string name="unblock_user">பயனரைத் தடைசெய்க</string>
|
||||
<string name="npub_hex_username">"npub, hex, பயனர்பெயர் "</string>
|
||||
<string name="clear">அழி</string>
|
||||
<string name="app_logo">பயன்பாட்டு லோகோ</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string>
|
||||
<string name="show_password">கடவுச்சொல்லை காட்டவும்</string>
|
||||
<string name="hide_password">கடவுச்சொல்லை மறைக்கவும்</string>
|
||||
<string name="invalid_key">தவறான சாவி</string>
|
||||
<string name="i_accept_the">"பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: "</string>
|
||||
<string name="terms_of_use">பயன்பாட்டு விதிமுறைகளை</string>
|
||||
<string name="acceptance_of_terms_is_required">விதிமுறைகளை ஏற்றுக்கொள்வது தேவை</string>
|
||||
<string name="key_is_required">சாவி தேவை</string>
|
||||
<string name="login">உள்நுழை</string>
|
||||
<string name="generate_a_new_key">புதிய சாவியை உருவாக்குங்கள்</string>
|
||||
<string name="loading_feed">ஊட்டம் ஏற்றப்படுகிறது</string>
|
||||
<string name="error_loading_replies">"பதில்களை ஏற்றுவதில் பிழை: "</string>
|
||||
<string name="try_again">மீண்டும் முயற்சி செய்</string>
|
||||
<string name="feed_is_empty">ஊட்டம் காலியாக உள்ளது.</string>
|
||||
<string name="refresh">புதுப்பி</string>
|
||||
<string name="created">உருவாக்கப்பட்டது</string>
|
||||
<string name="with_description_of">விளக்கம்:</string>
|
||||
<string name="and_picture">மற்றும் படம்</string>
|
||||
<string name="changed_chat_name_to">மாற்றப்பட்ட அரட்டை பெயர்</string>
|
||||
<string name="description_to">விளக்கம்</string>
|
||||
<string name="and_picture_to">மற்றும் படம்</string>
|
||||
<string name="leave">வெளியேறு</string>
|
||||
<string name="unfollow">பின்தொடர்வதை நிறுத்து</string>
|
||||
<string name="channel_created">அலைவரிசை உருவாக்கப் பட்டது</string>
|
||||
<string name="channel_information_changed_to">"அலைவரிசை தகவல் மாற்றப்பட்டது"</string>
|
||||
<string name="public_chat">பொது அரட்டை</string>
|
||||
<string name="posts_received">குறிப்புகள் பெறப்பட்டன</string>
|
||||
<string name="remove">அகற்று</string>
|
||||
<string name="translations_auto">தானியங்கி</string>
|
||||
<string name="translations_translated_from">மொழிபெயர்க்கப்பட்டுள்ளது மூலம்:</string>
|
||||
<string name="translations_to"></string>
|
||||
<string name="translations_show_in_lang_first">முதலில் %1$s இல் காண்பி</string>
|
||||
<string name="translations_always_translate_to_lang">எப்போதும் %1$s க்கு மொழிபெயர்க்கவும்</string>
|
||||
<string name="translations_never_translate_from_lang">%1$s இலிருந்து ஒருபோதும் மொழிபெயர்க்க வேண்டாம்</string>
|
||||
<string name="never">ஒருபோதும் இல்லை</string>
|
||||
<string name="now">இப்போது</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
<string name="nudity">நிர்வாணம்</string>
|
||||
<string name="profanity_hateful_speech">அவதூறு / வெறுக்கத்தக்க பேச்சு</string>
|
||||
<string name="report_hateful_speech">வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும்</string>
|
||||
<string name="report_nudity_porn">நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும்</string>
|
||||
<string name="others">மற்றவைகள்</string>
|
||||
<string name="mark_all_known_as_read">அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும்</string>
|
||||
<string name="mark_all_new_as_read">அனைத்து புதியதையும் படித்ததாக குறிக்கவும்</string>
|
||||
<string name="mark_all_as_read">அனைத்தையும் படித்ததாக குறிக்கவும்</string>
|
||||
<string name="backup_keys">காப்பு சாவிகள்</string>
|
||||
<string name="account_backup_tips_md">
|
||||
## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள்
|
||||
\n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம்.
|
||||
\n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**.
|
||||
\ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள்.
|
||||
\ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம்.
|
||||
</string>
|
||||
<string name="secret_key_copied_to_clipboard">ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது</string>
|
||||
<string name="copy_my_secret_key">எனது ரகசிய சாவியை நகலெடுக்கவும்</string>
|
||||
<string name="biometric_authentication_failed">அங்கீகரிப்பு தோல்வியுற்றது</string>
|
||||
<string name="biometric_error">பிழை</string>
|
||||
<string name="badge_created_by">" %1$s ஆல் உருவாக்கப்பட்டது"</string>
|
||||
<string name="badge_award_image_for">" %1$s க்கான பேட்ஜ் விருது படம்"</string>
|
||||
<string name="new_badge_award_notif">நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்</string>
|
||||
<string name="award_granted_to">பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:</string>
|
||||
<string name="copied_note_text_to_clipboard">குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது</string>
|
||||
<string name="copied_user_id_to_clipboard">கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது</string>
|
||||
<string name="copied_note_id_to_clipboard">கிளிப்போர்டுக்கு குறிப்பு ஐடி (@note1) நகலெடுக்கப் பட்டது</string>
|
||||
<string name="select_text_dialog_top">உரையைத் தேர்ந்தெடுக்கவும்</string>
|
||||
<string name="private_conversation_notification">"தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்."</string>
|
||||
<string name="account_switch_add_account_dialog_title">புதிய கணக்கைச் சேர்க்கவும்</string>
|
||||
<string name="drawer_accounts">கணக்குகள்</string>
|
||||
<string name="account_switch_select_account">கணக்கைத் தேர்ந்தெடுக்கவும்</string>
|
||||
<string name="account_switch_add_account_btn">புதிய கணக்கைச் சேர்க்கவும்</string>
|
||||
<string name="account_switch_active_account">செயலில் உள்ள கணக்கு</string>
|
||||
<string name="account_switch_has_private_key">தனிப்பட்ட சாவி உள்ளது</string>
|
||||
<string name="account_switch_pubkey_only">படிக்க மட்டும், தனிப்பட்ட சாவி இல்லை</string>
|
||||
<string name="back">பின்</string>
|
||||
<string name="quick_action_select">தேர்ந்தெடுக்கவும்</string>
|
||||
<string name="quick_action_share_browser_link">உலாவி இணைப்பைப் பகிரவும்</string>
|
||||
<string name="quick_action_share">பகிர்</string>
|
||||
<string name="quick_action_copy_user_id">ஆசிரியர் ஐடி</string>
|
||||
<string name="quick_action_copy_note_id">குறிப்பு ஐடி</string>
|
||||
<string name="quick_action_copy_text">உரையை நகலெடுக்கவும்</string>
|
||||
<string name="quick_action_delete">அழி</string>
|
||||
<string name="quick_action_unfollow">பின்தொடர்வதை நிறுத்து</string>
|
||||
<string name="quick_action_follow">பின்தொடர்</string>
|
||||
<string name="quick_action_request_deletion_alert_title">நீக்க கோரிக்கை செய்</string>
|
||||
<string name="quick_action_request_deletion_alert_body">நீங்கள் தற்போது இணைக்கப்பட்டுள்ள ரிலேக்களிலிருந்து உங்கள் குறிப்பை நீக்குமாறு அமேதிஸ்ட் கோரும். உங்கள் குறிப்பு அந்த ரிலேக்களிலிருந்து அல்லது அது சேமிக்கப்பட்டுள்ள பிற ரிலேக்களிலிருந்து நிரந்தரமாக நீக்கப்படும் என்பதற்கு எந்த உத்தரவாதமும் இல்லை.</string>
|
||||
<string name="quick_action_block_dialog_btn">தடைசெய்</string>
|
||||
<string name="quick_action_delete_dialog_btn">அழி</string>
|
||||
<string name="quick_action_block">தடைசெய்</string>
|
||||
<string name="quick_action_report">புகார் </string>
|
||||
<string name="quick_action_delete_button">அழி</string>
|
||||
<string name="quick_action_dont_show_again_button">மீண்டும் காட்ட வேண்டாம்</string>
|
||||
<string name="report_dialog_spam">ஸ்பேம் அல்லது மோசடிகள்</string>
|
||||
<string name="report_dialog_profanity">அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை</string>
|
||||
<string name="report_dialog_impersonation">தீங்கிழைக்கும் ஆள்மாறாட்டம்</string>
|
||||
<string name="report_dialog_nudity">நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்</string>
|
||||
<string name="report_dialog_illegal">சட்டவிரோத நடத்தை</string>
|
||||
<string name="report_dialog_blocking_a_user">ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
|
||||
<string name="report_dialog_block_hide_user_btn">பயனரைத் தடுத்து மறைக்கவும்</string>
|
||||
<string name="report_dialog_report_btn">தகாதது என பதிவுசெய்</string>
|
||||
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…</string>
|
||||
<string name="report_dialog_additional_reason_label">கூடுதல் சூழல்</string>
|
||||
<string name="report_dialog_select_reason_label">காரணம்</string>
|
||||
<string name="report_dialog_select_reason_placeholder">ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்…</string>
|
||||
<string name="report_dialog_post_report_btn">புகார் அளிக்கவும்</string>
|
||||
<string name="report_dialog_title">தடு மற்றும் புகாரளி</string>
|
||||
<string name="block_only">தடு</string>
|
||||
<string name="bookmarks">புக்மார்க்குகள்</string>
|
||||
<string name="private_bookmarks">தனிப்பட்ட புக்மார்க்குகள்</string>
|
||||
<string name="public_bookmarks">பொது புக்மார்க்குகள்</string>
|
||||
<string name="add_to_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளில் சேர்க்கவும்</string>
|
||||
<string name="add_to_public_bookmarks">பொது புக்மார்க்குகளில் சேர்க்கவும்</string>
|
||||
<string name="remove_from_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளிலிருந்து அகற்று</string>
|
||||
<string name="remove_from_public_bookmarks">பொது புக்மார்க்குகளிலிருந்து அகற்று</string>
|
||||
<string name="wallet_connect_service">பணப்பை இணைப்பு சேவை</string>
|
||||
<string name="wallet_connect_service_explainer">பயன்பாட்டை விட்டு வெளியேறாமல் ஜாப்களை செலுத்த ஒரு நாஸ்டர் ரகசியத்தை அங்கீகரிக்கிறது. ரகசியத்தை பாதுகாப்பாக வைத்து, முடிந்தால் தனியார் ரிலேவைப் பயன்படுத்துங்கள்</string>
|
||||
<string name="wallet_connect_service_pubkey">பணப்பை இணைப்பு பொது சாவி</string>
|
||||
<string name="wallet_connect_service_relay">பணப்பை இணைப்பு ரிலே</string>
|
||||
<string name="wallet_connect_service_secret">பணப்பை இணைப்பு ரகசியம்</string>
|
||||
<string name="wallet_connect_service_show_secret">ரகசிய சாவியைக் காட்டு</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex தனிப்பட்ட சாவி</string>
|
||||
<string name="pledge_amount_in_sats">உறுதிமொழி தொகை (ஸாட்களில்)</string>
|
||||
<string name="post_poll"></string>
|
||||
<string name="poll_heading_required">தேவையான புலங்கள்:</string>
|
||||
<string name="poll_zap_recipients">ஜாப் பெறுநர்கள்</string>
|
||||
<string name="poll_primary_description">முதன்மை வாக்கெடுப்பு விளக்கம்…</string>
|
||||
<string name="poll_option_index">விருப்பம் %s</string>
|
||||
<string name="poll_option_description">வாக்கெடுப்பு விருப்ப விளக்கம்</string>
|
||||
<string name="poll_heading_optional">விருப்ப புலங்கள்:</string>
|
||||
<string name="poll_zap_value_min">குறைந்தபட்ச ஜாப்</string>
|
||||
<string name="poll_zap_value_max">அதிகபட்ச ஜாப்</string>
|
||||
<string name="poll_consensus_threshold">ஒருமித்த கருத்து</string>
|
||||
<string name="poll_consensus_threshold_percent">(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>
|
||||
@@ -334,10 +334,10 @@
|
||||
<string name="upload_server_nostrimg_explainer">Uploads to NostrImg. NostrImg 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>
|
||||
|
||||
Reference in New Issue
Block a user