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

pull/391/head
Believethehype 2023-05-03 00:27:39 +02:00
commit b043e2da2c
32 zmienionych plików z 912 dodań i 369 usunięć

Wyświetl plik

@ -54,6 +54,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)

Wyświetl plik

@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 135
versionName "0.40.1"
versionCode 140
versionName "0.40.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

Wyświetl plik

@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@ -46,6 +47,12 @@
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />

Wyświetl plik

@ -35,8 +35,8 @@ object ServiceManager {
NostrChatroomListDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources

Wyświetl plik

@ -417,6 +417,7 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
@ -445,6 +446,7 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!

Wyświetl plik

@ -286,7 +286,7 @@ open class Note(val idHex: String) {
response is PayInvoiceSuccessResponse
}
.associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey)
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
val amount = try {
if (lnInvoice == null) {
null

Wyświetl plik

@ -14,6 +14,7 @@ class FileHeader(
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null
) {
@ -43,7 +44,7 @@ class FileHeader(
val hash = sha256.digest(data).toHexKey()
val size = data.size
val blurHash = if (mimeType?.startsWith("image/") == true) {
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
@ -59,20 +60,22 @@ class FileHeader(
mBitmap.height
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
} else {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else {
null
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()

Wyświetl plik

@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes
@ -50,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
},
TypedFilter(
types = COMMON_FEED_TYPES,
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
),
TypedFilter(
types = COMMON_FEED_TYPES,
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind),
search = mySearchString,
limit = 20
)

Wyświetl plik

@ -208,6 +208,7 @@ open class Event(
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
.registerTypeAdapter(Request::class.java, RequestDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)

Wyświetl plik

@ -19,6 +19,7 @@ class FileHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
@ -30,6 +31,7 @@ class FileHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
@ -41,6 +43,7 @@ class FileHeaderEvent(
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
@ -53,6 +56,7 @@ class FileHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },

Wyświetl plik

@ -20,6 +20,7 @@ class FileStorageHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
@ -30,6 +31,7 @@ class FileStorageHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
@ -41,6 +43,7 @@ class FileStorageHeaderEvent(
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
@ -53,6 +56,7 @@ class FileStorageHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },

Wyświetl plik

@ -1,10 +1,15 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
class LnZapPaymentRequestEvent(
@ -18,11 +23,15 @@ class LnZapPaymentRequestEvent(
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray): String? {
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
return Utils.decrypt(content, sharedSecret)
val jsonText = Utils.decrypt(content, sharedSecret)
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@ -39,7 +48,7 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = Utils.encrypt(
serializedRequest,
@ -59,11 +68,37 @@ class LnZapPaymentRequestEvent(
// REQUEST OBJECTS
abstract class Request(val method: String, val params: Params)
abstract class Params
abstract class Request(var method: String? = null)
// PayInvoice Call
class PayInvoiceParams(var invoice: String? = null)
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
class PayInvoiceParams(val invoice: String) : Params()
class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
companion object {
fun create(bolt11: String): PayInvoiceMethod {
return PayInvoiceMethod(PayInvoiceParams(bolt11))
}
}
}
class RequestDeserializer :
JsonDeserializer<Request?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Request? {
val jsonObject = json.asJsonObject
val method = jsonObject.get("method")?.asString
if (method == "pay_invoice") {
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
}
return null
}
companion object {
}
}

Wyświetl plik

@ -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) {

Wyświetl plik

@ -126,8 +126,14 @@ object ImageSaver {
)
}
val masterUri = if (contentType.startsWith("image")) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}

Wyświetl plik

@ -61,11 +61,13 @@ open class NewMediaModel : ViewModel() {
contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
}
} else {
uploadingPercentage.value = 0.1f

Wyświetl plik

@ -31,6 +31,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@ -45,10 +46,9 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
val scroolState = rememberScrollState()
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
LaunchedEffect(Unit) {
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
@ -101,7 +101,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
.fillMaxWidth()
.verticalScroll(scroolState)
) {
ImageVideoPost(postViewModel)
ImageVideoPost(postViewModel, account)
}
}
}
@ -110,7 +110,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel) {
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
val fileServers = listOf(
@ -178,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == postViewModel.selectedServer }?.second ?: fileServers[0].second,
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {

Wyświetl plik

@ -51,7 +51,7 @@ import java.net.URL
import java.util.regex.Pattern
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")

Wyświetl plik

@ -1,16 +1,47 @@
package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
@ -23,10 +54,11 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.util.EventLogger
import com.vitorpamplona.amethyst.VideoCache
import java.io.File
public var muted = mutableStateOf(true)
@Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(localFile.toUri(), description, onDialog)
@ -56,6 +88,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
volume = 0f
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
@ -69,28 +102,49 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
}
exoPlayer.addAnalyticsListener(EventLogger())
LaunchedEffect(key1 = muted.value) {
exoPlayer.volume = if (muted.value) 0f else 1f
}
DisposableEffect(
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
BoxWithConstraints() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible) {
exoPlayer.play()
} else {
exoPlayer.pause()
innerOnDialog(it)
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
MuteButton(muted, Modifier) {
muted.value = !muted.value
}
)
}
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@ -109,3 +163,76 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
}
}
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
val view = LocalView.current
var isVisible: Boolean? by remember { mutableStateOf(null) }
LaunchedEffect(isVisible) {
onVisibilityChanges(isVisible == true)
}
onGloballyPositioned { coordinates ->
isVisible = coordinates.isCompletelyVisible(view)
}
}
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
if (!isAttached) return false
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = android.graphics.Rect()
if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all.
return false
}
val bounds = boundsInWindow()
// Make sure we are completely in bounds.
return bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left &&
bounds.right <= globalRootRect.right &&
bounds.bottom <= globalRootRect.bottom
}
@Composable
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
Box(
modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (muted.value) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
} else {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
}
}
}

Wyświetl plik

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -63,68 +64,81 @@ import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.io.File
import java.security.MessageDigest
abstract class ZoomableContent(
val description: String? = null
val description: String? = null,
val dim: String? = null
)
abstract class ZoomableUrlContent(
val url: String,
description: String? = null,
val hash: String? = null,
dim: String? = null,
val uri: String? = null
) : ZoomableContent(description)
) : ZoomableContent(description, dim)
class ZoomableUrlImage(
url: String,
description: String? = null,
hash: String? = null,
val bluehash: String? = null,
val blurhash: String? = null,
dim: String? = null,
uri: String? = null
) : ZoomableUrlContent(url, description, hash, uri)
) : ZoomableUrlContent(url, description, hash, dim, uri)
class ZoomableUrlVideo(
url: String,
description: String? = null,
hash: String? = null,
dim: String? = null,
uri: String? = null
) : ZoomableUrlContent(url, description, hash, uri)
) : ZoomableUrlContent(url, description, hash, dim, uri)
abstract class ZoomablePreloadedContent(
val localFile: File,
description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null,
dim: String? = null,
val uri: String
) : ZoomableContent(description)
) : ZoomableContent(description, dim)
class ZoomableBitmapImage(
val localFile: File,
val mimeType: String? = null,
class ZoomableLocalImage(
localFile: File,
mimeType: String? = null,
description: String? = null,
val bluehash: String? = null,
val blurhash: String? = null,
dim: String? = null,
isVerified: Boolean? = null,
uri: String
) : ZoomablePreloadedContent(description, isVerified, uri)
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
class ZoomableBytesVideo(
val localFile: File,
val mimeType: String? = null,
class ZoomableLocalVideo(
localFile: File,
mimeType: String? = null,
description: String? = null,
dim: String? = null,
isVerified: Boolean? = null,
uri: String
) : ZoomablePreloadedContent(description, isVerified, uri)
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
@ -144,45 +158,12 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
var mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
@ -192,17 +173,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
RoundedCornerShape(15.dp)
)
val ratio = aspectRatio(content.dim)
if (ratio != null) {
mainImageModifier = mainImageModifier.aspectRatio(ratio)
}
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
)
} else if (content is ZoomableBitmapImage) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
)
} else if (content is ZoomableBytesVideo) {
} else if (content is ZoomablePreloadedContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
@ -213,40 +194,30 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
}
}
if (content is ZoomableUrlImage) {
Box(contentAlignment = Alignment.Center) {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
when (content) {
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
}
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
}
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else if (content is ZoomableUrlVideo) {
VideoView(content.url, content.description) { dialogOpen = true }
} else if (content is ZoomableBitmapImage) {
Box() {
@Composable
private fun LocalImageView(
content: ZoomableLocalImage,
mainImageModifier: Modifier
) {
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
Box(contentAlignment = Alignment.Center) {
if (content.localFile.exists()) {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
@ -259,30 +230,124 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
if (imageState !is AsyncImagePainter.State.Success) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else if (content is ZoomableBytesVideo) {
VideoView(content.localFile, content.description) { dialogOpen = true }
if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) {
BlankNote()
}
}
}
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
Box(contentAlignment = Alignment.Center) {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, mainImageModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
if (imageState is AsyncImagePainter.State.Error) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
}
}
private fun aspectRatio(dim: String?): Float? {
if (dim == null) return null
val parts = dim.split("x")
if (parts.size != 2) return null
return try {
val width = parts[0].toFloat()
val height = parts[1].toFloat()
width / height
} catch (e: Exception) {
null
}
}
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
delay(200)
cnt = content
}
}
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
}
@Composable
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
if (content is ZoomableUrlContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
} else {
@ -321,38 +386,20 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
}
@Composable
private fun DisplayBlueHash(
content: ZoomableUrlImage,
private fun DisplayBlurHash(
blurhash: String?,
description: String?,
modifier: Modifier
) {
if (content.bluehash == null) return
if (blurhash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
blurhash
),
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
}
@Composable
private fun DisplayBlueHash(
content: ZoomableBitmapImage,
modifier: Modifier
) {
if (content.bluehash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
),
contentDescription = content.description,
contentDescription = description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
@ -388,7 +435,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableBitmapImage && myContent.localFile != null) {
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
@ -411,100 +458,19 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
@Composable
fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
val mainModifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
if (content is ZoomableUrlImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
UrlImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description)
}
} else if (content is ZoomableBitmapImage) {
Box() {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else if (content is ZoomableBytesVideo) {
} else if (content is ZoomableLocalImage) {
LocalImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.localFile, content.description)
}
@ -512,7 +478,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->

Wyświetl plik

@ -113,6 +113,8 @@ fun ChatroomMessageCompose(
var alignment: Arrangement.Horizontal
var shape: Shape
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
@ -333,11 +335,11 @@ fun ChatroomMessageCompose(
}
Row() {
LikeReaction(baseNote, accountViewModel)
LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ZapReaction(baseNote, accountViewModel)
ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ReplyReaction(baseNote, accountViewModel, showCounter = false) {
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
onWantsToReply(baseNote)
}
}

Wyświetl plik

@ -873,13 +873,13 @@ fun FileHeaderDisplay(note: Note) {
withContext(Dispatchers.IO) {
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent()
content = if (isImage) {
ZoomableUrlImage(fullUrl, description, hash, blurHash, uri)
ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
} else {
ZoomableUrlVideo(fullUrl, description, hash, uri)
}
@ -888,7 +888,7 @@ fun FileHeaderDisplay(note: Note) {
content?.let {
ZoomableContentView(content = it, listOf(it))
} ?: UrlPreview(fullUrl, "$fullUrl ")
}
}
@Composable
@ -911,14 +911,30 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
val bytes = eventBytes?.decode()
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) {
ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri)
ZoomableLocalImage(
localFile = localDir,
mimeType = mimeType,
description = description,
blurhash = blurHash,
dim = dimensions,
isVerified = true,
uri = uri
)
} else {
if (bytes != null) {
ZoomableBytesVideo(localDir, mimeType, description, true, uri)
ZoomableLocalVideo(
localFile = localDir,
mimeType = mimeType,
description = description,
dim = dimensions,
isVerified = true,
uri = uri
)
} else {
null
}
@ -928,7 +944,7 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
content?.let {
ZoomableContentView(content = it, listOf(it))
} ?: BlankNote()
}
}
@Composable

Wyświetl plik

@ -71,6 +71,8 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
@ -91,23 +93,23 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ReplyReaction(baseNote, accountViewModel) {
ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
BoostReaction(baseNote, accountViewModel) {
BoostReaction(baseNote, grayTint, accountViewModel) {
wantsToQuote = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
LikeReaction(baseNote, accountViewModel)
LikeReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ZapReaction(baseNote, accountViewModel)
ZapReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ViewCountReaction(baseNote.idHex)
ViewCountReaction(baseNote.idHex, grayTint)
}
}
}
@ -115,6 +117,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
@Composable
fun ReplyReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
showCounter: Boolean = true,
iconSize: Dp = 20.dp,
@ -142,39 +145,33 @@ fun ReplyReaction(
}
}
) {
ReplyIcon(iconSize)
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(iconSize),
tint = grayTint
)
}
if (showCounter) {
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = grayTint
)
}
}
@Composable
private fun ReplyIcon(iconSize: Dp = 15.dp) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
public fun BoostReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@ -234,13 +231,14 @@ public fun BoostReaction(
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = grayTint
)
}
@Composable
fun LikeReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp
@ -248,7 +246,6 @@ fun LikeReaction(
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@ -292,7 +289,7 @@ fun LikeReaction(
Text(
" ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = grayTint
)
}
@ -300,6 +297,7 @@ fun LikeReaction(
@OptIn(ExperimentalFoundationApi::class)
fun ZapReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
@ -315,19 +313,21 @@ fun ZapReaction(
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
zapAmount = account.calculateZappedAmount(zappedNote)
}
}
@ -455,26 +455,23 @@ fun ZapReaction(
}
}
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
zapAmount = account.calculateZappedAmount(zappedNote)
}
}
Text(
showAmount(zapAmount),
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
color = grayTint,
modifier = textModifier
)
}
@Composable
public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) {
public fun ViewCountReaction(
idHex: String,
grayTint: Color,
iconSize: Dp = 20.dp,
barChartSize: Dp = 19.dp,
numberSize: Dp = 24.dp
) {
val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.size(iconSize),

Wyświetl plik

@ -42,7 +42,8 @@ fun FeedView(
navController: NavController,
routeForLastRead: String?,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
scrollToTop: Boolean = false,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
@ -50,7 +51,13 @@ fun FeedView(
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column {
Crossfade(
targetState = feedState,
@ -88,7 +95,9 @@ fun FeedView(
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

Wyświetl plik

@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun LnZapFeedView(
viewModel: LnZapFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
@ -59,7 +70,9 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

Wyświetl plik

@ -96,7 +96,11 @@ class RelayFeedViewModel : ViewModel() {
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) {
fun RelayFeedView(
viewModel: RelayFeedViewModel,
accountViewModel: AccountViewModel,
enablePullRefresh: Boolean = true
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@ -114,7 +118,13 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() {
val listState = rememberLazyListState()
@ -136,6 +146,8 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

Wyświetl plik

@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf<String, ScrollState>()
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
object ScrollStateKeys {

Wyświetl plik

@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun UserFeedView(
viewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
@ -59,7 +70,9 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}

Wyświetl plik

@ -760,7 +760,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavCon
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@ -780,7 +780,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, navController: Nav
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@ -801,7 +801,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navControll
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@ -821,7 +821,7 @@ fun TabFollows(baseUser: User, accountViewModel: AccountViewModel, navController
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
UserFeedView(feedViewModel, accountViewModel, navController)
UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@ -840,7 +840,7 @@ fun TabFollowers(baseUser: User, accountViewModel: AccountViewModel, navControll
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
UserFeedView(feedViewModel, accountViewModel, navController)
UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@ -859,7 +859,7 @@ fun TabReceivedZaps(baseUser: User, accountViewModel: AccountViewModel, navContr
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
LnZapFeedView(feedViewModel, accountViewModel, navController)
LnZapFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@ -878,7 +878,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, navController
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@ -913,7 +913,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RelayFeedView(feedViewModel, accountViewModel)
RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false)
}
}
}

Wyświetl plik

@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@ -55,6 +57,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@ -127,14 +130,62 @@ fun SearchScreen(
}
}
class SearchBarViewModel : ViewModel() {
var searchValue by mutableStateOf("")
val searchResults = mutableStateOf<List<User>>(emptyList())
val searchResultsNotes = mutableStateOf<List<Note>>(emptyList())
val searchResultsChannels = mutableStateOf<List<Channel>>(emptyList())
val hashtagResults = mutableStateOf<List<String>>(emptyList())
val isTrailingIconVisible by
derivedStateOf {
searchValue.isNotBlank()
}
fun updateSearchValue(newValue: String) {
searchValue = newValue
}
private fun runSearch() {
if (searchValue.isBlank()) {
hashtagResults.value = emptyList()
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
return
}
hashtagResults.value = findHashtags(searchValue)
searchResults.value = LocalCache.findUsersStartingWith(searchValue)
searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
}
fun clean() {
searchValue = ""
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
runSearch()
}
fun invalidateData() {
bundler.invalidate()
}
fun isSearching() = searchValue.isNotBlank()
}
@OptIn(FlowPreview::class)
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
var searchValue by remember { mutableStateOf("") }
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val hashtagResults = remember { mutableStateOf<List<String>>(emptyList()) }
val searchBarViewModel: SearchBarViewModel = viewModel()
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@ -143,25 +194,16 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.isNotBlank()
}
}
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
}
LaunchedEffect(db) {
if (searchValue.length > 1) {
withContext(Dispatchers.IO) {
searchResults.value = LocalCache.findUsersStartingWith(searchValue)
searchResultsNotes.value =
LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }
.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
withContext(Dispatchers.IO) {
if (searchBarViewModel.isSearching()) {
println("Search Active")
searchBarViewModel.invalidateData()
}
}
}
@ -174,15 +216,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.distinctUntilChanged()
.debounce(300)
.collectLatest {
hashtagResults.value = findHashtags(it)
if (it.length >= 4) {
if (it.length >= 2) {
onlineSearch.search(it.trim())
}
searchResults.value = LocalCache.findUsersStartingWith(it)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
@ -205,9 +243,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = searchValue,
value = searchBarViewModel.searchValue,
onValueChange = {
searchValue = it
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
@ -234,14 +272,10 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
},
trailingIcon = {
if (isTrailingIconVisible) {
if (searchBarViewModel.isTrailingIconVisible) {
IconButton(
onClick = {
searchValue = ""
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
searchBarViewModel.clean()
onlineSearch.clear()
}
) {
@ -260,7 +294,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
if (searchValue.isNotBlank()) {
if (searchBarViewModel.isSearching()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
@ -269,17 +303,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
),
state = listState
) {
itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
itemsIndexed(searchBarViewModel.hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(item) {
navController.navigate("Hashtag/$item")
}
}
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
itemsIndexed(searchBarViewModel.searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
itemsIndexed(searchBarViewModel.searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
@ -296,7 +330,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
itemsIndexed(searchBarViewModel.searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}

Wyświetl plik

@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
@ -368,9 +367,9 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navContr
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote
}*/
LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp)
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
ViewCountReaction(baseNote.idHex, grayTint = MaterialTheme.colors.onBackground, iconSize = 40.dp, barChartSize = 39.dp)
}
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -337,10 +337,10 @@
<string name="upload_server_nostrbuild_explainer">Uploads to Nostr.build. Nostr.build can change your image at any time</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Protects from Imgur changing your image/video after you post</string>
<string name="upload_server_imgur_nip94_explainer">Protects from Imgur modifying the content afterwards. This is a new NIP: other clients might not see the image</string>
<string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Protects from NostrImg changing your image after you post</string>
<string name="upload_server_nostrimg_nip94_explainer">Protects from NostrImg modifying the content afterwards. This is a new NIP: other clients might not see the image</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list</string>