gif and better reactions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,15 +856,45 @@ 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 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 dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||
if (token != null) {
|
||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
||||
if (token != null) 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 {
|
||||
setMediaSource(mediaSource)
|
||||
prepare()
|
||||
@@ -799,28 +904,47 @@ private fun GifContent(content: MessageContent.Gif) {
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(content.url) {
|
||||
onDispose { exoPlayer.release() }
|
||||
DisposableEffect(resolvedUrl) {
|
||||
onDispose { exoPlayer?.release() }
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
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 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"))
|
||||
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,16 +1208,24 @@ 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 {
|
||||
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) },
|
||||
@@ -1100,6 +1242,7 @@ private fun MessageInput(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete strip: show matching emojis when text ends with :partialword (no space, no closing colon)
|
||||
val autocompleteResults = remember(text, emojiPacks) {
|
||||
@@ -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,9 +1443,9 @@ private fun MessageContextMenu(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (emojiPacks.isNotEmpty()) {
|
||||
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) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
@@ -1313,31 +1456,15 @@ private fun MessageContextMenu(
|
||||
}
|
||||
}
|
||||
when {
|
||||
selectedTab == 0 -> AndroidView(
|
||||
factory = { ctx -> EmojiPickerView(ctx) },
|
||||
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,
|
||||
selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
|
||||
packs = emojiPacks,
|
||||
onEmojiSelected = { entry ->
|
||||
currentOnReact(entry.mxcUrl)
|
||||
showEmojiPicker = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
AndroidView(
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="cache" path="." />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user