Show image and QuotedAddressableNote.kt

This commit is contained in:
Kgothatso Ngako
2026-05-01 20:20:10 +02:00
parent 20dae4d147
commit dc625ff46a
5 changed files with 338 additions and 34 deletions

View File

@@ -0,0 +1,112 @@
package ac.cord.auxiliary.compose.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil3.Bitmap
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
object BlurHashDecoder {
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? {
if (blurHash == null || blurHash.length < 6) return null
val numComponents = decode83(blurHash, 0, 1)
val nx = (numComponents % 9) + 1
val ny = (numComponents / 9) + 1
if (blurHash.length != 4 + 2 * nx * ny) return null
val maxAc = (decode83(blurHash, 1, 2) + 1) / 166f
val colors = Array(nx * ny) { i ->
if (i == 0) {
val color83 = decode83(blurHash, 2, 6)
decodeDc(color83)
} else {
val color83 = decode83(blurHash, 4 + i * 2, 4 + i * 2 + 2)
decodeAc(color83, maxAc * punch)
}
}
return null
// val bitmap = Bitmap(width, height, Bitmap.Config.ARGB_8888)
// val pixels = IntArray(width * height)
//
// for (y in 0 until height) {
// for (x in 0 until width) {
// var r = 0f
// var g = 0f
// var b = 0f
//
// for (j in 0 until ny) {
// for (i in 0 until nx) {
// val basis = cos(PI * x * i / width) * cos(PI * y * j / height)
// val color = colors[j * nx + i]
// r += color[0] * basis.toFloat()
// g += color[1] * basis.toFloat()
// b += color[2] * basis.toFloat()
// }
// }
//
// val red = linearToSrgb(r)
// val green = linearToSrgb(g)
// val blue = linearToSrgb(b)
//
// pixels[y * width + x] = Color(red, green, blue).toArgb()
// }
// }
//
// bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
// return bitmap
}
private fun decode83(str: String, start: Int, end: Int): Int {
var res = 0
for (i in start until end) {
val c = str[i]
val digit = charMap[c] ?: 0
res = res * 83 + digit
}
return res
}
private fun decodeDc(color83: Int): FloatArray {
val r = (color83 shr 16) / 255f
val g = ((color83 shr 8) and 255) / 255f
val b = (color83 and 255) / 255f
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9f) * maxAc,
signedPow2((g - 9) / 9f) * maxAc,
signedPow2((b - 9) / 9f) * maxAc
)
}
private fun srgbToLinear(v: Float): Float {
val f = v / 255f
return if (f <= 0.04045f) f / 12.92f else ((f + 0.055f) / 1.055f).pow(2.4f)
}
private fun linearToSrgb(v: Float): Int {
val f = v.coerceIn(0f, 1f)
return if (f <= 0.0031308f) {
(f * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * f.pow(1f / 2.4f) - 0.055f) * 255f + 0.5f).toInt()
}
}
private fun signedPow2(v: Float) = v.pow(2f).withSign(v)
private val charMap = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
.mapIndexed { i, c -> c to i }
.toMap()
}

View File

@@ -0,0 +1,125 @@
package ac.cord.auxiliary.compose.ui.composable.widgets.content
import ac.cord.auxiliary.compose.ui.BlurHashDecoder
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import coil3.asImage
@kotlin.OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ImageWithContextMenu(meta: MediaMeta, onFullScreen: () -> Unit) {
val url = meta.url
val autoLoad = true
var loaded by remember { mutableStateOf(autoLoad) }
var showMenu by remember { mutableStateOf(false) }
// val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
if (!loaded) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { loaded = true },
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Image,
contentDescription = "Load image",
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text(
"Tap to load",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
return
}
val ratio = remember(meta.dimension) { parseAspectRatio(meta.dimension) }
Box {
LoadingAsyncImage(
model = url,
contentDescription = "Image",
contentScale = ContentScale.FillWidth,
onClick = onFullScreen,
onLongClick = { showMenu = true },
modifier = Modifier
.fillMaxWidth()
.let { if (ratio != null) it.aspectRatio(ratio) else it }
.clip(RoundedCornerShape(12.dp)),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Copy URL") },
onClick = {
clipboardManager.setText(AnnotatedString(url))
showMenu = false
}
)
DropdownMenuItem(
text = { Text("Download") },
onClick = {
showMenu = false
// scope.launch { MediaDownloader.downloadMedia(context, url) }
}
)
}
}
}
private fun parseAspectRatio(dim: String?): Float? {
if (dim == null) return null
val parts = dim.split('x')
if (parts.size != 2) return null
val w = parts[0].toFloatOrNull() ?: return null
val h = parts[1].toFloatOrNull() ?: return null
return if (h > 0) w / h else null
}

View File

@@ -0,0 +1,70 @@
package ac.cord.auxiliary.compose.ui.composable.widgets.content
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun LoadingAsyncImage(
model: Any?,
contentDescription: String?,
contentScale: ContentScale,
modifier: Modifier = Modifier,
blurPainter: BitmapPainter? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
var isLoading by remember { mutableStateOf(true) }
Box(modifier = modifier) {
AsyncImage(
model = model,
contentDescription = contentDescription,
contentScale = contentScale,
placeholder = blurPainter,
onLoading = { isLoading = true },
onSuccess = { isLoading = false },
onError = { isLoading = false },
modifier = Modifier
.fillMaxSize()
.then(
if (onClick != null || onLongClick != null) {
Modifier.combinedClickable(
onClick = onClick ?: {},
onLongClick = onLongClick
)
} else Modifier
)
)
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = if (blurPainter != null) Color.White else MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@@ -1,6 +1,7 @@
package ac.cord.auxiliary.compose.ui.composable.widgets.content
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalQuotedNostrEvent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -22,7 +23,7 @@ import androidx.compose.ui.unit.dp
@Composable
internal fun QuotedAddressableNote(
localNostrEvent: LocalNostrEvent? = null,
localQuotedNostrEvent: LocalQuotedNostrEvent?,
kind: Int,
dTag: String,
author: String,
@@ -34,15 +35,10 @@ internal fun QuotedAddressableNote(
) {
// TODO: Sync quoted event...
// LaunchedEffect(kind, author, dTag) {
// if (eventRepo.findAddressableEvent(kind, author, dTag) == null) {
// eventRepo.requestAddressableEvent(kind, author, dTag, relayHints)
// }
// }
if (localNostrEvent?.localQuotedNostrEvent != null) {
if (localQuotedNostrEvent != null) {
QuotedNote(
localQuotedNostrEvent = localNostrEvent.localQuotedNostrEvent,
localQuotedNostrEvent = localQuotedNostrEvent,
onNoteClick = onNoteClick,
noteActions = noteActions
)

View File

@@ -250,12 +250,12 @@ fun RichContent(
Logger.w("Doing the else: $group")
val segment = group as ContentSegment
when (segment) {
// is ContentSegment.ImageSegment -> {
// ImageWithContextMenu(
// meta = segment.meta,
// onFullScreen = { fullScreenImageUrl = segment.meta.url }
// )
// }
is ContentSegment.ImageSegment -> {
ImageWithContextMenu(
meta = segment.meta,
onFullScreen = { fullScreenImageUrl = segment.meta.url }
)
}
// is ContentSegment.VideoSegment -> {
// InlineVideoPlayerWithFullscreen(
// meta = segment.meta,
@@ -297,26 +297,27 @@ fun RichContent(
is ContentSegment.NostrAddressableSegment -> {
val kind = segment.kind
when {
// kind == 1 || kind == 0 -> {
// if (localNostrEvent.quotedPost != null && segment.author != null) {
// QuotedAddressableNote(
// kind = kind,
// dTag = segment.dTag,
// author = segment.author,
// relayHints = segment.relays,
// onNoteClick = onNoteClick,
// onProfileClick = onProfileClick,
// noteActions = noteActions,
// style = style
// )
// } else {
// Text(
// text = "nostr:${segment.dTag.take(12)}...",
// style = style,
// color = MaterialTheme.colorScheme.primary
// )
// }
// }
kind == 1 || kind == 0 -> {
if (localQuotedNostrEvent != null && segment.author != null) {
QuotedAddressableNote(
localQuotedNostrEvent = localQuotedNostrEvent,
kind = kind,
dTag = segment.dTag,
author = segment.author,
relayHints = segment.relays,
onNoteClick = onNoteClick,
onProfileClick = onProfileClick,
noteActions = noteActions,
style = style
)
} else {
Text(
text = "nostr:${segment.dTag.take(12)}...",
style = style,
color = MaterialTheme.colorScheme.primary
)
}
}
// kind == 30023 -> {
// if (segment.author != null) {
// // TODO: Add support for articles in localNostrEvents...