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/Message.kt b/app/src/main/java/chat/stoat/composables/chat/Message.kt index c2b3cc3a..b070bd96 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/app/src/main/java/chat/stoat/composables/chat/MessageField.kt b/app/src/main/java/chat/stoat/composables/chat/MessageField.kt index b174b5a7..a44845dd 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 f5d4feb7..b6a8914c 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 @@ + + + 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,