gif and better reactions

This commit is contained in:
2026-03-03 11:48:15 +00:00
parent 6a87a33ea0
commit 82890d85ba
11 changed files with 689 additions and 120 deletions

View File

@@ -18,6 +18,7 @@ android {
versionName = "1.1" versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "TENOR_API_KEY", "\"AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCDY\"")
} }
buildTypes { buildTypes {
@@ -41,7 +42,9 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
packaging { packaging {
dex { dex {
useLegacyPackaging = true useLegacyPackaging = true

View File

@@ -23,6 +23,15 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -31,6 +31,7 @@ class PreferencesManager(private val context: Context) {
private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed") private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed")
private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names") private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names")
private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads") private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads")
private val KEY_TENOR_API_KEY = stringPreferencesKey("tenor_api_key")
} }
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs -> val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -125,6 +126,16 @@ class PreferencesManager(private val context: Context) {
} }
} }
val tenorApiKey: Flow<String> = context.dataStore.data.map { prefs ->
prefs[KEY_TENOR_API_KEY] ?: ""
}
suspend fun setTenorApiKey(key: String) {
context.dataStore.edit { prefs ->
prefs[KEY_TENOR_API_KEY] = key
}
}
// Thread names: key = "roomId:threadRootEventId", value = custom name // Thread names: key = "roomId:threadRootEventId", value = custom name
val threadNames: Flow<Map<String, String>> = context.dataStore.data.map { prefs -> val threadNames: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_THREAD_NAMES] ?: return@map emptyMap() val raw = prefs[KEY_THREAD_NAMES] ?: return@map emptyMap()

View File

@@ -0,0 +1,37 @@
package com.example.fluffytrix.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GiphyResponse(
val data: List<GiphyResult> = emptyList(),
)
@Serializable
data class GiphyResult(
val id: String,
val title: String = "",
val images: GiphyImages = GiphyImages(),
) {
val previewUrl: String get() = images.fixedWidthDownsampled.url
.ifBlank { images.fixedHeight.url }
val fullUrl: String get() = images.original.url
.ifBlank { images.fixedHeight.url }
val previewWidth: Int get() = images.fixedWidthDownsampled.width.toIntOrNull() ?: 200
val previewHeight: Int get() = images.fixedWidthDownsampled.height.toIntOrNull() ?: 200
}
@Serializable
data class GiphyImages(
@SerialName("fixed_height") val fixedHeight: GiphyImageEntry = GiphyImageEntry(),
@SerialName("fixed_width_downsampled") val fixedWidthDownsampled: GiphyImageEntry = GiphyImageEntry(),
val original: GiphyImageEntry = GiphyImageEntry(),
)
@Serializable
data class GiphyImageEntry(
val url: String = "",
val width: String = "200",
val height: String = "200",
)

View File

@@ -0,0 +1,48 @@
package com.example.fluffytrix.data.repository
import android.util.Log
import com.example.fluffytrix.data.model.GiphyResponse
import com.example.fluffytrix.data.model.GiphyResult
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
class GifRepository {
private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
private val baseUrl = "https://api.giphy.com/v1/gifs"
fun trending(apiKey: String, limit: Int = 24): List<GiphyResult> {
val url = "$baseUrl/trending?api_key=$apiKey&limit=$limit&rating=g"
return fetch(url)
}
fun search(apiKey: String, query: String, limit: Int = 24): List<GiphyResult> {
val encoded = java.net.URLEncoder.encode(query, "UTF-8")
val url = "$baseUrl/search?api_key=$apiKey&q=$encoded&limit=$limit&rating=g"
return fetch(url)
}
private fun fetch(url: String): List<GiphyResult> {
Log.d("GifRepository", "Fetching: $url")
return try {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
Log.d("GifRepository", "Response code: ${response.code}")
val body = response.body?.string() ?: run {
Log.w("GifRepository", "Empty body")
return emptyList()
}
if (!response.isSuccessful) {
Log.e("GifRepository", "Error response: $body")
return emptyList()
}
val results = json.decodeFromString<GiphyResponse>(body).data
Log.d("GifRepository", "Parsed ${results.size} results")
results
} catch (e: Exception) {
Log.e("GifRepository", "Fetch failed", e)
emptyList()
}
}
}

View File

@@ -151,9 +151,6 @@ fun FluffytrixNavigation() {
onSettingsClick = { onSettingsClick = {
navController.navigate(Screen.Settings.route) navController.navigate(Screen.Settings.route)
}, },
onEmojiPackManagement = { roomId ->
navController.navigate(Screen.EmojiPackManagement.route(roomId))
},
) )
} }
composable( composable(
@@ -174,6 +171,9 @@ fun FluffytrixNavigation() {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
}, },
onEmojiPackManagement = {
navController.navigate(Screen.EmojiPackManagement.route())
},
) )
} }
} }

View File

@@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -17,6 +19,8 @@ import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
@@ -31,7 +35,6 @@ import org.koin.compose.koinInject
fun MainScreen( fun MainScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
onSettingsClick: () -> Unit = {}, onSettingsClick: () -> Unit = {},
onEmojiPackManagement: (String?) -> Unit = {},
viewModel: MainViewModel = koinViewModel(), viewModel: MainViewModel = koinViewModel(),
) { ) {
val spaces by viewModel.spaces.collectAsStateWithLifecycle() val spaces by viewModel.spaces.collectAsStateWithLifecycle()
@@ -59,9 +62,16 @@ fun MainScreen(
val preferencesManager: PreferencesManager = koinInject() val preferencesManager: PreferencesManager = koinInject()
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false) val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
// Back button: close thread first, then open channel list val keyboardController = LocalSoftwareKeyboardController.current
BackHandler(enabled = selectedThread != null || (selectedChannel != null && !showChannelList)) { val imeInsets = WindowInsets.ime
if (selectedThread != null) { val density = LocalDensity.current
val isKeyboardVisible = imeInsets.getBottom(density) > 0
// Back button: dismiss keyboard first, then close thread, then open channel list
BackHandler(enabled = isKeyboardVisible || selectedThread != null || (selectedChannel != null && !showChannelList)) {
if (isKeyboardVisible) {
keyboardController?.hide()
} else if (selectedThread != null) {
viewModel.closeThread() viewModel.closeThread()
} else { } else {
viewModel.toggleChannelList() viewModel.toggleChannelList()
@@ -110,6 +120,7 @@ fun MainScreen(
onToggleMemberList = { viewModel.toggleMemberList() }, onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) }, onSendMessage = { viewModel.sendMessage(it) },
onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) }, onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) },
onSendGif = { url -> viewModel.sendGif(url) },
onLoadMore = { viewModel.loadMoreMessages() }, onLoadMore = { viewModel.loadMoreMessages() },
unreadMarkerIndex = unreadMarkerIndex, unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -145,7 +156,6 @@ fun MainScreen(
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) }, onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) }, onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
emojiPacks = emojiPacks, emojiPacks = emojiPacks,
onOpenEmojiPackManagement = { onEmojiPackManagement(selectedChannel) },
) )
AnimatedVisibility(visible = showMemberList) { AnimatedVisibility(visible = showMemberList) {

View File

@@ -68,9 +68,9 @@ data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUr
sealed interface MessageContent { sealed interface MessageContent {
data class Text(val body: String, val urls: List<String> = emptyList(), val inlineEmojis: List<InlineEmoji> = emptyList()) : MessageContent data class Text(val body: String, val urls: List<String> = emptyList(), val inlineEmojis: List<InlineEmoji> = emptyList()) : MessageContent
data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent data class Image(val body: String, val url: String, val sourceJson: String? = null, val mimeType: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class Gif(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent data class Gif(val body: String, val url: String, val sourceJson: String? = null, val mimeType: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent data class Video(val body: String, val url: String? = null, val sourceJson: String? = null, val mimeType: String? = null, val thumbnailUrl: String? = null, val thumbnailSourceJson: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent
} }
@@ -833,16 +833,21 @@ class MainViewModel(
val c = msgType.content val c = msgType.content
val mxcUrl = c.source.url() val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val sourceJson = try { c.source.toJson() } catch (_: Exception) { null }
val info = c.info val info = c.info
val isGif = info?.mimetype == "image/gif" || info?.isAnimated == true val isGif = info?.mimetype == "image/gif" || info?.isAnimated == true
if (isGif) MessageContent.Gif( if (isGif) MessageContent.Gif(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "image/gif",
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) else MessageContent.Image( ) else MessageContent.Image(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "image/*",
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) )
@@ -851,6 +856,7 @@ class MainViewModel(
val c = msgType.content val c = msgType.content
val mxcUrl = c.source.url() val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val sourceJson = try { c.source.toJson() } catch (_: Exception) { null }
val info = c.info val info = c.info
// Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL // Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL
val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) || val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) ||
@@ -860,16 +866,22 @@ class MainViewModel(
MessageContent.Gif( MessageContent.Gif(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "video/mp4",
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) )
} else { } else {
val thumbMxc = info?.thumbnailSource?.url() val thumbMxc = info?.thumbnailSource?.url()
val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url
val thumbnailSourceJson = try { info?.thumbnailSource?.toJson() } catch (_: Exception) { null }
MessageContent.Video( MessageContent.Video(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "video/*",
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
thumbnailSourceJson = thumbnailSourceJson,
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) )
@@ -1059,6 +1071,42 @@ class MainViewModel(
} }
} }
fun sendGif(url: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
android.util.Log.d("SendGif", "Downloading $url")
val client = okhttp3.OkHttpClient()
val response = client.newCall(okhttp3.Request.Builder().url(url).build()).execute()
android.util.Log.d("SendGif", "Response: ${response.code}")
val bytes = response.body?.bytes() ?: run {
android.util.Log.e("SendGif", "Empty body")
return@launch
}
android.util.Log.d("SendGif", "Downloaded ${bytes.size} bytes, sending…")
val params = UploadParameters(
source = UploadSource.Data(bytes, "giphy.gif"),
caption = null,
formattedCaption = null,
mentions = null,
inReplyTo = null,
)
timeline.sendFile(
params = params,
fileInfo = org.matrix.rustcomponents.sdk.FileInfo(
mimetype = "image/gif",
size = bytes.size.toULong(),
thumbnailInfo = null,
thumbnailSource = null,
),
)
android.util.Log.d("SendGif", "Sent!")
} catch (e: Exception) {
android.util.Log.e("SendGif", "Failed", e)
}
}
}
fun selectHome() { fun selectHome() {
if (_selectedSpace.value == null) { if (_selectedSpace.value == null) {
_showChannelList.value = !_showChannelList.value _showChannelList.value = !_showChannelList.value

View File

@@ -74,13 +74,21 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.emoji2.emojipicker.EmojiPickerView import androidx.emoji2.emojipicker.EmojiPickerView
import androidx.emoji2.emojipicker.EmojiViewItem import androidx.emoji2.emojipicker.EmojiViewItem
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.EmojiEmotions
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
@@ -89,12 +97,17 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@@ -107,7 +120,11 @@ import com.example.fluffytrix.data.repository.AuthRepository
import org.koin.compose.koinInject import org.koin.compose.koinInject
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.example.fluffytrix.data.MxcUrlHelper import com.example.fluffytrix.data.MxcUrlHelper
import androidx.compose.runtime.produceState
import org.matrix.rustcomponents.sdk.MediaSource
import com.example.fluffytrix.data.model.EmojiPack import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.data.model.GiphyResult
import com.example.fluffytrix.data.repository.GifRepository
import com.example.fluffytrix.ui.screens.main.InlineEmoji import com.example.fluffytrix.ui.screens.main.InlineEmoji
import com.example.fluffytrix.ui.screens.main.MessageContent import com.example.fluffytrix.ui.screens.main.MessageContent
import com.example.fluffytrix.ui.screens.main.MessageItem import com.example.fluffytrix.ui.screens.main.MessageItem
@@ -155,6 +172,7 @@ fun MessageTimeline(
onToggleMemberList: () -> Unit, onToggleMemberList: () -> Unit,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onSendFiles: (List<Uri>, String?) -> Unit, onSendFiles: (List<Uri>, String?) -> Unit,
onSendGif: (String) -> Unit = {},
onLoadMore: () -> Unit = {}, onLoadMore: () -> Unit = {},
unreadMarkerIndex: Int = -1, unreadMarkerIndex: Int = -1,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -178,7 +196,6 @@ fun MessageTimeline(
onSendReaction: (String, String) -> Unit = { _, _ -> }, onSendReaction: (String, String) -> Unit = { _, _ -> },
onSendThreadReaction: (String, String) -> Unit = { _, _ -> }, onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
emojiPacks: List<EmojiPack> = emptyList(), emojiPacks: List<EmojiPack> = emptyList(),
onOpenEmojiPackManagement: () -> Unit = {},
) { ) {
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) } var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) } var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -251,7 +268,7 @@ fun MessageTimeline(
if (selectedThread != null) { if (selectedThread != null) {
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread) ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
} else { } else {
TopBar(channelName ?: selectedChannel, onToggleMemberList, onOpenEmojiPackManagement) TopBar(channelName ?: selectedChannel, onToggleMemberList)
} }
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
} }
@@ -422,6 +439,7 @@ fun MessageTimeline(
editingMessage = editingMessage, editingMessage = editingMessage,
onSendMessage = activeSend, onSendMessage = activeSend,
onSendFiles = onSendFiles, onSendFiles = onSendFiles,
onSendGif = onSendGif,
onSendReply = { body, eventId -> onSendReply = { body, eventId ->
if (selectedThread != null) onSendThreadReply(body, eventId) if (selectedThread != null) onSendThreadReply(body, eventId)
else onSendReply(body, eventId) else onSendReply(body, eventId)
@@ -438,7 +456,7 @@ fun MessageTimeline(
} }
@Composable @Composable
private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPackManagement: () -> Unit = {}) { private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -452,9 +470,6 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPack
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onOpenEmojiPackManagement) {
Icon(Icons.Default.EmojiEmotions, "Emoji packs", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onToggleMemberList) { IconButton(onClick = onToggleMemberList) {
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
@@ -488,6 +503,36 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
val currentUserId = LocalCurrentUserId.current val currentUserId = LocalCurrentUserId.current
val authRepository: AuthRepository = koinInject() val authRepository: AuthRepository = koinInject()
val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } } val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } }
var reactionDetailEmoji by remember { mutableStateOf<String?>(null) }
var reactionDetailSenders by remember { mutableStateOf<List<String>>(emptyList()) }
if (reactionDetailEmoji != null) {
AlertDialog(
onDismissRequest = { reactionDetailEmoji = null },
title = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (reactionDetailEmoji!!.startsWith("mxc://")) {
val resolvedUrl = remember(reactionDetailEmoji) { MxcUrlHelper.mxcToDownloadUrl(baseUrl, reactionDetailEmoji!!) ?: reactionDetailEmoji!! }
AsyncImage(model = resolvedUrl, contentDescription = null, modifier = Modifier.size(24.dp))
} else {
Text(reactionDetailEmoji!!, fontSize = 20.sp)
}
Text("Reacted by", style = MaterialTheme.typography.titleMedium)
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
reactionDetailSenders.forEach { sender ->
Text(sender, style = MaterialTheme.typography.bodyMedium)
}
}
},
confirmButton = {
TextButton(onClick = { reactionDetailEmoji = null }) { Text("Close") }
},
)
}
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -499,7 +544,13 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = if (isMine) MaterialTheme.colorScheme.primaryContainer color = if (isMine) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant, else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.clickable { onReact(eventId, emoji) }, modifier = Modifier.combinedClickable(
onClick = { onReact(eventId, emoji) },
onLongClick = {
reactionDetailEmoji = emoji
reactionDetailSenders = senders
},
),
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
@@ -755,20 +806,44 @@ private fun InlineEmojiText(content: MessageContent.Text) {
} }
} }
private suspend fun resolveMediaUrl(
authRepository: AuthRepository,
sourceJson: String,
mimeType: String,
filename: String?,
fallbackUrl: String?,
): String? = try {
val client = authRepository.getClient() ?: return fallbackUrl
val source = MediaSource.fromJson(sourceJson)
val handle = client.getMediaFile(source, filename, mimeType, useCache = true, tempDir = null)
"file://${handle.path()}"
} catch (_: Exception) {
fallbackUrl
}
@Composable @Composable
private fun ImageContent(content: MessageContent.Image) { private fun ImageContent(content: MessageContent.Image) {
val onViewImage = LocalImageViewer.current val onViewImage = LocalImageViewer.current
val authRepository: AuthRepository = koinInject()
val aspectRatio = if (content.width != null && content.height != null && content.height > 0) val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
content.width.toFloat() / content.height.toFloat() else null content.width.toFloat() / content.height.toFloat() else null
val resolvedUrl by produceState<String?>(null, content.sourceJson, content.url) {
value = if (content.sourceJson != null) {
resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "image/*", content.body, content.url)
} else {
content.url
}
}
AsyncImage( AsyncImage(
model = content.url, model = resolvedUrl,
contentDescription = content.body, contentDescription = content.body,
modifier = Modifier modifier = Modifier
.let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) } .let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) }
.height(300.dp) .height(300.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onViewImage(content.url) }, .clickable { resolvedUrl?.let { onViewImage(it) } },
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} }
@@ -781,15 +856,45 @@ private fun GifContent(content: MessageContent.Gif) {
val aspectRatio = if (content.width != null && content.height != null && content.height > 0) val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
content.width.toFloat() / content.height.toFloat() else 16f / 9f content.width.toFloat() / content.height.toFloat() else 16f / 9f
val exoPlayer = remember(content.url) { val isNativeGif = content.mimeType == "image/gif" ||
content.body.endsWith(".gif", ignoreCase = true) ||
content.url.endsWith(".gif", ignoreCase = true)
val resolvedUrl by produceState<String?>(null, content.sourceJson, content.url) {
value = if (content.sourceJson != null) {
resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "video/mp4", content.body, content.url)
} else {
content.url
}
}
if (isNativeGif) {
// Real GIF — use Coil (coil-gif handles animated playback)
AsyncImage(
model = resolvedUrl,
contentDescription = content.body,
contentScale = ContentScale.Fit,
modifier = Modifier
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
.height(200.dp)
.clip(RoundedCornerShape(8.dp)),
)
} else {
// Video GIF (MP4/WebM from bridges) — use ExoPlayer
val exoPlayer = remember(resolvedUrl) {
val url = resolvedUrl ?: return@remember null
val isFileUri = url.startsWith("file://")
val mediaItem = MediaItem.fromUri(Uri.parse(url))
val mediaSource = if (isFileUri) {
ProgressiveMediaSource.Factory(androidx.media3.datasource.FileDataSource.Factory())
.createMediaSource(mediaItem)
} else {
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
val dataSourceFactory = DefaultHttpDataSource.Factory().apply { val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) { if (token != null) setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
} }
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
} }
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(content.url)))
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource) setMediaSource(mediaSource)
prepare() prepare()
@@ -799,28 +904,47 @@ private fun GifContent(content: MessageContent.Gif) {
} }
} }
DisposableEffect(content.url) { DisposableEffect(resolvedUrl) {
onDispose { exoPlayer.release() } onDispose { exoPlayer?.release() }
} }
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PlayerView(ctx).apply { PlayerView(ctx).apply {
player = exoPlayer
useController = false useController = false
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER) setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
} }
}, },
update = { view -> view.player = exoPlayer },
modifier = Modifier modifier = Modifier
.width((200.dp * aspectRatio).coerceAtMost(400.dp)) .width((200.dp * aspectRatio).coerceAtMost(400.dp))
.height(200.dp) .height(200.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
) )
} }
}
@Composable @Composable
private fun VideoContent(content: MessageContent.Video) { private fun VideoContent(content: MessageContent.Video) {
val onPlayVideo = LocalVideoPlayer.current val onPlayVideo = LocalVideoPlayer.current
val authRepository: AuthRepository = koinInject()
val resolvedVideoUrl by produceState<String?>(null, content.sourceJson, content.url) {
value = if (content.sourceJson != null) {
resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "video/*", content.body, content.url)
} else {
content.url
}
}
val resolvedThumbnailUrl by produceState<String?>(null, content.thumbnailSourceJson, content.thumbnailUrl) {
value = if (content.thumbnailSourceJson != null) {
resolveMediaUrl(authRepository, content.thumbnailSourceJson, "image/*", null, content.thumbnailUrl)
} else {
content.thumbnailUrl
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.height(200.dp) .height(200.dp)
@@ -830,12 +954,12 @@ private fun VideoContent(content: MessageContent.Video) {
mod.width((200.dp * ar).coerceAtMost(400.dp)) mod.width((200.dp * ar).coerceAtMost(400.dp))
} }
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { content.url?.let { onPlayVideo(it) } }, .clickable { resolvedVideoUrl?.let { onPlayVideo(it) } },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (content.thumbnailUrl != null) { if (resolvedThumbnailUrl != null) {
AsyncImage( AsyncImage(
model = content.thumbnailUrl, model = resolvedThumbnailUrl,
contentDescription = content.body, contentDescription = content.body,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@@ -912,14 +1036,17 @@ private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val authRepository: AuthRepository = koinInject() val authRepository: AuthRepository = koinInject()
val exoPlayer = remember { val exoPlayer = remember {
val mediaItem = MediaItem.fromUri(Uri.parse(url))
val mediaSource = if (url.startsWith("file://")) {
ProgressiveMediaSource.Factory(androidx.media3.datasource.FileDataSource.Factory())
.createMediaSource(mediaItem)
} else {
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
val dataSourceFactory = DefaultHttpDataSource.Factory().apply { val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) { if (token != null) setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
} }
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
} }
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource) setMediaSource(mediaSource)
prepare() prepare()
@@ -1012,12 +1139,15 @@ private fun MessageInput(
channelName: String, channelName: String,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onSendFiles: (List<Uri>, String?) -> Unit, onSendFiles: (List<Uri>, String?) -> Unit,
onSendGif: (String) -> Unit = {},
replyingTo: MessageItem? = null, replyingTo: MessageItem? = null,
editingMessage: MessageItem? = null, editingMessage: MessageItem? = null,
onSendReply: (String, String) -> Unit = { _, _ -> }, onSendReply: (String, String) -> Unit = { _, _ -> },
onEditMessage: (String, String) -> Unit = { _, _ -> }, onEditMessage: (String, String) -> Unit = { _, _ -> },
emojiPacks: List<EmojiPack> = emptyList(), emojiPacks: List<EmojiPack> = emptyList(),
) { ) {
val prefsManager: com.example.fluffytrix.data.local.PreferencesManager = koinInject()
val gifApiKey by prefsManager.tenorApiKey.collectAsState(initial = "")
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var attachedUris by remember { mutableStateOf(listOf<Uri>()) } var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
var showEmojiPackPicker by remember { mutableStateOf(false) } var showEmojiPackPicker by remember { mutableStateOf(false) }
@@ -1063,8 +1193,12 @@ private fun MessageInput(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
val tabs = emojiPacks.map { it.displayName } + "Unicode" val inputScope = rememberCoroutineScope()
val inputContext = LocalContext.current
var selectedTab by remember { mutableStateOf(0) } var selectedTab by remember { mutableStateOf(0) }
val tabs = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "GIFs", "Unicode") else listOf("GIFs", "Unicode")
val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1
val gifTabIndex = if (emojiPacks.isNotEmpty()) 1 else 0
ScrollableTabRow(selectedTabIndex = selectedTab) { ScrollableTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title -> tabs.forEachIndexed { index, title ->
Tab( Tab(
@@ -1074,16 +1208,24 @@ private fun MessageInput(
) )
} }
} }
if (selectedTab < emojiPacks.size) { when {
CustomEmojiGrid( selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
pack = emojiPacks[selectedTab], packs = emojiPacks,
onEmojiSelected = { entry -> onEmojiSelected = { entry ->
text = text + ":${entry.shortcode}:" text = text + ":${entry.shortcode}:"
showEmojiPackPicker = false showEmojiPackPicker = false
}, },
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.fillMaxWidth().weight(1f),
) )
} else { selectedTab == gifTabIndex -> GifSearchTab(
apiKey = gifApiKey,
onGifSelected = { gif ->
showEmojiPackPicker = false
onSendGif(gif.fullUrl)
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
else -> {
val currentText by rememberUpdatedState(text) val currentText by rememberUpdatedState(text)
AndroidView( AndroidView(
factory = { ctx -> EmojiPickerView(ctx) }, factory = { ctx -> EmojiPickerView(ctx) },
@@ -1100,6 +1242,7 @@ private fun MessageInput(
} }
} }
} }
}
// Autocomplete strip: show matching emojis when text ends with :partialword (no space, no closing colon) // Autocomplete strip: show matching emojis when text ends with :partialword (no space, no closing colon)
val autocompleteResults = remember(text, emojiPacks) { val autocompleteResults = remember(text, emojiPacks) {
@@ -1194,14 +1337,6 @@ private fun MessageInput(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (emojiPacks.isNotEmpty()) {
IconButton(onClick = { showEmojiPackPicker = true }) {
Icon(
Icons.Default.EmojiEmotions, "Insert emoji",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextField( TextField(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
@@ -1215,6 +1350,14 @@ private fun MessageInput(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
), ),
) )
if (emojiPacks.isNotEmpty()) {
IconButton(onClick = { showEmojiPackPicker = true }) {
Icon(
Icons.Default.EmojiEmotions, "Insert emoji",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton( IconButton(
onClick = { onClick = {
val trimmed = text.trim() val trimmed = text.trim()
@@ -1300,9 +1443,9 @@ private fun MessageContextMenu(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
if (emojiPacks.isNotEmpty()) {
var selectedTab by remember { mutableStateOf(0) } var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Unicode") + emojiPacks.map { it.displayName } val tabs = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "Unicode") else listOf("Unicode")
val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1
ScrollableTabRow(selectedTabIndex = selectedTab) { ScrollableTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title -> tabs.forEachIndexed { index, title ->
Tab( Tab(
@@ -1313,31 +1456,15 @@ private fun MessageContextMenu(
} }
} }
when { when {
selectedTab == 0 -> AndroidView( selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
factory = { ctx -> EmojiPickerView(ctx) }, packs = emojiPacks,
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
currentOnReact(emojiViewItem.emoji)
showEmojiPicker = false
}
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
else -> {
val pack = emojiPacks[selectedTab - 1]
CustomEmojiGrid(
pack = pack,
onEmojiSelected = { entry -> onEmojiSelected = { entry ->
currentOnReact(entry.mxcUrl) currentOnReact(entry.mxcUrl)
showEmojiPicker = false showEmojiPicker = false
}, },
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.fillMaxWidth().weight(1f),
) )
} else -> AndroidView(
}
} else {
Spacer(modifier = Modifier.height(8.dp))
AndroidView(
factory = { ctx -> EmojiPickerView(ctx) }, factory = { ctx -> EmojiPickerView(ctx) },
update = { view -> update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem -> view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
@@ -1345,9 +1472,7 @@ private fun MessageContextMenu(
showEmojiPicker = false showEmojiPicker = false
} }
}, },
modifier = Modifier modifier = Modifier.fillMaxWidth().weight(1f),
.fillMaxWidth()
.weight(1f),
) )
} }
} }
@@ -1510,6 +1635,158 @@ private fun EditModeBar(body: String, onDismiss: () -> Unit) {
} }
} }
@Composable
private fun GifSearchTab(
apiKey: String,
onGifSelected: (GiphyResult) -> Unit,
modifier: Modifier = Modifier,
) {
val gifRepo = remember { GifRepository() }
var query by remember { mutableStateOf("") }
val results = remember { mutableStateListOf<GiphyResult>() }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
LaunchedEffect(query, apiKey) {
if (query.isNotBlank()) kotlinx.coroutines.delay(400)
if (apiKey.isBlank()) {
errorMessage = "No GIPHY API key — add one in Settings → Customization."
isLoading = false
return@LaunchedEffect
}
isLoading = true
errorMessage = null
try {
val list = withContext(Dispatchers.IO) {
if (query.isBlank()) gifRepo.trending(apiKey) else gifRepo.search(apiKey, query)
}
results.clear()
results.addAll(list)
} catch (e: Exception) {
errorMessage = e.message ?: "Unknown error"
} finally {
isLoading = false
}
}
val keyboardController = LocalSoftwareKeyboardController.current
Column(modifier = modifier) {
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search GIFs…") },
leadingIcon = { Icon(Icons.Default.Search, null) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(RoundedCornerShape(24.dp)),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
),
)
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
androidx.compose.material3.CircularProgressIndicator()
}
errorMessage != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Error: $errorMessage", color = MaterialTheme.colorScheme.error)
}
results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No GIFs found", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(results, key = { it.id }) { gif ->
val ratio = if (gif.previewHeight > 0) gif.previewWidth.toFloat() / gif.previewHeight else 1f
AsyncImage(
model = gif.previewUrl,
contentDescription = gif.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(ratio)
.clip(RoundedCornerShape(4.dp))
.clickable { onGifSelected(gif) },
)
}
}
}
}
}
@Composable
private fun CollapsableCustomEmojiPacks(
packs: List<EmojiPack>,
onEmojiSelected: (com.example.fluffytrix.data.model.EmojiEntry) -> Unit,
modifier: Modifier = Modifier,
) {
val expanded = remember { mutableStateMapOf<String, Boolean>().also { map -> packs.forEach { map[it.packId] = true } } }
LazyVerticalGrid(
columns = GridCells.Adaptive(56.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
packs.forEach { pack ->
val isExpanded = expanded[pack.packId] != false
item(key = "header_${pack.packId}", span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded[pack.packId] = !isExpanded }
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = pack.displayName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (isExpanded) {
items(pack.emojis, key = { "${pack.packId}_${it.shortcode}" }) { entry ->
Column(
modifier = Modifier
.clickable { onEmojiSelected(entry) }
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = entry.resolvedUrl,
contentDescription = entry.shortcode,
modifier = Modifier.size(40.dp),
)
Text(
text = entry.shortcode,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
@Composable @Composable
private fun CustomEmojiGrid( private fun CustomEmojiGrid(
pack: EmojiPack, pack: EmojiPack,

View File

@@ -13,6 +13,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -20,6 +22,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -28,26 +31,41 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.koinInject import org.koin.compose.koinInject
private sealed interface GiphyKeyStatus {
data object Idle : GiphyKeyStatus
data object Testing : GiphyKeyStatus
data object Invalid : GiphyKeyStatus
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onEmojiPackManagement: () -> Unit = {},
) { ) {
val preferencesManager: PreferencesManager = koinInject() val preferencesManager: PreferencesManager = koinInject()
val userId by preferencesManager.userId.collectAsState(initial = null) val userId by preferencesManager.userId.collectAsState(initial = null)
val homeserver by preferencesManager.homeserverUrl.collectAsState(initial = null) val homeserver by preferencesManager.homeserverUrl.collectAsState(initial = null)
val deviceId by preferencesManager.deviceId.collectAsState(initial = null) val deviceId by preferencesManager.deviceId.collectAsState(initial = null)
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false) val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false)
val savedGiphyKey by preferencesManager.tenorApiKey.collectAsState(initial = "")
var giphyKeyInput by remember { mutableStateOf("") }
var giphyKeyStatus by remember { mutableStateOf<GiphyKeyStatus>(GiphyKeyStatus.Idle) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val appVersion = try { val appVersion = try {
@@ -109,6 +127,91 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeader("Customization")
SettingNavRow(label = "Emoji Packs", onClick = onEmojiPackManagement)
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Text("GIPHY API Key (for GIF search)", style = MaterialTheme.typography.bodyMedium)
Text(
"Get a free key at developers.giphy.com → Create App",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
if (savedGiphyKey.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Key saved (${savedGiphyKey.take(8)}…)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Button(
onClick = { scope.launch { preferencesManager.setTenorApiKey("") } },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Text("Remove key")
}
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = giphyKeyInput,
onValueChange = {
giphyKeyInput = it
giphyKeyStatus = GiphyKeyStatus.Idle
},
placeholder = { Text("Paste API key…") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(
onClick = {
val key = giphyKeyInput.trim()
if (key.isBlank()) return@Button
giphyKeyStatus = GiphyKeyStatus.Testing
scope.launch {
val valid = withContext(Dispatchers.IO) {
try {
val url = "https://api.giphy.com/v1/gifs/trending?api_key=$key&limit=1"
val client = okhttp3.OkHttpClient()
val response = client.newCall(okhttp3.Request.Builder().url(url).build()).execute()
response.isSuccessful
} catch (_: Exception) { false }
}
if (valid) {
preferencesManager.setTenorApiKey(key)
giphyKeyInput = ""
giphyKeyStatus = GiphyKeyStatus.Idle
} else {
giphyKeyStatus = GiphyKeyStatus.Invalid
}
}
},
enabled = giphyKeyInput.isNotBlank() && giphyKeyStatus != GiphyKeyStatus.Testing,
) {
Text(if (giphyKeyStatus == GiphyKeyStatus.Testing) "Testing…" else "Save")
}
}
when (giphyKeyStatus) {
GiphyKeyStatus.Invalid -> Text(
"Invalid API key — check and try again.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
else -> {}
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeader("Notifications") SectionHeader("Notifications")
SettingRow("Push notifications", "Enabled") SettingRow("Push notifications", "Enabled")
@@ -173,3 +276,22 @@ private fun SettingRow(label: String, value: String) {
) )
} }
} }
@Composable
private fun SettingNavRow(label: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
</paths>