From 54438dcedaac9a63fa163d65e04b178f96ba10ab Mon Sep 17 00:00:00 2001 From: sanasol Date: Wed, 4 Mar 2026 21:56:04 +0100 Subject: [PATCH 1/2] feat: add GIF picker with gifbox API integration Adds a GIF picker panel alongside the existing emoji picker in the message composition area. Uses the gifbox microservice API for search, categories, and trending GIFs. Components: - Gifbox API client (search, categories, trending endpoints) - GifPicker composable (search bar, category grid, results grid) - GIF button in MessageField - GifPicker pane in ChannelScreen Signed-off-by: sanasol --- .../api/routes/microservices/gifbox/Gifbox.kt | 66 +++++ .../stoat/composables/chat/MessageField.kt | 16 ++ .../chat/stoat/composables/gif/GifPicker.kt | 245 ++++++++++++++++++ .../chat/views/channel/ChannelScreen.kt | 76 ++++++ app/src/main/res/drawable/ic_gif_24dp.xml | 9 + 5 files changed, 412 insertions(+) create mode 100644 app/src/main/java/chat/stoat/api/routes/microservices/gifbox/Gifbox.kt create mode 100644 app/src/main/java/chat/stoat/composables/gif/GifPicker.kt create mode 100644 app/src/main/res/drawable/ic_gif_24dp.xml diff --git a/app/src/main/java/chat/stoat/api/routes/microservices/gifbox/Gifbox.kt b/app/src/main/java/chat/stoat/api/routes/microservices/gifbox/Gifbox.kt new file mode 100644 index 00000000..b18374af --- /dev/null +++ b/app/src/main/java/chat/stoat/api/routes/microservices/gifbox/Gifbox.kt @@ -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 = emptyMap() +) + +@Serializable +data class GifSearchResponse( + val results: List = emptyList(), + val next: String? = null +) + +object Gifbox { + suspend fun fetchCategories(): List { + 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()) + } +} diff --git a/app/src/main/java/chat/stoat/composables/chat/MessageField.kt b/app/src/main/java/chat/stoat/composables/chat/MessageField.kt index a223b0c9..daed818c 100644 --- a/app/src/main/java/chat/stoat/composables/chat/MessageField.kt +++ b/app/src/main/java/chat/stoat/composables/chat/MessageField.kt @@ -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( diff --git a/app/src/main/java/chat/stoat/composables/gif/GifPicker.kt b/app/src/main/java/chat/stoat/composables/gif/GifPicker.kt new file mode 100644 index 00000000..6eefe318 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/gif/GifPicker.kt @@ -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>(emptyList()) } + var results by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var activeQuery by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + var searchJob by remember { mutableStateOf(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, + 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, + 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() + ) + } + } + } +} diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index f413c0ab..0988b82c 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -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() diff --git a/app/src/main/res/drawable/ic_gif_24dp.xml b/app/src/main/res/drawable/ic_gif_24dp.xml new file mode 100644 index 00000000..df61d8c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_24dp.xml @@ -0,0 +1,9 @@ + + + From c1b275367b224d4ff04803cf2a0024415fa00e2c Mon Sep 17 00:00:00 2001 From: sanasol Date: Wed, 4 Mar 2026 21:59:27 +0100 Subject: [PATCH 2/2] feat: add inline GIF rendering and Video field on Embed Adds giphy.com URL detection in message embeds to render GIFs inline instead of as generic website embeds. Also adds the missing Video field to the Embed model for proper GIF dimension extraction. Signed-off-by: sanasol --- .../chat/stoat/composables/chat/Message.kt | 80 +++++++++++++++---- .../chat/stoat/core/model/schemas/Messages.kt | 8 ++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/chat/stoat/composables/chat/Message.kt b/app/src/main/java/chat/stoat/composables/chat/Message.kt index ee93be87..21a14c7a 100644 --- a/app/src/main/java/chat/stoat/composables/chat/Message.kt +++ b/app/src/main/java/chat/stoat/composables/chat/Message.kt @@ -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" -> { diff --git a/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt b/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt index 22b5d73a..04387808 100644 --- a/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt +++ b/core/model/src/main/java/chat/stoat/core/model/schemas/Messages.kt @@ -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,