This commit is contained in:
Aleksandr Aksentev 2026-05-30 00:33:12 +02:00 committed by GitHub
commit d452ec1bf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 486 additions and 14 deletions

View File

@ -0,0 +1,66 @@
package chat.stoat.api.routes.microservices.gifbox
import chat.stoat.api.StoatHttp
import chat.stoat.api.StoatAPI
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
private const val GIFBOX_BASE = "https://gifbox.stoat.chat"
private val gifboxJson = Json { ignoreUnknownKeys = true }
@Serializable
data class GifCategory(
val title: String,
val image: String
)
@Serializable
data class GifMediaFormat(
val url: String
)
@Serializable
data class GifResult(
val url: String,
@SerialName("media_formats")
val mediaFormats: Map<String, GifMediaFormat> = emptyMap()
)
@Serializable
data class GifSearchResponse(
val results: List<GifResult> = emptyList(),
val next: String? = null
)
object Gifbox {
suspend fun fetchCategories(): List<GifCategory> {
val response = StoatHttp.get("$GIFBOX_BASE/categories") {
parameter("locale", "en_US")
header(StoatAPI.TOKEN_HEADER_NAME, StoatAPI.sessionToken)
}
return gifboxJson.decodeFromString(response.bodyAsText())
}
suspend fun fetchTrending(limit: Int = 50): GifSearchResponse {
val response = StoatHttp.get("$GIFBOX_BASE/trending") {
parameter("locale", "en_US")
parameter("limit", limit.toString())
header(StoatAPI.TOKEN_HEADER_NAME, StoatAPI.sessionToken)
}
return gifboxJson.decodeFromString(response.bodyAsText())
}
suspend fun search(query: String, limit: Int = 50): GifSearchResponse {
val response = StoatHttp.get("$GIFBOX_BASE/search") {
parameter("locale", "en_US")
parameter("query", query)
parameter("limit", limit.toString())
header(StoatAPI.TOKEN_HEADER_NAME, StoatAPI.sessionToken)
}
return gifboxJson.decodeFromString(response.bodyAsText())
}
}

View File

@ -438,9 +438,17 @@ fun Message(
}
}
val isOnlyGIF = message.content != null &&
message.embeds?.size == 1 &&
message.embeds!![0].type == "Website" &&
(message.embeds!![0].special?.type == "GIF" ||
message.embeds!![0].originalURL?.startsWith("https://giphy.com") == true) &&
message.content!!.trim().let { c -> c.startsWith("http://") || c.startsWith("https://") } &&
!message.content!!.trim().contains(' ')
key(message.content) {
message.content?.let {
if (message.content!!.isBlank()) return@let // if only an attachment is sent
if (message.content!!.isBlank() || isOnlyGIF) return@let // if only an attachment or GIF is sent
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
CompositionLocalProvider(
@ -520,21 +528,65 @@ fun Message(
it.forEach { embed ->
when (embed.type) {
"Website", "Text" -> {
val embedIsEmpty =
embed.title == null && embed.description == null && embed.iconURL == null && embed.image == null
val isGIF = embed.type == "Website" && (
embed.special?.type == "GIF" ||
embed.originalURL?.startsWith("https://giphy.com") == true
)
if (embedIsEmpty) {
// if we do not emit anything, compose will cause an internal error.
// FIXME if you are doing fixme's anyways then check if this is still an issue
Box {}
return@forEach
// Prefer image URL; for video (mp4), derive GIF URL from giphy page URL
val gifUrl = if (isGIF) {
embed.image?.url ?: run {
val pageUrl = embed.url ?: embed.originalURL
val giphyId = pageUrl?.substringAfterLast("/")?.substringAfterLast("-")
if (giphyId != null) "https://media.giphy.com/media/$giphyId/giphy.gif" else embed.video?.url
}
} else null
val gifW = if (isGIF) (embed.image?.width ?: embed.video?.width) else null
val gifH = if (isGIF) (embed.image?.height ?: embed.video?.height) else null
if (gifUrl != null) {
Spacer(modifier = Modifier.height(2.dp))
BoxWithConstraints(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.clickable {
embed.url?.let {
viewUrlInBrowser(context, it)
}
}
) {
val imgWidth = gifW?.toInt()?.dp ?: maxWidth
val aspectRatio = if (gifW != null && gifH != null && gifH > 0) {
gifW.toFloat() / gifH.toFloat()
} else null
RemoteImage(
url = asJanuaryProxyUrl(gifUrl),
contentScale = ContentScale.Fit,
modifier = Modifier
.width(imgWidth)
.then(
if (aspectRatio != null) Modifier.aspectRatio(aspectRatio)
else Modifier
),
description = null
)
}
Spacer(modifier = Modifier.height(2.dp))
} else {
val embedIsEmpty =
embed.title == null && embed.description == null && embed.iconURL == null && embed.image == null
if (embedIsEmpty) {
Box {}
return@forEach
}
Spacer(modifier = Modifier.height(8.dp))
Embed(embed = embed, onLinkClick = {
viewUrlInBrowser(context, it)
})
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Embed(embed = embed, onLinkClick = {
viewUrlInBrowser(context, it)
})
Spacer(modifier = Modifier.height(8.dp))
}
"Image" -> {

View File

@ -147,6 +147,7 @@ fun MessageField(
onAddAttachment: () -> Unit,
onCommitAttachment: (Uri) -> Unit,
onPickEmoji: () -> Unit,
onPickGif: () -> Unit = {},
onSendMessage: () -> Unit,
channelType: ChannelType,
channelName: String,
@ -596,6 +597,21 @@ fun MessageField(
.testTag("pick_emoji")
)
Icon(
painter = painterResource(R.drawable.ic_gif_24dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
contentDescription = "Pick GIF",
modifier = Modifier
.clip(CircleShape)
.size(32.dp)
.clickable {
focusManager.clearFocus()
onPickGif()
}
.padding(4.dp)
.testTag("pick_gif")
)
Spacer(modifier = Modifier.width(8.dp))
AnimatedVisibility(

View File

@ -0,0 +1,245 @@
package chat.stoat.composables.gif
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.stoat.api.routes.microservices.gifbox.GifCategory
import chat.stoat.api.routes.microservices.gifbox.GifResult
import chat.stoat.api.routes.microservices.gifbox.Gifbox
import chat.stoat.composables.generic.RemoteImage
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun GifPicker(
onGifSelected: (String) -> Unit,
onSearchFocus: (Boolean) -> Unit = {},
bottomInset: Dp = 0.dp
) {
var searchQuery by remember { mutableStateOf("") }
var categories by remember { mutableStateOf<List<GifCategory>>(emptyList()) }
var results by remember { mutableStateOf<List<GifResult>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var activeQuery by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
var searchJob by remember { mutableStateOf<Job?>(null) }
// Load categories on mount
LaunchedEffect(Unit) {
try {
categories = Gifbox.fetchCategories()
} catch (_: Exception) { }
isLoading = false
}
// Debounced search
fun doSearch(query: String) {
searchJob?.cancel()
if (query.isBlank()) {
activeQuery = null
results = emptyList()
return
}
searchJob = scope.launch {
delay(400)
activeQuery = query
isLoading = true
try {
results = Gifbox.search(query).results
} catch (_: Exception) {
results = emptyList()
}
isLoading = false
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
// Search bar
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh
) {
val textFieldState = rememberTextFieldState()
BasicTextField(
state = textFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.onFocusChanged { onSearchFocus(it.isFocused) },
textStyle = TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
decorator = { innerTextField ->
if (textFieldState.text.isEmpty()) {
Text(
"Search GIFs...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
}
innerTextField()
},
onTextLayout = { },
)
// Sync text field state with search query
LaunchedEffect(textFieldState.text) {
val newText = textFieldState.text.toString()
if (newText != searchQuery) {
searchQuery = newText
doSearch(newText)
}
}
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
}
} else if (activeQuery != null) {
// Search results
GifGrid(
gifs = results,
onGifSelected = onGifSelected,
modifier = Modifier.weight(1f),
bottomInset = bottomInset
)
} else {
// Category grid
CategoryGrid(
categories = categories,
onCategorySelected = { category ->
searchQuery = category.title
doSearch(category.title)
},
modifier = Modifier.weight(1f),
bottomInset = bottomInset
)
}
}
}
@Composable
private fun CategoryGrid(
categories: List<GifCategory>,
onCategorySelected: (GifCategory) -> Unit,
modifier: Modifier = Modifier,
bottomInset: Dp = 0.dp
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp + bottomInset),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
items(categories, key = { it.title }) { category ->
Box(
modifier = Modifier
.aspectRatio(5f / 3f)
.clip(RoundedCornerShape(8.dp))
.clickable { onCategorySelected(category) }
) {
RemoteImage(
url = category.image,
description = category.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Surface(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)
) {
Text(
text = category.title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}
@Composable
private fun GifGrid(
gifs: List<GifResult>,
onGifSelected: (String) -> Unit,
modifier: Modifier = Modifier,
bottomInset: Dp = 0.dp
) {
if (gifs.isEmpty()) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
"No GIFs found",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
}
return
}
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp + bottomInset),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
items(gifs, key = { it.url }) { gif ->
val previewUrl = gif.mediaFormats["tinywebm"]?.url
?: gif.mediaFormats["webm"]?.url
?: gif.url
Box(
modifier = Modifier
.aspectRatio(5f / 3f)
.clip(RoundedCornerShape(8.dp))
.clickable { onGifSelected(gif.url) }
) {
RemoteImage(
url = previewUrl,
description = "GIF",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
}
}

View File

@ -171,6 +171,7 @@ sealed class ChannelScreenItem {
sealed class ChannelScreenActivePane {
data object None : ChannelScreenActivePane()
data object EmojiPicker : ChannelScreenActivePane()
data object GifPicker : ChannelScreenActivePane()
data object AttachmentPicker : ChannelScreenActivePane()
}
@ -1069,6 +1070,15 @@ fun ChannelScreen(
ChannelScreenActivePane.EmojiPicker
}
},
onPickGif = {
if (viewModel.activePane == ChannelScreenActivePane.GifPicker) {
viewModel.activePane =
ChannelScreenActivePane.None
} else {
viewModel.activePane =
ChannelScreenActivePane.GifPicker
}
},
onSendMessage = viewModel::sendPendingMessage,
channelType = viewModel.channel?.channelType
?: ChannelType.TextChannel,
@ -1222,6 +1232,39 @@ fun ChannelScreen(
}
}
ChannelScreenActivePane.GifPicker -> {
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.GifPicker) {
viewModel.activePane =
ChannelScreenActivePane.None
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(4.dp)
.navigationBarsPadding()
) {
chat.stoat.composables.gif.GifPicker(
onGifSelected = { gifUrl ->
viewModel.putDraftContent(gifUrl, true)
viewModel.sendPendingMessage()
viewModel.activePane =
ChannelScreenActivePane.None
},
onSearchFocus = {
emojiSearchFocused = it
},
bottomInset = pxAsDp(
max(
imeCurrentInset - navigationBarsInset,
0
)
)
)
}
}
ChannelScreenActivePane.AttachmentPicker -> {
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
viewModel.activePane =
@ -1283,6 +1326,39 @@ fun ChannelScreen(
)
}
}
if (viewModel.activePane == ChannelScreenActivePane.GifPicker) {
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.GifPicker) {
viewModel.activePane =
ChannelScreenActivePane.None
}
Column(
modifier = Modifier
.fillMaxWidth()
.height(600.dp)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(4.dp)
.navigationBarsPadding()
) {
chat.stoat.composables.gif.GifPicker(
onGifSelected = { gifUrl ->
viewModel.putDraftContent(gifUrl, true)
viewModel.sendPendingMessage()
viewModel.activePane =
ChannelScreenActivePane.None
},
onSearchFocus = {
emojiSearchFocused = it
},
bottomInset = pxAsDp(
max(
imeCurrentInset - navigationBarsInset,
0
)
)
)
}
}
Box(
Modifier
.imePadding()

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M11.5,9H13v6h-1.5zM9,9H6c-0.6,0 -1,0.5 -1,1v4c0,0.5 0.4,1 1,1h3c0.6,0 1,-0.5 1,-1v-2H8.5v1H6.5v-3h3.5V10C10,9.5 9.6,9 9,9zM19,10.5V9h-4.5v6H16v-2h2v-1.5h-2v-1z"/>
</vector>

View File

@ -62,6 +62,7 @@ data class Embed(
val title: String? = null,
val description: String? = null,
val image: Image? = null,
val video: Video? = null,
@SerialName("icon_url")
val iconURL: String? = null,
@ -83,6 +84,13 @@ data class Image(
val size: String? = null
)
@Serializable
data class Video(
val url: String? = null,
val width: Long? = null,
val height: Long? = null
)
@Serializable
data class Special(
val type: String? = null,