feat(media): add playback controls and autoplay support for GIFs
Introduces `GifVideoView` to manage GIF playback with manual play/pause controls and support for the "Auto-play Videos" setting. Updates `MyAsyncImage` and `ZoomableContentView` to utilize this specialized view when GIF content is detected via file extension or MIME type. Key changes include: * **GifVideoView**: A new component that uses Coil's `Animatable` to start and stop GIF animations, featuring a video-like UI with play/pause overlays and a zoom button. * **GIF Detection**: Added `isGif()` and `isGifUrl()` helpers to identify GIF content by MIME type or URL patterns. * **Integration**: Redirects GIF rendering in `MyAsyncImage` and `ZoomableContentView` from static image views to the new interactive `GifVideoView`.
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.outlined.OpenInFull
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import coil3.asDrawable
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import coil3.compose.SubcomposeAsyncImage
|
||||||
|
import coil3.compose.SubcomposeAsyncImageContent
|
||||||
|
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||||
|
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GifVideoView(
|
||||||
|
videoUri: String,
|
||||||
|
contentDescription: String?,
|
||||||
|
dimensions: DimensionTag?,
|
||||||
|
blurhash: String?,
|
||||||
|
roundedCorner: Boolean,
|
||||||
|
contentScale: ContentScale,
|
||||||
|
onDialog: (() -> Unit)? = null,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
thumbhash: String? = null,
|
||||||
|
) {
|
||||||
|
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
|
||||||
|
val automaticallyStartPlayback = remember { mutableStateOf(accountViewModel.settings.autoPlayVideos()) }
|
||||||
|
val controllerVisible = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(controllerVisible.value, automaticallyStartPlayback.value) {
|
||||||
|
if (controllerVisible.value && automaticallyStartPlayback.value) {
|
||||||
|
delay(3000)
|
||||||
|
controllerVisible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
(if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { controllerVisible.value = !controllerVisible.value },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = videoUri,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = contentScale,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
val state by painter.state.collectAsState()
|
||||||
|
|
||||||
|
val successState = state as? AsyncImagePainter.State.Success
|
||||||
|
val drawable = successState?.result?.image?.asDrawable(context.resources)
|
||||||
|
|
||||||
|
LaunchedEffect(automaticallyStartPlayback.value, drawable) {
|
||||||
|
if (drawable is Animatable) {
|
||||||
|
if (automaticallyStartPlayback.value) {
|
||||||
|
drawable.start()
|
||||||
|
} else {
|
||||||
|
drawable.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
is AsyncImagePainter.State.Loading -> {
|
||||||
|
DisplayBlurHash(
|
||||||
|
blurhash,
|
||||||
|
contentDescription,
|
||||||
|
contentScale,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
thumbhash = thumbhash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AsyncImagePainter.State.Success -> {
|
||||||
|
SubcomposeAsyncImageContent(Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
is AsyncImagePainter.State.Error -> {
|
||||||
|
ClickableUrl(urlText = videoUri, url = videoUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom button
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerVisible.value,
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onDialog?.invoke() },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.OpenInFull,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play/Pause button
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerVisible.value || !automaticallyStartPlayback.value,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(if (!automaticallyStartPlayback.value) Color.Black.copy(alpha = 0.3f) else Color.Transparent),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.size(Size75dp),
|
||||||
|
onClick = { automaticallyStartPlayback.value = !automaticallyStartPlayback.value },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (automaticallyStartPlayback.value) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(Size75dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,86 +56,105 @@ fun MyAsyncImage(
|
|||||||
onLoadingBackground: (@Composable () -> Unit)?,
|
onLoadingBackground: (@Composable () -> Unit)?,
|
||||||
onError: (@Composable () -> Unit)?,
|
onError: (@Composable () -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val ratio = MediaAspectRatioCache.get(imageUrl)
|
if (isGifUrl(imageUrl)) {
|
||||||
val showImage = remember { mutableStateOf(accountViewModel.settings.showImages()) }
|
GifVideoView(
|
||||||
|
videoUri = imageUrl,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
dimensions = null,
|
||||||
|
blurhash = null,
|
||||||
|
roundedCorner = contentScale == ContentScale.Crop,
|
||||||
|
contentScale = contentScale,
|
||||||
|
onDialog = null,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
thumbhash = null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val ratio = MediaAspectRatioCache.get(imageUrl)
|
||||||
|
val showImage = remember { mutableStateOf(accountViewModel.settings.showImages()) }
|
||||||
|
|
||||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||||
if (it) {
|
if (it) {
|
||||||
SubcomposeAsyncImage(
|
SubcomposeAsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
modifier = mainImageModifier,
|
modifier = mainImageModifier,
|
||||||
) {
|
) {
|
||||||
val state by painter.state.collectAsState()
|
val state by painter.state.collectAsState()
|
||||||
when (state) {
|
when (state) {
|
||||||
is AsyncImagePainter.State.Loading -> {
|
is AsyncImagePainter.State.Loading -> {
|
||||||
if (ratio != null) {
|
|
||||||
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
|
|
||||||
LoadingAnimation(Size40dp, Size6dp)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
WaitAndDisplay {
|
|
||||||
if (onLoadingBackground != null) {
|
|
||||||
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
|
|
||||||
onLoadingBackground()
|
|
||||||
LoadingAnimation(Size40dp, Size6dp)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is AsyncImagePainter.State.Error -> {
|
|
||||||
if (onError != null) {
|
|
||||||
if (ratio != null) {
|
if (ratio != null) {
|
||||||
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
|
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
|
||||||
onError()
|
LoadingAnimation(Size40dp, Size6dp)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
|
WaitAndDisplay {
|
||||||
onError()
|
if (onLoadingBackground != null) {
|
||||||
|
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
|
||||||
|
onLoadingBackground()
|
||||||
|
LoadingAnimation(Size40dp, Size6dp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ClickableUrl(urlText = imageUrl, url = imageUrl)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
is AsyncImagePainter.State.Success -> {
|
is AsyncImagePainter.State.Error -> {
|
||||||
SubcomposeAsyncImageContent(loadedImageModifier)
|
if (onError != null) {
|
||||||
|
if (ratio != null) {
|
||||||
SideEffect {
|
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
|
||||||
val drawable = (state as AsyncImagePainter.State.Success).result.image
|
onError()
|
||||||
MediaAspectRatioCache.add(imageUrl, drawable.width, drawable.height)
|
}
|
||||||
|
} else {
|
||||||
|
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ClickableUrl(urlText = imageUrl, url = imageUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
is AsyncImagePainter.State.Success -> {
|
||||||
}
|
SubcomposeAsyncImageContent(loadedImageModifier)
|
||||||
}
|
|
||||||
} else {
|
SideEffect {
|
||||||
if (ratio != null) {
|
val drawable = (state as AsyncImagePainter.State.Success).result.image
|
||||||
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
|
MediaAspectRatioCache.add(imageUrl, drawable.width, drawable.height)
|
||||||
IconButton(
|
}
|
||||||
modifier = Modifier.size(Size75dp),
|
}
|
||||||
onClick = { showImage.value = true },
|
|
||||||
) {
|
else -> {}
|
||||||
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(loadedImageModifier.aspectRatio(16 / 9.0f), contentAlignment = Alignment.Center) {
|
if (ratio != null) {
|
||||||
IconButton(
|
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.size(Size75dp),
|
IconButton(
|
||||||
onClick = { showImage.value = true },
|
modifier = Modifier.size(Size75dp),
|
||||||
) {
|
onClick = { showImage.value = true },
|
||||||
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
|
) {
|
||||||
|
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(loadedImageModifier.aspectRatio(16 / 9.0f), contentAlignment = Alignment.Center) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.size(Size75dp),
|
||||||
|
onClick = { showImage.value = true },
|
||||||
|
) {
|
||||||
|
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isGifUrl(url: String): Boolean =
|
||||||
|
url.endsWith(".gif", ignoreCase = true) ||
|
||||||
|
url.contains(".gif?", ignoreCase = true) ||
|
||||||
|
url.contains(".gif#", ignoreCase = true)
|
||||||
|
|||||||
+55
-16
@@ -159,14 +159,28 @@ fun ZoomableContentView(
|
|||||||
modifier = mediaSizingModifier(ratio, contentScale),
|
modifier = mediaSizingModifier(ratio, contentScale),
|
||||||
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
|
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
|
||||||
) {
|
) {
|
||||||
TwoSecondController(content) { controllerVisible ->
|
if (content.isGif()) {
|
||||||
val mainImageModifier =
|
GifVideoView(
|
||||||
Modifier
|
videoUri = content.url,
|
||||||
.fillMaxWidth()
|
contentDescription = content.description,
|
||||||
.then(boundsTrackingModifier)
|
dimensions = content.dim,
|
||||||
.clickable { dialogOpen = true }
|
blurhash = content.blurhash,
|
||||||
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
roundedCorner = roundedCorner,
|
||||||
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
|
contentScale = contentScale,
|
||||||
|
onDialog = { dialogOpen = true },
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
thumbhash = content.thumbhash,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TwoSecondController(content) { controllerVisible ->
|
||||||
|
val mainImageModifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(boundsTrackingModifier)
|
||||||
|
.clickable { dialogOpen = true }
|
||||||
|
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
||||||
|
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,15 +220,31 @@ fun ZoomableContentView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is MediaLocalImage -> {
|
is MediaLocalImage -> {
|
||||||
TwoSecondController(content) { controllerVisible ->
|
if (content.isGif()) {
|
||||||
val mainImageModifier =
|
content.localFile?.let {
|
||||||
Modifier
|
GifVideoView(
|
||||||
.fillMaxWidth()
|
videoUri = it.toUri().toString(),
|
||||||
.then(boundsTrackingModifier)
|
contentDescription = content.description,
|
||||||
.clickable { dialogOpen = true }
|
dimensions = content.dim,
|
||||||
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
blurhash = content.blurhash,
|
||||||
|
roundedCorner = roundedCorner,
|
||||||
|
contentScale = contentScale,
|
||||||
|
onDialog = { dialogOpen = true },
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
thumbhash = content.thumbhash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TwoSecondController(content) { controllerVisible ->
|
||||||
|
val mainImageModifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(boundsTrackingModifier)
|
||||||
|
.clickable { dialogOpen = true }
|
||||||
|
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
||||||
|
|
||||||
LocalImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
|
LocalImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,6 +677,15 @@ fun ShowHash(content: MediaUrlContent) {
|
|||||||
verifiedHash?.let { HashVerificationSymbol(it) }
|
verifiedHash?.let { HashVerificationSymbol(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun BaseMediaContent.isGif(): Boolean =
|
||||||
|
if (this is MediaUrlContent) {
|
||||||
|
mimeType == "image/gif" || url.endsWith(".gif", ignoreCase = true) || url.contains(".gif?", ignoreCase = true) || url.contains(".gif#", ignoreCase = true)
|
||||||
|
} else if (this is MediaPreloadedContent) {
|
||||||
|
mimeType == "image/gif" || localFile?.name?.endsWith(".gif", ignoreCase = true) == true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
|
fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
|
||||||
val visible = remember { mutableStateOf(false) }
|
val visible = remember { mutableStateOf(false) }
|
||||||
|
|||||||
Reference in New Issue
Block a user