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