feat: add media tab to user profiles with video thumbnails and reply animations

Add a Media tab to user profile screens showing an Instagram-style grid
of images and videos extracted from the user's notes. Videos display
actual frame thumbnails via Coil's VideoFrameDecoder with a play icon
overlay. The grid supports infinite scroll.

Also adds ICQ-style flower burst animation for reply notifications,
restores the shared OkHttpClient for image loading (fixes GC thrashing
from per-request client creation), and separates reply vs generic
notification sounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Barry Deen
2026-03-03 22:05:35 -05:00
co-authored by Claude Opus 4.6
parent 0c5242e944
commit 9fd63c7181
12 changed files with 442 additions and 30 deletions
+1
View File
@@ -61,6 +61,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.video)
implementation(libs.security.crypto)
implementation(libs.bouncycastle)
implementation(libs.media3.exoplayer)
@@ -344,6 +344,15 @@ fun WispNavHost(
}
}
var isReplyAnimating by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
notificationsViewModel.replyReceived.collect {
isReplyAnimating = true
kotlinx.coroutines.delay(1000)
isReplyAnimating = false
}
}
val notifPrefs = remember { context.getSharedPreferences("wisp_settings", Context.MODE_PRIVATE) }
var notifSoundEnabled by rememberSaveable { mutableStateOf(notifPrefs.getBoolean("notif_sound_enabled", true)) }
val notifBlipSound = remember { NotifBlipSound(context) }
@@ -380,6 +389,7 @@ fun WispNavHost(
hasUnreadMessages = hasUnreadDms,
hasUnreadNotifications = hasUnreadNotifications,
isZapAnimating = isZapAnimating,
isReplyAnimating = isReplyAnimating,
onTabSelected = { tab ->
if (currentRoute == tab.route) {
scrollToTopTrigger++
+3 -4
View File
@@ -4,6 +4,7 @@ import android.app.Application
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.gif.AnimatedImageDecoder
import coil3.video.VideoFrameDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.wisp.app.relay.HttpClientFactory
@@ -19,14 +20,12 @@ class WispApp : Application(), SingletonImageLoader.Factory {
override fun newImageLoader(context: android.content.Context): ImageLoader {
val torAwareCallFactory = Call.Factory { request ->
HttpClientFactory.createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 30
).newCall(request)
HttpClientFactory.getImageClient().newCall(request)
}
return ImageLoader.Builder(context)
.components {
add(AnimatedImageDecoder.Factory())
add(VideoFrameDecoder.Factory())
add(OkHttpNetworkFetcherFactory(callFactory = { torAwareCallFactory }))
}
.crossfade(true)
@@ -54,6 +54,22 @@ object HttpClientFactory {
return builder.build()
}
private var imageClient: OkHttpClient? = null
private var imageClientBuiltWithTor: Boolean = false
fun getImageClient(): OkHttpClient {
val torNow = TorManager.isEnabled()
val client = imageClient
if (client != null && imageClientBuiltWithTor == torNow) return client
return createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 30
).also {
imageClient = it
imageClientBuiltWithTor = torNow
}
}
fun createHttpClient(
connectTimeoutSeconds: Long = 10,
readTimeoutSeconds: Long = 10,
@@ -51,6 +51,9 @@ class NotificationRepository(
private var lastReadTimestamp: Long = prefs.getLong(KEY_LAST_READ, 0L)
private val _replyReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val replyReceived: SharedFlow<Unit> = _replyReceived
private val _notifReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val notifReceived: SharedFlow<Unit> = _notifReceived
@@ -88,10 +91,15 @@ class NotificationRepository(
_hasUnread.value = true
}
if (event.created_at >= soundEligibleAfter && appIsActive) {
if (event.kind == 9735) {
_zapReceived.tryEmit(Unit)
} else {
_notifReceived.tryEmit(Unit)
when (event.kind) {
9735 -> _zapReceived.tryEmit(Unit)
1 -> {
// Replies get the ICQ flower effect; quotes/mentions get generic blip
val isReply = Nip10.getReplyTarget(event) != null
if (isReply) _replyReceived.tryEmit(Unit)
else _notifReceived.tryEmit(Unit)
}
else -> _notifReceived.tryEmit(Unit)
}
}
}
@@ -50,6 +50,7 @@ fun WispBottomBar(
hasUnreadMessages: Boolean,
hasUnreadNotifications: Boolean,
isZapAnimating: Boolean = false,
isReplyAnimating: Boolean = false,
onTabSelected: (BottomTab) -> Unit
) {
NavigationBar {
@@ -92,24 +93,29 @@ fun WispBottomBar(
)
}
if (tab == BottomTab.NOTIFICATIONS) {
val zeroFootprintModifier = Modifier
.size(120.dp)
.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
minWidth = 0,
minHeight = 0
)
)
layout(0, 0) {
placeable.place(
-placeable.width / 2,
-placeable.height / 2
)
}
}
ZapBurstEffect(
isActive = isZapAnimating,
modifier = Modifier
.size(120.dp)
.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
minWidth = 0,
minHeight = 0
)
)
layout(0, 0) {
placeable.place(
-placeable.width / 2,
-placeable.height / 2
)
}
}
modifier = zeroFootprintModifier
)
IcqFlowerBurstEffect(
isActive = isReplyAnimating,
modifier = zeroFootprintModifier
)
}
}
@@ -0,0 +1,238 @@
package com.wisp.app.ui.component
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.cos
import kotlin.math.sin
/**
* ICQ-style green flower burst that blooms outward from center when triggered.
* Each petal grows and rotates slightly, then fades out.
*/
@Composable
fun IcqFlowerBurstEffect(
isActive: Boolean,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val replySound = remember { ReplySound(context) }
DisposableEffect(Unit) {
onDispose { replySound.release() }
}
var petalCount by remember { mutableStateOf(8) }
val progress = remember { Animatable(0f) }
val rotation = remember { Animatable(0f) }
if (!isActive && progress.value <= 0f) return
LaunchedEffect(isActive) {
if (!isActive) return@LaunchedEffect
petalCount = (6..8).random()
replySound.play()
progress.snapTo(0f)
rotation.snapTo(0f)
coroutineScope {
launch { rotation.animateTo(30f, tween(900, easing = LinearEasing)) }
launch { progress.animateTo(1f, animationSpec = tween(900, easing = LinearEasing)) }
}
progress.snapTo(0f)
rotation.snapTo(0f)
}
Canvas(modifier = modifier.fillMaxSize()) {
val p = progress.value
val rot = rotation.value
// Scale up in first 50%, then hold and fade
val scale = (p / 0.5f).coerceAtMost(1f)
val alpha = if (p < 0.4f) 1f else 1f - ((p - 0.4f) / 0.6f)
if (alpha <= 0f) return@Canvas
val cx = size.width / 2f
val cy = size.height / 2f
drawIcqFlower(cx, cy, scale, alpha, rot, petalCount)
}
}
private fun DrawScope.drawIcqFlower(
cx: Float,
cy: Float,
scale: Float,
alpha: Float,
rotationDeg: Float,
petalCount: Int
) {
val petalLength = 24f * density * scale
val petalWidth = 10f * density * scale
val rotRad = Math.toRadians(rotationDeg.toDouble()).toFloat()
// Draw petals
for (i in 0 until petalCount) {
val baseAngle = (2f * Math.PI.toFloat() * i / petalCount) + rotRad
drawPetal(cx, cy, baseAngle, petalLength, petalWidth, alpha)
}
// Center circle
val centerRadius = 5f * density * scale
drawCircle(
color = Color(0xFF8BC34A).copy(alpha = alpha),
radius = centerRadius,
center = Offset(cx, cy)
)
drawCircle(
color = Color(0xFFCDDC39).copy(alpha = alpha * 0.8f),
radius = centerRadius * 0.6f,
center = Offset(cx, cy)
)
}
private fun DrawScope.drawPetal(
cx: Float,
cy: Float,
angle: Float,
length: Float,
width: Float,
alpha: Float
) {
val cosA = cos(angle)
val sinA = sin(angle)
val perpX = -sinA
val perpY = cosA
// Petal shape: oval-ish path from center outward
val tipX = cx + cosA * length
val tipY = cy + sinA * length
val midDist = length * 0.55f
val controlDist = length * 0.45f
val path = Path().apply {
moveTo(cx, cy)
// Left side curve
cubicTo(
cx + perpX * width * 0.5f + cosA * controlDist,
cy + perpY * width * 0.5f + sinA * controlDist,
cx + perpX * width * 0.3f + cosA * midDist,
cy + perpY * width * 0.3f + sinA * midDist,
tipX, tipY
)
// Right side curve back
cubicTo(
cx - perpX * width * 0.3f + cosA * midDist,
cy - perpY * width * 0.3f + sinA * midDist,
cx - perpX * width * 0.5f + cosA * controlDist,
cy - perpY * width * 0.5f + sinA * controlDist,
cx, cy
)
close()
}
// Outer glow
drawPath(
path = path,
color = Color(0xFF4CAF50).copy(alpha = alpha * 0.3f),
style = Fill
)
// Main petal fill
val innerPath = Path().apply {
val inset = 0.85f
val iLength = length * inset
val iWidth = width * inset
val iTipX = cx + cosA * iLength
val iTipY = cy + sinA * iLength
val iMidDist = iLength * 0.55f
val iControlDist = iLength * 0.45f
moveTo(cx, cy)
cubicTo(
cx + perpX * iWidth * 0.5f + cosA * iControlDist,
cy + perpY * iWidth * 0.5f + sinA * iControlDist,
cx + perpX * iWidth * 0.3f + cosA * iMidDist,
cy + perpY * iWidth * 0.3f + sinA * iMidDist,
iTipX, iTipY
)
cubicTo(
cx - perpX * iWidth * 0.3f + cosA * iMidDist,
cy - perpY * iWidth * 0.3f + sinA * iMidDist,
cx - perpX * iWidth * 0.5f + cosA * iControlDist,
cy - perpY * iWidth * 0.5f + sinA * iControlDist,
cx, cy
)
close()
}
drawPath(
path = innerPath,
color = Color(0xFF66BB6A).copy(alpha = alpha * 0.8f),
style = Fill
)
}
class ReplySound(context: Context) {
private var pool: SoundPool? = null
private var soundId: Int = 0
@Volatile private var loaded = false
init {
val resId = context.resources.getIdentifier("icq_reply", "raw", context.packageName)
if (resId != 0) {
try {
val p = SoundPool.Builder()
.setMaxStreams(2)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
.build()
p.setOnLoadCompleteListener { _, _, status ->
if (status == 0) loaded = true
}
soundId = p.load(context, resId, 1)
pool = p
} catch (_: Exception) { }
}
}
fun play() {
if (loaded) {
pool?.play(soundId, 0.4f, 0.4f, 1, 0, 1f)
}
}
fun release() {
pool?.release()
pool = null
}
}
@@ -102,7 +102,7 @@ data class NoteActions(
val onRelayClick: ((String) -> Unit)? = null,
)
private sealed interface ContentSegment {
internal sealed interface ContentSegment {
data class TextSegment(val text: String) : ContentSegment
data class ImageSegment(val url: String) : ContentSegment
data class VideoSegment(val url: String) : ContentSegment
@@ -138,7 +138,7 @@ private fun isStandaloneUrl(content: String, matchRange: IntRange): Boolean {
return true
}
private fun parseContent(content: String, emojiMap: Map<String, String> = emptyMap()): List<ContentSegment> {
internal fun parseContent(content: String, emojiMap: Map<String, String> = emptyMap()): List<ContentSegment> {
val segments = mutableListOf<ContentSegment>()
var lastEnd = 0
@@ -4,6 +4,7 @@ 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.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -29,6 +31,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ElectricBolt
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -39,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -54,6 +58,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -74,8 +79,10 @@ import com.wisp.app.repo.Nip05Status
import com.wisp.app.repo.RelayInfoRepository
import com.wisp.app.repo.TranslationRepository
import com.wisp.app.ui.component.FollowButton
import com.wisp.app.ui.component.ContentSegment
import com.wisp.app.ui.component.FullScreenImageViewer
import com.wisp.app.ui.component.PostCard
import com.wisp.app.ui.component.parseContent
import com.wisp.app.ui.component.QrCodeDialog
import com.wisp.app.ui.component.ProfilePicture
import com.wisp.app.ui.component.RichContent
@@ -219,7 +226,23 @@ fun UserProfileScreen(
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var blockedContentRevealed by remember { mutableStateOf(false) }
val tabTitles = listOf("Notes", "Replies", "Following", "Relays")
var fullScreenMediaImageUrl by remember { mutableStateOf<String?>(null) }
var fullScreenMediaVideoUrl by remember { mutableStateOf<String?>(null) }
val tabTitles = listOf("Notes", "Replies", "Media", "Following", "Relays")
if (fullScreenMediaImageUrl != null) {
FullScreenImageViewer(
imageUrl = fullScreenMediaImageUrl!!,
onDismiss = { fullScreenMediaImageUrl = null }
)
}
if (fullScreenMediaVideoUrl != null) {
com.wisp.app.ui.component.FullScreenVideoPlayer(
videoUrl = fullScreenMediaVideoUrl!!,
onDismiss = { fullScreenMediaVideoUrl = null }
)
}
Scaffold(
topBar = {
@@ -303,7 +326,42 @@ fun UserProfileScreen(
)
}
) { padding ->
val mediaItems = remember(rootNotes.size, replies.size, selectedTab) {
if (selectedTab != 2) emptyList()
else (rootNotes + replies)
.sortedByDescending { it.created_at }
.flatMap { event ->
parseContent(event.content).mapNotNull { segment ->
when (segment) {
is ContentSegment.ImageSegment -> MediaItem(segment.url, MediaType.IMAGE)
is ContentSegment.VideoSegment -> MediaItem(segment.url, MediaType.VIDEO)
else -> null
}
}
}
.distinctBy { it.url }
}
val listState = rememberLazyListState()
// Auto-load more media when scrolling near the bottom of the grid
if (selectedTab == 2 && mediaItems.isNotEmpty()) {
LaunchedEffect(listState) {
snapshotFlow {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible to totalItems
}.collect { (lastVisible, totalItems) ->
if (totalItems > 0 && lastVisible >= totalItems - 3) {
viewModel.loadMoreNotes()
viewModel.loadMoreReplies()
}
}
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(padding)
@@ -330,9 +388,10 @@ fun UserProfileScreen(
}
stickyHeader {
TabRow(
ScrollableTabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface
containerColor = MaterialTheme.colorScheme.surface,
edgePadding = 16.dp
) {
tabTitles.forEachIndexed { index, title ->
Tab(
@@ -541,6 +600,19 @@ fun UserProfileScreen(
}
}
2 -> {
if (mediaItems.isEmpty()) {
item { EmptyTabContent("No media yet") }
} else {
items(items = mediaItems.chunked(3), key = { row -> row.first().url }) { row ->
MediaGridRow(
items = row,
onImageClick = { url -> fullScreenMediaImageUrl = url },
onVideoClick = { url -> fullScreenMediaVideoUrl = url }
)
}
}
}
3 -> {
if (followList.isEmpty()) {
item { EmptyTabContent("Not following anyone") }
} else {
@@ -559,7 +631,7 @@ fun UserProfileScreen(
}
}
}
3 -> {
4 -> {
if (relayList.isEmpty() && relayHints.isEmpty()) {
item { EmptyTabContent("No relay list published") }
} else {
@@ -1134,6 +1206,64 @@ private fun EmptyTabContent(message: String) {
}
}
private enum class MediaType { IMAGE, VIDEO }
private data class MediaItem(val url: String, val type: MediaType)
@Composable
private fun MediaGridRow(
items: List<MediaItem>,
onImageClick: (String) -> Unit,
onVideoClick: (String) -> Unit
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 2.dp)
) {
items.forEach { item ->
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clickable {
when (item.type) {
MediaType.IMAGE -> onImageClick(item.url)
MediaType.VIDEO -> onVideoClick(item.url)
}
}
) {
AsyncImage(
model = item.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
if (item.type == MediaType.VIDEO) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Play video",
tint = Color.White,
modifier = Modifier.size(36.dp)
)
}
}
}
}
// Fill remaining cells in incomplete rows
repeat(3 - items.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun BlockedContentOverlay(onReveal: () -> Unit) {
Box(
@@ -37,6 +37,9 @@ class NotificationsViewModel(app: Application) : AndroidViewModel(app) {
val zapReceived: SharedFlow<Unit>
get() = notifRepo?.zapReceived ?: MutableSharedFlow()
val replyReceived: SharedFlow<Unit>
get() = notifRepo?.replyReceived ?: MutableSharedFlow()
val notifReceived: SharedFlow<Unit>
get() = notifRepo?.notifReceived ?: MutableSharedFlow()
Binary file not shown.
+1
View File
@@ -40,6 +40,7 @@ secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-km
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }