- Displays Video Uploading Progress

- Keeps position in the Video feed between screen
- Animates to the top when posting
- Adds NIP-95 in-disk caching
- Fixes the Crossfade for BlurHash on NIP94 and NIP95 events
This commit is contained in:
Vitor Pamplona
2023-05-01 16:42:58 -04:00
parent 9015089593
commit 8f257a7e2d
15 changed files with 393 additions and 124 deletions
@@ -403,7 +403,7 @@ class Account(
} }
} }
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? { fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
if (!isWriteable()) return null if (!isWriteable()) return null
val data = FileStorageEvent.create( val data = FileStorageEvent.create(
@@ -422,6 +422,12 @@ class Account(
privateKey = loggedIn.privKey!! privateKey = loggedIn.privKey!!
) )
return Pair(data, signedEvent)
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
if (!isWriteable()) return null
Client.send(data) Client.send(data)
LocalCache.consume(data, null) LocalCache.consume(data, null)
@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -11,6 +13,9 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.* import kotlinx.coroutines.*
import nostr.postr.toNpub import nostr.postr.toNpub
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -720,7 +725,21 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
note.loadEvent(event, author, emptyList()) try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
stream.write(event.decode())
stream.close()
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
} catch (e: IOException) {
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
}
// this is an invalid event. But we don't need to keep the data in memory.
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
note.loadEvent(eventNoData, author, emptyList())
refreshObservers(note) refreshObservers(note)
} }
@@ -29,7 +29,14 @@ class FileHeader(
} }
} }
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
description: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
try { try {
val sha256 = MessageDigest.getInstance("SHA-256") val sha256 = MessageDigest.getInstance("SHA-256")
@@ -86,10 +86,6 @@ open class BaseTextNoteEvent(
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) { if (parsed != null) {
if (content.contains("Testing event")) {
println("AAAA $key")
}
try { try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
@@ -40,13 +40,9 @@ class LnZapPaymentResponseEvent(
} }
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try { fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
println("Response Arrived: Decrypting")
if (content.isNotEmpty()) { if (content.isNotEmpty()) {
val decrypted = decrypt(privKey, pubKey) val decrypted = decrypt(privKey, pubKey)
println("Response Arrived: Parsing $decrypted") gson.fromJson(decrypted, Response::class.java)
val response = gson.fromJson(decrypted, Response::class.java)
println("Response Arrived: Decrypted ${response?.resultType}")
response
} else { } else {
null null
} }
@@ -79,7 +79,7 @@ object ImageSaver {
} }
fun saveImage( fun saveImage(
byteArray: ByteArray, localFile: File,
mimeType: String?, mimeType: String?,
context: Context, context: Context,
onSuccess: () -> Any?, onSuccess: () -> Any?,
@@ -87,7 +87,7 @@ object ImageSaver {
) { ) {
try { try {
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = byteArray.inputStream().source().buffer() val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ( saveContentQ(
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -13,6 +14,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URL
open class NewMediaModel : ViewModel() { open class NewMediaModel : ViewModel() {
var account: Account? = null var account: Account? = null
@@ -27,6 +29,11 @@ open class NewMediaModel : ViewModel() {
// Images and Videos // Images and Videos
var galleryUri by mutableStateOf<Uri?>(null) var galleryUri by mutableStateOf<Uri?>(null)
var uploadingPercentage = mutableStateOf(0.0f)
var uploadingDescription = mutableStateOf<String?>(null)
var onceUploaded: () -> Unit = {}
open fun load(account: Account, uri: Uri, contentType: String?) { open fun load(account: Account, uri: Uri, contentType: String?) {
this.account = account this.account = account
this.galleryUri = uri this.galleryUri = uri
@@ -40,7 +47,7 @@ open class NewMediaModel : ViewModel() {
} }
} }
fun upload(context: Context, onClose: () -> Unit) { fun upload(context: Context) {
isUploadingImage = true isUploadingImage = true
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
@@ -48,23 +55,32 @@ open class NewMediaModel : ViewModel() {
val serverToUse = selectedServer ?: return val serverToUse = selectedServer ?: return
if (selectedServer == ServersAvailable.NIP95) { if (selectedServer == ServersAvailable.NIP95) {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Loading"
val contentType = contentResolver.getType(uri) val contentType = contentResolver.getType(uri)
contentResolver.openInputStream(uri)?.use { contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description, onClose) createNIP95Record(it.readBytes(), contentType, description)
} }
?: viewModelScope.launch { ?: viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video") imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
} }
} else { } else {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage( ImageUploader.uploadImage(
uri = uri, uri = uri,
server = serverToUse, server = serverToUse,
contentResolver = contentResolver, contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType -> onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description, onClose) createNIP94Record(imageUrl, mimeType, description)
}, },
onError = { onError = {
isUploadingImage = false isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch { viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video") imageUploadingError.emit("Failed to upload the image / video")
} }
@@ -77,6 +93,8 @@ open class NewMediaModel : ViewModel() {
galleryUri = null galleryUri = null
isUploadingImage = false isUploadingImage = false
mediaType = null mediaType = null
uploadingDescription.value = null
uploadingPercentage.value = 0.0f
description = "" description = ""
selectedServer = account?.defaultFileServer selectedServer = account?.defaultFileServer
@@ -86,37 +104,68 @@ open class NewMediaModel : ViewModel() {
return !isUploadingImage && galleryUri != null && selectedServer != null return !isUploadingImage && galleryUri != null && selectedServer != null
} }
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) { fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload // Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) { if (mimeType?.startsWith("image/") == true) {
delay(2000) delay(2000)
} else { } else {
delay(5000) delay(15000)
} }
FileHeader.prepare( uploadingDescription.value = "Downloading"
imageUrl, uploadingPercentage.value = 0.60f
mimeType,
description, try {
onReady = { val imageData = URL(imageUrl).readBytes()
val note = account?.sendHeader(it)
isUploadingImage = false uploadingPercentage.value = 0.80f
cancel() uploadingDescription.value = "Hashing"
onClose()
}, FileHeader.prepare(
onError = { imageData,
isUploadingImage = false imageUrl,
viewModelScope.launch { mimeType,
imageUploadingError.emit("Failed to upload the image / video") description,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
val note = account?.sendHeader(it)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
} }
)
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
} }
) }
} }
} }
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) { fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
uploadingPercentage.value = 0.20f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare( FileHeader.prepare(
bytes, bytes,
@@ -124,13 +173,26 @@ open class NewMediaModel : ViewModel() {
mimeType, mimeType,
description, description,
onReady = { onReady = {
account?.sendNip95(bytes, headerInfo = it) uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.30f
val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) {
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second)
}
uploadingPercentage.value = 1.00f
isUploadingImage = false isUploadingImage = false
onceUploaded()
cancel() cancel()
onClose()
}, },
onError = { onError = {
uploadingDescription.value = null
uploadingPercentage.value = 0.00f
isUploadingImage = false isUploadingImage = false
cancel()
viewModelScope.launch { viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video") imageUploadingError.emit("Failed to upload the image / video")
} }
@@ -142,4 +204,7 @@ open class NewMediaModel : ViewModel() {
fun isImage() = mediaType?.startsWith("image") fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video") fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer fun defaultServer() = account?.defaultFileServer
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
}
} }
@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -36,23 +35,20 @@ import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) { fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
val account = accountViewModel.accountLiveData.value?.account ?: return val account = accountViewModel.accountLiveData.value?.account ?: return
val resolver = LocalContext.current.contentResolver val resolver = LocalContext.current.contentResolver
val postViewModel: NewMediaModel = viewModel()
val context = LocalContext.current val context = LocalContext.current
val scroolState = rememberScrollState() val scroolState = rememberScrollState()
LaunchedEffect(Unit) { val mediaType = resolver.getType(uri) ?: ""
val mediaType = resolver.getType(uri) ?: "" postViewModel.load(account, uri, mediaType)
postViewModel.load(account, uri, mediaType)
delay(100)
LaunchedEffect(Unit) {
postViewModel.imageUploadingError.collect { error -> postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show() Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
} }
@@ -85,15 +81,11 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
onClose() onClose()
}) })
if (postViewModel.isUploadingImage) {
LoadingAnimation()
}
PostButton( PostButton(
onPost = { onPost = {
postViewModel.upload(context) { onClose()
onClose() postViewModel.upload(context)
} postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
}, },
isActive = postViewModel.canPost() isActive = postViewModel.canPost()
) )
@@ -154,7 +146,11 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
LaunchedEffect(key1 = postViewModel.galleryUri) { LaunchedEffect(key1 = postViewModel.galleryUri) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
postViewModel.galleryUri?.let { postViewModel.galleryUri?.let {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
postViewModel.imageUploadingError.emit("Unable to load file")
}
} }
} }
} }
@@ -182,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
) { ) {
TextSpinner( TextSpinner(
label = stringResource(id = R.string.file_server), label = stringResource(id = R.string.file_server),
placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second, placeholder = fileServers.firstOrNull { it.first == postViewModel.selectedServer }?.second ?: fileServers[0].second,
options = fileServerOptions, options = fileServerOptions,
explainers = fileServerExplainers, explainers = fileServerExplainers,
onSelect = { onSelect = {
@@ -329,7 +329,8 @@ open class NewPostViewModel : ViewModel() {
mimeType, mimeType,
description, description,
onReady = { onReady = {
val note = account?.sendNip95(bytes, headerInfo = it) val nip95 = account?.createNip95(bytes, headerInfo = it)
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
isUploadingImage = false isUploadingImage = false
@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
/** /**
* A button to save the remote image to the gallery. * A button to save the remote image to the gallery.
@@ -86,14 +87,14 @@ fun SaveToGallery(url: String) {
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) { fun SaveToGallery(localFile: File, mimeType: String?) {
val localContext = LocalContext.current val localContext = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
fun saveImage() { fun saveImage() {
ImageSaver.saveImage( ImageSaver.saveImage(
context = localContext, context = localContext,
byteArray = byteArray, localFile = localFile,
mimeType = mimeType, mimeType = mimeType,
onSuccess = { onSuccess = {
scope.launch { scope.launch {
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
@@ -22,7 +23,14 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.util.EventLogger
import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.VideoCache
import java.io.File
@Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(localFile.toUri(), description, onDialog)
}
@Composable @Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
@@ -48,15 +56,21 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource( if (videoUri.scheme?.startsWith("file") == true) {
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( setMediaItem(media)
media } else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
) )
) }
prepare() prepare()
} }
} }
exoPlayer.addAnalyticsListener(EventLogger())
DisposableEffect( DisposableEffect(
AndroidView( AndroidView(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -95,7 +109,3 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
} }
} }
} }
@Composable
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
}
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -71,6 +74,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable import net.engawapg.lib.zoomable.zoomable
import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
abstract class ZoomableContent( abstract class ZoomableContent(
@@ -106,7 +110,7 @@ abstract class ZoomablePreloadedContent(
) : ZoomableContent(description) ) : ZoomableContent(description)
class ZoomableBitmapImage( class ZoomableBitmapImage(
val byteArray: ByteArray?, val localFile: File,
val mimeType: String? = null, val mimeType: String? = null,
description: String? = null, description: String? = null,
val bluehash: String? = null, val bluehash: String? = null,
@@ -115,7 +119,7 @@ class ZoomableBitmapImage(
) : ZoomablePreloadedContent(description, isVerified, uri) ) : ZoomablePreloadedContent(description, isVerified, uri)
class ZoomableBytesVideo( class ZoomableBytesVideo(
val byteArray: ByteArray, val localFile: File,
val mimeType: String? = null, val mimeType: String? = null,
description: String? = null, description: String? = null,
isVerified: Boolean? = null, isVerified: Boolean? = null,
@@ -166,7 +170,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} }
} }
} else if (content is ZoomableBitmapImage) { } else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) { LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified verifiedHash = content.isVerified
@@ -174,12 +178,8 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} }
} }
} else if (content is ZoomableBytesVideo) { } else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) { LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { verifiedHash = content.isVerified
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
} }
} }
@@ -214,7 +214,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} }
if (content is ZoomableUrlImage) { if (content is ZoomableUrlImage) {
Box() { Box(contentAlignment = Alignment.Center) {
AsyncImage( AsyncImage(
model = content.url, model = content.url,
contentDescription = content.description, contentDescription = content.description,
@@ -231,13 +231,16 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
if (imageState is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
} }
}
if (imageState !is AsyncImagePainter.State.Success) { AnimatedVisibility(
if (content.bluehash != null) { visible = imageState !is AsyncImagePainter.State.Success,
DisplayBlueHash(content, mainImageModifier) exit = fadeOut(animationSpec = tween(200))
} else { ) {
DisplayUrlWithLoadingSymbol(content) if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
}
} }
} }
} else if (content is ZoomableUrlVideo) { } else if (content is ZoomableUrlVideo) {
@@ -245,7 +248,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} else if (content is ZoomableBitmapImage) { } else if (content is ZoomableBitmapImage) {
Box() { Box() {
AsyncImage( AsyncImage(
model = content.byteArray, model = content.localFile,
contentDescription = content.description, contentDescription = content.description,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = mainImageModifier, modifier = mainImageModifier,
@@ -270,6 +273,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} }
} }
} else if (content is ZoomableBytesVideo) { } else if (content is ZoomableBytesVideo) {
VideoView(content.localFile, content.description) { dialogOpen = true }
} }
if (dialogOpen) { if (dialogOpen) {
@@ -384,8 +388,8 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
val myContent = allImages[pagerState.currentPage] val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) { if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url) SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableBitmapImage && myContent.byteArray != null) { } else if (myContent is ZoomableBitmapImage && myContent.localFile != null) {
SaveToGallery(byteArray = myContent.byteArray, mimeType = myContent.mimeType) SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
} }
} }
@@ -428,7 +432,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
} }
} }
} else if (content is ZoomableBitmapImage) { } else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) { LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified verifiedHash = content.isVerified
@@ -436,12 +440,8 @@ fun RenderImageOrVideo(content: ZoomableContent) {
} }
} }
} else if (content is ZoomableBytesVideo) { } else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) { LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { verifiedHash = content.isVerified
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
} }
} }
@@ -461,11 +461,21 @@ fun RenderImageOrVideo(content: ZoomableContent) {
imageState = it imageState = it
} }
) )
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) if (imageState is AsyncImagePainter.State.Success) {
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
} }
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
} }
} else if (content is ZoomableUrlVideo) { } else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
@@ -474,7 +484,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
} else if (content is ZoomableBitmapImage) { } else if (content is ZoomableBitmapImage) {
Box() { Box() {
AsyncImage( AsyncImage(
model = content.byteArray, model = content.localFile,
contentDescription = content.description, contentDescription = content.description,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = Modifier modifier = Modifier
@@ -496,7 +506,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
} }
} else if (content is ZoomableBytesVideo) { } else if (content is ZoomableBytesVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.byteArray, content.description) VideoView(content.localFile, content.description)
} }
} }
} }
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.math.BigDecimal import java.math.BigDecimal
import java.net.URL import java.net.URL
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -892,6 +893,7 @@ fun FileHeaderDisplay(note: Note) {
@Composable @Composable
fun FileStorageHeaderDisplay(baseNote: Note) { fun FileStorageHeaderDisplay(baseNote: Note) {
val appContext = LocalContext.current.applicationContext
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
@@ -906,16 +908,17 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) { LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val uri = "nostr:" + baseNote.toNEvent() val uri = "nostr:" + baseNote.toNEvent()
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
val bytes = eventBytes?.decode() val bytes = eventBytes?.decode()
val blurHash = eventHeader.blurhash() val blurHash = eventHeader.blurhash()
val description = eventHeader.content val description = eventHeader.content
val mimeType = eventHeader.mimeType() val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) { content = if (mimeType?.startsWith("image") == true) {
ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri) ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri)
} else { } else {
if (bytes != null) { if (bytes != null) {
ZoomableBytesVideo(bytes, mimeType, description, true, uri) ZoomableBytesVideo(localDir, mimeType, description, true, uri)
} else { } else {
null null
} }
@@ -1,17 +1,21 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf<String, ScrollState>() private val savedScrollStates = mutableMapOf<String, ScrollState>()
private data class ScrollState(val index: Int, val scrollOffset: Int) private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
object ScrollStateKeys { object ScrollStateKeys {
const val GLOBAL_SCREEN = "Global" const val GLOBAL_SCREEN = "Global"
const val NOTIFICATION_SCREEN = "Notifications" const val NOTIFICATION_SCREEN = "Notifications"
const val VIDEO_SCREEN = "Video"
val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies" val HOME_REPLIES = Route.Home.base + "FollowsReplies"
} }
@@ -25,16 +29,42 @@ fun rememberForeverLazyListState(
val scrollState = rememberSaveable(saver = LazyListState.Saver) { val scrollState = rememberSaveable(saver = LazyListState.Saver) {
val savedValue = savedScrollStates[key] val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
LazyListState( LazyListState(
savedIndex, savedIndex,
savedOffset savedOffset.roundToInt()
) )
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
val lastIndex = scrollState.firstVisibleItemIndex val lastIndex = scrollState.firstVisibleItemIndex
val lastOffset = scrollState.firstVisibleItemScrollOffset val lastOffset = scrollState.firstVisibleItemScrollOffset
savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
}
}
return scrollState
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun rememberForeverPagerState(
key: String,
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Float = 0.0f
): PagerState {
val scrollState = rememberSaveable(saver = PagerState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
PagerState(
savedIndex,
savedOffset
)
}
DisposableEffect(Unit) {
onDispose {
val lastIndex = scrollState.currentPage
val lastOffset = scrollState.currentPageOffsetFraction
savedScrollStates[key] = ScrollState(lastIndex, lastOffset) savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
} }
} }
@@ -6,6 +6,8 @@ import android.os.Build
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -22,10 +24,12 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -37,16 +41,27 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
@@ -56,10 +71,13 @@ import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.ui.actions.GallerySelect import com.vitorpamplona.amethyst.ui.actions.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.LikeReaction
@@ -73,6 +91,10 @@ import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable @Composable
fun VideoScreen( fun VideoScreen(
@@ -116,7 +138,7 @@ fun VideoScreen(
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(videoFeedView, accountViewModel, navController) FeedView(videoFeedView, accountViewModel, navController, ScrollStateKeys.VIDEO_SCREEN, scrollToTop)
} }
} }
} }
@@ -125,7 +147,9 @@ fun VideoScreen(
fun FeedView( fun FeedView(
videoFeedView: NostrVideoFeedViewModel, videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController navController: NavController,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
) { ) {
val feedState by videoFeedView.feedContent.collectAsState() val feedState by videoFeedView.feedContent.collectAsState()
@@ -148,7 +172,9 @@ fun FeedView(
SlidingCarousel( SlidingCarousel(
state.feed, state.feed,
accountViewModel, accountViewModel,
navController navController,
scrollStateKey,
scrollToTop
) )
} }
@@ -166,9 +192,21 @@ fun FeedView(
fun SlidingCarousel( fun SlidingCarousel(
feed: MutableState<List<Note>>, feed: MutableState<List<Note>>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController navController: NavController,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
) { ) {
val pagerState: PagerState = remember { PagerState() } val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
if (scrollToTop) {
LaunchedEffect(Unit) {
pagerState.scrollToPage(page = 0)
}
}
VerticalPager( VerticalPager(
pageCount = feed.value.size, pageCount = feed.value.size,
@@ -212,14 +250,14 @@ private fun RenderVideoOrPictureNote(
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
Column(Modifier.size(45.dp), verticalArrangement = Arrangement.Center) { Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) {
NoteAuthorPicture(note, navController, loggedIn, 45.dp) NoteAuthorPicture(note, navController, loggedIn, 55.dp)
} }
Column( Column(
Modifier Modifier
.padding(start = 10.dp, end = 10.dp) .padding(start = 10.dp, end = 10.dp)
.height(45.dp) .height(60.dp)
.weight(1f), .weight(1f),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
@@ -243,6 +281,9 @@ private fun RenderVideoOrPictureNote(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f)) ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
} }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) {
RelayBadges(baseNote = note)
}
} }
} }
} }
@@ -260,6 +301,42 @@ private fun RenderVideoOrPictureNote(
} }
} }
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = noteRelays
val uri = LocalUriHandler.current
FlowRow() {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)
.padding(1.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
}
}
@Composable @Composable
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -308,6 +385,26 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl
mutableStateOf<Uri?>(null) mutableStateOf<Uri?>(null)
} }
val scope = rememberCoroutineScope()
val postViewModel: NewMediaModel = viewModel()
postViewModel.onceUploaded {
scope.launch {
// awaits an refresh on the list
delay(250)
val route = Route.Video.route.replace("{scrollToTop}", "true")
navController.navigate(route) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = false }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
if (wantsToPost) { if (wantsToPost) {
val cameraPermissionState = val cameraPermissionState =
rememberPermissionState( rememberPermissionState(
@@ -339,21 +436,53 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl
} }
pickedURI?.let { pickedURI?.let {
NewMediaView(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController) NewMediaView(
} uri = it,
onClose = { pickedURI = null },
OutlinedButton( postViewModel = postViewModel,
onClick = { wantsToPost = true }, accountViewModel = accountViewModel,
modifier = Modifier.size(55.dp), navController = navController
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
) )
} }
if (postViewModel.isUploadingImage) {
ShowProgress(postViewModel)
} else {
OutlinedButton(
onClick = { wantsToPost = true },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
}
@Composable
private fun ShowProgress(postViewModel: NewMediaModel) {
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = postViewModel.uploadingPercentage.value,
modifier = Modifier
.size(55.dp)
.background(MaterialTheme.colors.background)
.clip(CircleShape),
strokeWidth = 5.dp
)
postViewModel.uploadingDescription.value?.let {
Text(
it,
color = MaterialTheme.colors.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center
)
}
}
} }