Merge c1b275367b into e9b16f2a28
This commit is contained in:
commit
d452ec1bf3
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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" -> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue