mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2024-09-30 00:40:49 +00:00
Adds support for NIP-95 Images
This commit is contained in:
parent
c6a0b0950a
commit
86fe9b4a65
@ -48,7 +48,7 @@ class FileHeaderEvent(
|
|||||||
privateKey: ByteArray,
|
privateKey: ByteArray,
|
||||||
createdAt: Long = Date().time / 1000
|
createdAt: Long = Date().time / 1000
|
||||||
): FileHeaderEvent {
|
): FileHeaderEvent {
|
||||||
var tags = listOfNotNull(
|
val tags = listOfNotNull(
|
||||||
listOf(URL, url),
|
listOf(URL, url),
|
||||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||||
hash?.let { listOf(HASH, it) },
|
hash?.let { listOf(HASH, it) },
|
||||||
|
@ -0,0 +1,58 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.vitorpamplona.amethyst.model.HexKey
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class FileStorageEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey
|
||||||
|
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
|
||||||
|
fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) }
|
||||||
|
|
||||||
|
fun decode(): ByteArray? {
|
||||||
|
return try {
|
||||||
|
Base64.getDecoder().decode(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 1064
|
||||||
|
|
||||||
|
private const val TYPE = "type"
|
||||||
|
private const val DECRYPT = "decrypt"
|
||||||
|
|
||||||
|
fun encode(bytes: ByteArray): String {
|
||||||
|
return Base64.getEncoder().encodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
mimeType: String,
|
||||||
|
data: ByteArray,
|
||||||
|
privateKey: ByteArray,
|
||||||
|
createdAt: Long = Date().time / 1000
|
||||||
|
): FileStorageEvent {
|
||||||
|
val tags = listOfNotNull(
|
||||||
|
listOf(TYPE, mimeType)
|
||||||
|
)
|
||||||
|
|
||||||
|
val content = encode(data)
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||||
|
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||||
|
val sig = Utils.sign(id, privateKey)
|
||||||
|
return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.HexKey
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class FileStorageHeaderEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey
|
||||||
|
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||||
|
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||||
|
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||||
|
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||||
|
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||||
|
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||||
|
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 1065
|
||||||
|
|
||||||
|
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||||
|
private const val MIME_TYPE = "m"
|
||||||
|
private const val FILE_SIZE = "size"
|
||||||
|
private const val HASH = "x"
|
||||||
|
private const val MAGNET_URI = "magnet"
|
||||||
|
private const val TORRENT_INFOHASH = "i"
|
||||||
|
private const val BLUR_HASH = "blurhash"
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
storageEvent: FileStorageEvent,
|
||||||
|
mimeType: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null,
|
||||||
|
size: String? = null,
|
||||||
|
blurhash: String? = null,
|
||||||
|
magnetURI: String? = null,
|
||||||
|
torrentInfoHash: String? = null,
|
||||||
|
encryptionKey: AESGCM? = null,
|
||||||
|
privateKey: ByteArray,
|
||||||
|
createdAt: Long = Date().time / 1000
|
||||||
|
): FileStorageHeaderEvent {
|
||||||
|
val tags = listOfNotNull(
|
||||||
|
listOf("e", storageEvent.id),
|
||||||
|
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||||
|
hash?.let { listOf(HASH, it) },
|
||||||
|
size?.let { listOf(FILE_SIZE, it) },
|
||||||
|
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||||
|
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||||
|
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||||
|
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||||
|
)
|
||||||
|
|
||||||
|
val content = description ?: ""
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||||
|
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||||
|
val sig = Utils.sign(id, privateKey)
|
||||||
|
return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -83,3 +83,63 @@ fun SaveToGallery(url: String) {
|
|||||||
Text(text = stringResource(id = R.string.save), color = Color.White)
|
Text(text = stringResource(id = R.string.save), color = Color.White)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) {
|
||||||
|
val localContext = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun saveImage() {
|
||||||
|
ImageSaver.saveImage(
|
||||||
|
context = localContext,
|
||||||
|
byteArray = byteArray,
|
||||||
|
mimeType = mimeType,
|
||||||
|
onSuccess = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
localContext.getString(R.string.image_saved_to_the_gallery),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
localContext.getString(R.string.failed_to_save_the_image),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val writeStoragePermissionState = rememberPermissionState(
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
) { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
saveImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
|
||||||
|
saveImage()
|
||||||
|
} else {
|
||||||
|
writeStoragePermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = ButtonDefaults
|
||||||
|
.buttonColors(
|
||||||
|
backgroundColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.save), color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -142,9 +142,9 @@ fun RichTextViewer(
|
|||||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
ZoomableImage(fullUrl)
|
ZoomableUrlImage(fullUrl)
|
||||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
ZoomableVideo(fullUrl)
|
ZoomableUrlVideo(fullUrl)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
@ -95,3 +95,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||||
|
}
|
||||||
|
@ -6,6 +6,7 @@ import android.widget.Toast
|
|||||||
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
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -73,23 +74,47 @@ import net.engawapg.lib.zoomable.zoomable
|
|||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
abstract class ZoomableContent(
|
abstract class ZoomableContent(
|
||||||
val url: String,
|
val description: String? = null
|
||||||
val description: String? = null,
|
|
||||||
val hash: String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class ZoomableImage(
|
abstract class ZoomableUrlContent(
|
||||||
|
val url: String,
|
||||||
|
description: String? = null,
|
||||||
|
val hash: String? = null
|
||||||
|
) : ZoomableContent(description)
|
||||||
|
|
||||||
|
class ZoomableUrlImage(
|
||||||
url: String,
|
url: String,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
hash: String? = null,
|
hash: String? = null,
|
||||||
val bluehash: String? = null
|
val bluehash: String? = null
|
||||||
) : ZoomableContent(url, description, hash)
|
) : ZoomableUrlContent(url, description, hash)
|
||||||
|
|
||||||
class ZoomableVideo(
|
class ZoomableUrlVideo(
|
||||||
url: String,
|
url: String,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
hash: String? = null
|
hash: String? = null
|
||||||
) : ZoomableContent(url, description, hash)
|
) : ZoomableUrlContent(url, description, hash)
|
||||||
|
|
||||||
|
abstract class ZoomablePreloadedContent(
|
||||||
|
description: String? = null,
|
||||||
|
val isVerified: Boolean? = null
|
||||||
|
) : ZoomableContent(description)
|
||||||
|
|
||||||
|
class ZoomableBitmapImage(
|
||||||
|
val byteArray: ByteArray?,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
val bluehash: String? = null,
|
||||||
|
isVerified: Boolean? = null
|
||||||
|
) : ZoomablePreloadedContent(description, isVerified)
|
||||||
|
|
||||||
|
class ZoomableBytesVideo(
|
||||||
|
val byteArray: ByteArray,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
isVerified: Boolean? = null
|
||||||
|
) : ZoomablePreloadedContent(description, isVerified)
|
||||||
|
|
||||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
@ -97,11 +122,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
|||||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
|
||||||
return if (isImage) {
|
return if (isImage) {
|
||||||
ZoomableImage(fullUrl)
|
ZoomableUrlImage(fullUrl)
|
||||||
} else if (isVideo) {
|
} else if (isVideo) {
|
||||||
ZoomableVideo(fullUrl)
|
ZoomableUrlVideo(fullUrl)
|
||||||
} else {
|
} else {
|
||||||
ZoomableImage(fullUrl)
|
ZoomableUrlImage(fullUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +151,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
mutableStateOf<Boolean?>(null)
|
mutableStateOf<Boolean?>(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (content is ZoomableUrlContent) {
|
||||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||||
if (imageState is AsyncImagePainter.State.Success) {
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
@ -133,8 +159,25 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (content is ZoomableBitmapImage) {
|
||||||
|
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = content.isVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (content is ZoomableBytesVideo) {
|
||||||
|
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = content.isVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val mainImageModifier = Modifier
|
var mainImageModifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(shape = RoundedCornerShape(15.dp))
|
.clip(shape = RoundedCornerShape(15.dp))
|
||||||
.border(
|
.border(
|
||||||
@ -142,12 +185,19 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||||
RoundedCornerShape(15.dp)
|
RoundedCornerShape(15.dp)
|
||||||
)
|
)
|
||||||
.combinedClickable(
|
|
||||||
|
if (content is ZoomableUrlContent) {
|
||||||
|
mainImageModifier = mainImageModifier.combinedClickable(
|
||||||
onClick = { dialogOpen = true },
|
onClick = { dialogOpen = true },
|
||||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
|
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
mainImageModifier = mainImageModifier.clickable {
|
||||||
|
dialogOpen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (content is ZoomableImage) {
|
if (content is ZoomableUrlImage) {
|
||||||
Box() {
|
Box() {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = content.url,
|
model = content.url,
|
||||||
@ -174,8 +224,36 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
DisplayUrlWithLoadingSymbol(content)
|
DisplayUrlWithLoadingSymbol(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (content is ZoomableUrlVideo) {
|
||||||
VideoView(content.url, content.description) { dialogOpen = true }
|
VideoView(content.url, content.description) { dialogOpen = true }
|
||||||
|
} else if (content is ZoomableBitmapImage) {
|
||||||
|
Box() {
|
||||||
|
AsyncImage(
|
||||||
|
model = content.byteArray,
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = mainImageModifier,
|
||||||
|
onLoading = {
|
||||||
|
imageState = it
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
imageState = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageState !is AsyncImagePainter.State.Success) {
|
||||||
|
if (content.bluehash != null) {
|
||||||
|
DisplayBlueHash(content, mainImageModifier)
|
||||||
|
} else {
|
||||||
|
DisplayUrlWithLoadingSymbol(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (content is ZoomableBytesVideo) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
@ -185,7 +263,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||||
|
if (content is ZoomableUrlContent) {
|
||||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||||
|
} else {
|
||||||
|
Text("Loading content... ")
|
||||||
|
}
|
||||||
|
|
||||||
val myId = "inlineContent"
|
val myId = "inlineContent"
|
||||||
val emptytext = buildAnnotatedString {
|
val emptytext = buildAnnotatedString {
|
||||||
@ -220,7 +302,26 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DisplayBlueHash(
|
private fun DisplayBlueHash(
|
||||||
content: ZoomableImage,
|
content: ZoomableUrlImage,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
if (content.bluehash == null) return
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
AsyncImage(
|
||||||
|
model = BlurHashRequester.imageRequest(
|
||||||
|
context,
|
||||||
|
content.bluehash
|
||||||
|
),
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplayBlueHash(
|
||||||
|
content: ZoomableBitmapImage,
|
||||||
modifier: Modifier
|
modifier: Modifier
|
||||||
) {
|
) {
|
||||||
if (content.bluehash == null) return
|
if (content.bluehash == null) return
|
||||||
@ -264,7 +365,12 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
|||||||
) {
|
) {
|
||||||
CloseButton(onCancel = onDismiss)
|
CloseButton(onCancel = onDismiss)
|
||||||
|
|
||||||
SaveToGallery(url = allImages[pagerState.currentPage].url)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allImages.size > 1) {
|
if (allImages.size > 1) {
|
||||||
@ -297,6 +403,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
mutableStateOf<Boolean?>(null)
|
mutableStateOf<Boolean?>(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (content is ZoomableUrlContent) {
|
||||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||||
if (imageState is AsyncImagePainter.State.Success) {
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
@ -304,8 +411,25 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (content is ZoomableBitmapImage) {
|
||||||
|
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = content.isVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (content is ZoomableBytesVideo) {
|
||||||
|
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = content.isVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (content is ZoomableImage) {
|
if (content is ZoomableUrlImage) {
|
||||||
Box() {
|
Box() {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = content.url,
|
model = content.url,
|
||||||
@ -327,15 +451,42 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (content is ZoomableUrlVideo) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
VideoView(content.url, content.description)
|
VideoView(content.url, content.description)
|
||||||
}
|
}
|
||||||
|
} else if (content is ZoomableBitmapImage) {
|
||||||
|
Box() {
|
||||||
|
AsyncImage(
|
||||||
|
model = content.byteArray,
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zoomable(rememberZoomState()),
|
||||||
|
onLoading = {
|
||||||
|
imageState = it
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
imageState = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imageState !is AsyncImagePainter.State.Success) {
|
||||||
|
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (content is ZoomableBytesVideo) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
|
VideoView(content.byteArray, content.description)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoilApi::class)
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
|
private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
|
||||||
if (content.hash == null) return null
|
if (content.hash == null) return null
|
||||||
|
|
||||||
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||||
|
@ -169,6 +169,8 @@ fun NoteComposeInner(
|
|||||||
BadgeDisplay(baseNote = note)
|
BadgeDisplay(baseNote = note)
|
||||||
} else if (noteEvent is FileHeaderEvent) {
|
} else if (noteEvent is FileHeaderEvent) {
|
||||||
FileHeaderDisplay(note)
|
FileHeaderDisplay(note)
|
||||||
|
} else if (noteEvent is FileStorageHeaderEvent) {
|
||||||
|
FileStorageHeaderDisplay(note)
|
||||||
} else {
|
} else {
|
||||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||||
|
|
||||||
@ -794,9 +796,9 @@ fun FileHeaderDisplay(note: Note) {
|
|||||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
content = if (isImage) {
|
content = if (isImage) {
|
||||||
ZoomableImage(fullUrl, description, hash, blurHash)
|
ZoomableUrlImage(fullUrl, description, hash, blurHash)
|
||||||
} else {
|
} else {
|
||||||
ZoomableVideo(fullUrl, description, hash)
|
ZoomableUrlVideo(fullUrl, description, hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -806,6 +808,42 @@ fun FileHeaderDisplay(note: Note) {
|
|||||||
} ?: UrlPreview(fullUrl, "$fullUrl ")
|
} ?: UrlPreview(fullUrl, "$fullUrl ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileStorageHeaderDisplay(baseNote: Note) {
|
||||||
|
val fileNote = baseNote.replyTo?.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val noteState by fileNote.live().metadata.observeAsState()
|
||||||
|
val note = noteState?.note
|
||||||
|
|
||||||
|
val eventBytes = (note?.event as? FileStorageEvent)
|
||||||
|
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
||||||
|
|
||||||
|
var content by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = eventHeader.id) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
if (bytes != null) {
|
||||||
|
ZoomableBytesVideo(bytes, mimeType, description, true)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content?.let {
|
||||||
|
ZoomableContentView(content = it, listOf(it))
|
||||||
|
} ?: BlankNote()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
||||||
Row(
|
Row(
|
||||||
|
Loading…
Reference in New Issue
Block a user