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"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "TENOR_API_KEY", "\"AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCDY\"")
}
buildTypes {
@@ -41,7 +42,9 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
dex {
useLegacyPackaging = true

View File

@@ -23,6 +23,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</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>
</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_THREAD_NAMES = stringPreferencesKey("thread_names")
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 ->
@@ -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
val threadNames: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
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 = {
navController.navigate(Screen.Settings.route)
},
onEmojiPackManagement = { roomId ->
navController.navigate(Screen.EmojiPackManagement.route(roomId))
},
)
}
composable(
@@ -174,6 +171,9 @@ fun FluffytrixNavigation() {
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.fillMaxSize
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.runtime.Composable
import androidx.compose.runtime.getValue
@@ -17,6 +19,8 @@ import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier
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.zIndex
import com.example.fluffytrix.data.local.PreferencesManager
@@ -31,7 +35,6 @@ import org.koin.compose.koinInject
fun MainScreen(
onLogout: () -> Unit,
onSettingsClick: () -> Unit = {},
onEmojiPackManagement: (String?) -> Unit = {},
viewModel: MainViewModel = koinViewModel(),
) {
val spaces by viewModel.spaces.collectAsStateWithLifecycle()
@@ -59,9 +62,16 @@ fun MainScreen(
val preferencesManager: PreferencesManager = koinInject()
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
// Back button: close thread first, then open channel list
BackHandler(enabled = selectedThread != null || (selectedChannel != null && !showChannelList)) {
if (selectedThread != null) {
val keyboardController = LocalSoftwareKeyboardController.current
val imeInsets = WindowInsets.ime
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()
} else {
viewModel.toggleChannelList()
@@ -110,6 +120,7 @@ fun MainScreen(
onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) },
onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) },
onSendGif = { url -> viewModel.sendGif(url) },
onLoadMore = { viewModel.loadMoreMessages() },
unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f),
@@ -145,7 +156,6 @@ fun MainScreen(
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
emojiPacks = emojiPacks,
onOpenEmojiPackManagement = { onEmojiPackManagement(selectedChannel) },
)
AnimatedVisibility(visible = showMemberList) {

View File

@@ -68,9 +68,9 @@ data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUr
sealed interface 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 Gif(val body: String, val url: String, 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 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 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 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
}
@@ -833,16 +833,21 @@ class MainViewModel(
val c = msgType.content
val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val sourceJson = try { c.source.toJson() } catch (_: Exception) { null }
val info = c.info
val isGif = info?.mimetype == "image/gif" || info?.isAnimated == true
if (isGif) MessageContent.Gif(
body = c.filename,
url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "image/gif",
width = info?.width?.toInt(),
height = info?.height?.toInt(),
) else MessageContent.Image(
body = c.filename,
url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "image/*",
width = info?.width?.toInt(),
height = info?.height?.toInt(),
)
@@ -851,6 +856,7 @@ class MainViewModel(
val c = msgType.content
val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val sourceJson = try { c.source.toJson() } catch (_: Exception) { null }
val info = c.info
// Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL
val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) ||
@@ -860,16 +866,22 @@ class MainViewModel(
MessageContent.Gif(
body = c.filename,
url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "video/mp4",
width = info?.width?.toInt(),
height = info?.height?.toInt(),
)
} else {
val thumbMxc = info?.thumbnailSource?.url()
val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url
val thumbnailSourceJson = try { info?.thumbnailSource?.toJson() } catch (_: Exception) { null }
MessageContent.Video(
body = c.filename,
url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "video/*",
thumbnailUrl = thumbnailUrl,
thumbnailSourceJson = thumbnailSourceJson,
width = info?.width?.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() {
if (_selectedSpace.value == null) {
_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.EmojiViewItem
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.items
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
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.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.material3.ScrollableTabRow
import androidx.compose.material3.Tab
@@ -89,12 +97,17 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
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.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -107,7 +120,11 @@ import com.example.fluffytrix.data.repository.AuthRepository
import org.koin.compose.koinInject
import coil3.compose.AsyncImage
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.GiphyResult
import com.example.fluffytrix.data.repository.GifRepository
import com.example.fluffytrix.ui.screens.main.InlineEmoji
import com.example.fluffytrix.ui.screens.main.MessageContent
import com.example.fluffytrix.ui.screens.main.MessageItem
@@ -155,6 +172,7 @@ fun MessageTimeline(
onToggleMemberList: () -> Unit,
onSendMessage: (String) -> Unit,
onSendFiles: (List<Uri>, String?) -> Unit,
onSendGif: (String) -> Unit = {},
onLoadMore: () -> Unit = {},
unreadMarkerIndex: Int = -1,
modifier: Modifier = Modifier,
@@ -178,7 +196,6 @@ fun MessageTimeline(
onSendReaction: (String, String) -> Unit = { _, _ -> },
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
emojiPacks: List<EmojiPack> = emptyList(),
onOpenEmojiPackManagement: () -> Unit = {},
) {
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -251,7 +268,7 @@ fun MessageTimeline(
if (selectedThread != null) {
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
} else {
TopBar(channelName ?: selectedChannel, onToggleMemberList, onOpenEmojiPackManagement)
TopBar(channelName ?: selectedChannel, onToggleMemberList)
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
}
@@ -422,6 +439,7 @@ fun MessageTimeline(
editingMessage = editingMessage,
onSendMessage = activeSend,
onSendFiles = onSendFiles,
onSendGif = onSendGif,
onSendReply = { body, eventId ->
if (selectedThread != null) onSendThreadReply(body, eventId)
else onSendReply(body, eventId)
@@ -438,7 +456,7 @@ fun MessageTimeline(
}
@Composable
private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPackManagement: () -> Unit = {}) {
private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -452,9 +470,6 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPack
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onOpenEmojiPackManagement) {
Icon(Icons.Default.EmojiEmotions, "Emoji packs", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onToggleMemberList) {
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 authRepository: AuthRepository = koinInject()
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(
horizontalArrangement = 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),
color = if (isMine) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.clickable { onReact(eventId, emoji) },
modifier = Modifier.combinedClickable(
onClick = { onReact(eventId, emoji) },
onLongClick = {
reactionDetailEmoji = emoji
reactionDetailSenders = senders
},
),
) {
Row(
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
private fun ImageContent(content: MessageContent.Image) {
val onViewImage = LocalImageViewer.current
val authRepository: AuthRepository = koinInject()
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
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(
model = content.url,
model = resolvedUrl,
contentDescription = content.body,
modifier = Modifier
.let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) }
.height(300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onViewImage(content.url) },
.clickable { resolvedUrl?.let { onViewImage(it) } },
contentScale = ContentScale.Fit,
)
}
@@ -781,46 +856,95 @@ private fun GifContent(content: MessageContent.Gif) {
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
content.width.toFloat() / content.height.toFloat() else 16f / 9f
val exoPlayer = remember(content.url) {
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) {
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
}
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(content.url)))
ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = true
repeatMode = ExoPlayer.REPEAT_MODE_ALL
volume = 0f
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
}
}
DisposableEffect(content.url) {
onDispose { exoPlayer.release() }
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = false
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
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 dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
}
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
},
modifier = Modifier
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
.height(200.dp)
.clip(RoundedCornerShape(8.dp)),
)
ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = true
repeatMode = ExoPlayer.REPEAT_MODE_ALL
volume = 0f
}
}
DisposableEffect(resolvedUrl) {
onDispose { exoPlayer?.release() }
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
}
},
update = { view -> view.player = exoPlayer },
modifier = Modifier
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
.height(200.dp)
.clip(RoundedCornerShape(8.dp)),
)
}
}
@Composable
private fun VideoContent(content: MessageContent.Video) {
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(
modifier = Modifier
.height(200.dp)
@@ -830,12 +954,12 @@ private fun VideoContent(content: MessageContent.Video) {
mod.width((200.dp * ar).coerceAtMost(400.dp))
}
.clip(RoundedCornerShape(8.dp))
.clickable { content.url?.let { onPlayVideo(it) } },
.clickable { resolvedVideoUrl?.let { onPlayVideo(it) } },
contentAlignment = Alignment.Center,
) {
if (content.thumbnailUrl != null) {
if (resolvedThumbnailUrl != null) {
AsyncImage(
model = content.thumbnailUrl,
model = resolvedThumbnailUrl,
contentDescription = content.body,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
@@ -912,14 +1036,17 @@ private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) {
val context = LocalContext.current
val authRepository: AuthRepository = koinInject()
val exoPlayer = remember {
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) {
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
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 dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) 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 {
setMediaSource(mediaSource)
prepare()
@@ -1012,12 +1139,15 @@ private fun MessageInput(
channelName: String,
onSendMessage: (String) -> Unit,
onSendFiles: (List<Uri>, String?) -> Unit,
onSendGif: (String) -> Unit = {},
replyingTo: MessageItem? = null,
editingMessage: MessageItem? = null,
onSendReply: (String, String) -> Unit = { _, _ -> },
onEditMessage: (String, String) -> Unit = { _, _ -> },
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 attachedUris by remember { mutableStateOf(listOf<Uri>()) }
var showEmojiPackPicker by remember { mutableStateOf(false) }
@@ -1063,8 +1193,12 @@ private fun MessageInput(
modifier = Modifier.weight(1f),
)
}
val tabs = emojiPacks.map { it.displayName } + "Unicode"
val inputScope = rememberCoroutineScope()
val inputContext = LocalContext.current
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) {
tabs.forEachIndexed { index, title ->
Tab(
@@ -1074,27 +1208,36 @@ private fun MessageInput(
)
}
}
if (selectedTab < emojiPacks.size) {
CustomEmojiGrid(
pack = emojiPacks[selectedTab],
when {
selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
packs = emojiPacks,
onEmojiSelected = { entry ->
text = text + ":${entry.shortcode}:"
showEmojiPackPicker = false
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
} else {
val currentText by rememberUpdatedState(text)
AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
text = currentText + emojiViewItem.emoji
showEmojiPackPicker = false
}
selectedTab == gifTabIndex -> GifSearchTab(
apiKey = gifApiKey,
onGifSelected = { gif ->
showEmojiPackPicker = false
onSendGif(gif.fullUrl)
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
else -> {
val currentText by rememberUpdatedState(text)
AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
text = currentText + emojiViewItem.emoji
showEmojiPackPicker = false
}
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
}
}
@@ -1194,14 +1337,6 @@ private fun MessageInput(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (emojiPacks.isNotEmpty()) {
IconButton(onClick = { showEmojiPackPicker = true }) {
Icon(
Icons.Default.EmojiEmotions, "Insert emoji",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextField(
value = text,
onValueChange = { text = it },
@@ -1215,6 +1350,14 @@ private fun MessageInput(
focusedIndicatorColor = Color.Transparent,
),
)
if (emojiPacks.isNotEmpty()) {
IconButton(onClick = { showEmojiPackPicker = true }) {
Icon(
Icons.Default.EmojiEmotions, "Insert emoji",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(
onClick = {
val trimmed = text.trim()
@@ -1300,44 +1443,28 @@ private fun MessageContextMenu(
modifier = Modifier.weight(1f),
)
}
if (emojiPacks.isNotEmpty()) {
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Unicode") + emojiPacks.map { it.displayName }
ScrollableTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title, maxLines = 1) },
)
}
}
when {
selectedTab == 0 -> AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
currentOnReact(emojiViewItem.emoji)
showEmojiPicker = false
}
},
modifier = Modifier.fillMaxWidth().weight(1f),
var selectedTab by remember { mutableStateOf(0) }
val tabs = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "Unicode") else listOf("Unicode")
val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1
ScrollableTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title, maxLines = 1) },
)
else -> {
val pack = emojiPacks[selectedTab - 1]
CustomEmojiGrid(
pack = pack,
onEmojiSelected = { entry ->
currentOnReact(entry.mxcUrl)
showEmojiPicker = false
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
} else {
Spacer(modifier = Modifier.height(8.dp))
AndroidView(
}
when {
selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
packs = emojiPacks,
onEmojiSelected = { entry ->
currentOnReact(entry.mxcUrl)
showEmojiPicker = false
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
else -> AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
@@ -1345,9 +1472,7 @@ private fun MessageContextMenu(
showEmojiPicker = false
}
},
modifier = Modifier
.fillMaxWidth()
.weight(1f),
modifier = Modifier.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
private fun CustomEmojiGrid(
pack: EmojiPack,

View File

@@ -13,6 +13,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -20,6 +22,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -28,26 +31,41 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.koinInject
private sealed interface GiphyKeyStatus {
data object Idle : GiphyKeyStatus
data object Testing : GiphyKeyStatus
data object Invalid : GiphyKeyStatus
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onLogout: () -> Unit,
onEmojiPackManagement: () -> Unit = {},
) {
val preferencesManager: PreferencesManager = koinInject()
val userId by preferencesManager.userId.collectAsState(initial = null)
val homeserver by preferencesManager.homeserverUrl.collectAsState(initial = null)
val deviceId by preferencesManager.deviceId.collectAsState(initial = null)
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 context = LocalContext.current
val appVersion = try {
@@ -109,6 +127,91 @@ fun SettingsScreen(
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")
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>