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 06ec6d96f..b6f30a581 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -403,7 +403,7 @@ class Account( } } - fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? { + fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair? { 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 8572869e5..6dff6da3a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } 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 f5dd55142..537b4626e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -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") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index bcd47faee..9b03abaa9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index fee398c69..42f71f7c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -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 } 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 ba62883c4..c47da78fb 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 @@ -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( 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 2c4a89999..86fd92b32 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 @@ -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(null) + var uploadingPercentage = mutableStateOf(0.0f) + var uploadingDescription = mutableStateOf(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 + } } 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 b50b1843e..1ca8ca51b 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 @@ -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 = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 22c7994f4..fd38e3031 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt index 1da278cbc..0336877ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt @@ -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 { 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 59c7ec844..e60b543ec 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 @@ -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) { -} 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 775851211..ec0d94d49 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 @@ -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 } } } 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 } } } 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 } 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 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 } 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 } } } else if (content is ZoomableBytesVideo) { + VideoView(content.localFile, content.description) { dialogOpen = true } } if (dialogOpen) { @@ -384,8 +388,8 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List() -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) } } 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 9a85f9406..1be4f09dd 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 @@ -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>, 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(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 + ) + } + } }