fix(android): send gifs/stickers as image and add giphy search
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
@@ -9,6 +11,15 @@ plugins {
|
||||
id("com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
val file = rootProject.file("local.properties")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun String.escapeForBuildConfig(): String = replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
|
||||
android {
|
||||
namespace = "ru.daemonlord.messenger"
|
||||
compileSdk = 35
|
||||
@@ -21,6 +32,12 @@ android {
|
||||
versionName = "0.1.0"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
|
||||
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
|
||||
val giphyApiKey = (
|
||||
localProperties.getProperty("GIPHY_API_KEY")
|
||||
?: System.getenv("GIPHY_API_KEY")
|
||||
?: ""
|
||||
).trim()
|
||||
buildConfigField("String", "GIPHY_API_KEY", "\"${giphyApiKey.escapeForBuildConfig()}\"")
|
||||
buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true")
|
||||
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
|
||||
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
|
||||
|
||||
@@ -493,8 +493,8 @@ class NetworkMessageRepository @Inject constructor(
|
||||
val normalizedMime = mimeType.lowercase()
|
||||
val normalizedName = fileName.lowercase()
|
||||
return when {
|
||||
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "gif"
|
||||
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "sticker"
|
||||
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
|
||||
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
|
||||
normalizedMime.startsWith("image/") -> "image"
|
||||
normalizedMime.startsWith("video/") -> "video"
|
||||
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
|
||||
|
||||
@@ -132,6 +132,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.daemonlord.messenger.BuildConfig
|
||||
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
||||
@@ -141,6 +146,7 @@ import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
import java.net.URLEncoder
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -314,6 +320,10 @@ fun ChatScreen(
|
||||
var showInlineSearch by remember { mutableStateOf(false) }
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
var emojiPickerTab by remember { mutableStateOf(ComposerPickerTab.Emoji) }
|
||||
var gifSearchQuery by remember { mutableStateOf("") }
|
||||
var giphySearchItems by remember { mutableStateOf<List<RemotePickerItem>>(emptyList()) }
|
||||
var isGiphyLoading by remember { mutableStateOf(false) }
|
||||
var giphyErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var isPickerSending by remember { mutableStateOf(false) }
|
||||
var showChatMenu by remember { mutableStateOf(false) }
|
||||
var showChatInfoSheet by remember { mutableStateOf(false) }
|
||||
@@ -325,6 +335,7 @@ fun ChatScreen(
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp
|
||||
val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) }
|
||||
val giphyApiKey = remember { BuildConfig.GIPHY_API_KEY.trim() }
|
||||
|
||||
LaunchedEffect(state.isRecordingVoice) {
|
||||
if (!state.isRecordingVoice) return@LaunchedEffect
|
||||
@@ -357,6 +368,33 @@ fun ChatScreen(
|
||||
onInlineSearchChanged("")
|
||||
}
|
||||
}
|
||||
LaunchedEffect(emojiPickerTab, gifSearchQuery, giphyApiKey) {
|
||||
if (emojiPickerTab != ComposerPickerTab.Gif) return@LaunchedEffect
|
||||
val query = gifSearchQuery.trim()
|
||||
if (query.isBlank()) {
|
||||
giphySearchItems = emptyList()
|
||||
giphyErrorMessage = null
|
||||
isGiphyLoading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (giphyApiKey.isBlank()) {
|
||||
giphySearchItems = emptyList()
|
||||
giphyErrorMessage = "Set GIPHY_API_KEY in local.properties"
|
||||
isGiphyLoading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
isGiphyLoading = true
|
||||
giphyErrorMessage = null
|
||||
delay(300)
|
||||
val found = fetchGiphySearchItems(
|
||||
query = query,
|
||||
apiKey = giphyApiKey,
|
||||
limit = 24,
|
||||
)
|
||||
giphySearchItems = found
|
||||
giphyErrorMessage = if (found.isEmpty()) "No GIFs found" else null
|
||||
isGiphyLoading = false
|
||||
}
|
||||
LaunchedEffect(listState, timelineItems) {
|
||||
snapshotFlow {
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
@@ -1575,10 +1613,41 @@ fun ChatScreen(
|
||||
ComposerPickerTab.Sticker,
|
||||
-> {
|
||||
val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) {
|
||||
defaultGifItems
|
||||
if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems
|
||||
} else {
|
||||
defaultStickerItems
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (emojiPickerTab == ComposerPickerTab.Gif) {
|
||||
OutlinedTextField(
|
||||
value = gifSearchQuery,
|
||||
onValueChange = { gifSearchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("Search GIFs") },
|
||||
)
|
||||
when {
|
||||
isGiphyLoading -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
!giphyErrorMessage.isNullOrBlank() -> {
|
||||
Text(
|
||||
text = giphyErrorMessage.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier
|
||||
@@ -1625,6 +1694,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isPickerSending) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -2287,6 +2357,44 @@ private fun TextFieldValue.insertAtCursor(value: String): TextFieldValue {
|
||||
return copy(text = nextText, selection = TextRange(cursor))
|
||||
}
|
||||
|
||||
private suspend fun fetchGiphySearchItems(
|
||||
query: String,
|
||||
apiKey: String,
|
||||
limit: Int = 24,
|
||||
): List<RemotePickerItem> {
|
||||
if (query.isBlank() || apiKey.isBlank()) return emptyList()
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val encoded = URLEncoder.encode(query, "UTF-8")
|
||||
val endpoint = "https://api.giphy.com/v1/gifs/search?api_key=$apiKey&q=$encoded&limit=$limit&rating=pg-13&lang=ru"
|
||||
val raw = java.net.URL(endpoint).readText()
|
||||
val data = Json.parseToJsonElement(raw)
|
||||
.jsonObject["data"]
|
||||
?.jsonArray
|
||||
.orEmpty()
|
||||
|
||||
data.mapNotNull { node ->
|
||||
val obj = node.jsonObject
|
||||
val id = obj["id"]?.jsonPrimitive?.content.orEmpty()
|
||||
val url = obj["images"]
|
||||
?.jsonObject
|
||||
?.get("fixed_width")
|
||||
?.jsonObject
|
||||
?.get("url")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
.orEmpty()
|
||||
if (id.isBlank() || url.isBlank()) return@mapNotNull null
|
||||
RemotePickerItem(
|
||||
title = obj["title"]?.jsonPrimitive?.content.orEmpty().ifBlank { "GIF" },
|
||||
url = url,
|
||||
fileNamePrefix = "gif_search_$id",
|
||||
)
|
||||
}
|
||||
}.getOrElse { emptyList() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadRemoteMedia(url: String, fileNamePrefix: String): PickedMediaPayload? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
|
||||
Reference in New Issue
Block a user