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