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:
co-authored by
Claude Opus 4.6
parent
0c5242e944
commit
9fd63c7181
@@ -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++
|
||||
|
||||
@@ -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.
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user