From 54438dcedaac9a63fa163d65e04b178f96ba10ab Mon Sep 17 00:00:00 2001 From: sanasol Date: Wed, 4 Mar 2026 21:56:04 +0100 Subject: [PATCH] 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 @@ + + +