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 <mail@sanasol.ws>
This commit is contained in:
parent
4015513b96
commit
54438dceda
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue