gif and better reactions
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = {
|
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())
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,46 +856,95 @@ 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" ||
|
||||||
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
content.body.endsWith(".gif", ignoreCase = true) ||
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
content.url.endsWith(".gif", ignoreCase = true)
|
||||||
if (token != null) {
|
|
||||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
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)
|
||||||
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
|
} else {
|
||||||
.createMediaSource(MediaItem.fromUri(Uri.parse(content.url)))
|
content.url
|
||||||
ExoPlayer.Builder(context).build().apply {
|
|
||||||
setMediaSource(mediaSource)
|
|
||||||
prepare()
|
|
||||||
playWhenReady = true
|
|
||||||
repeatMode = ExoPlayer.REPEAT_MODE_ALL
|
|
||||||
volume = 0f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(content.url) {
|
if (isNativeGif) {
|
||||||
onDispose { exoPlayer.release() }
|
// Real GIF — use Coil (coil-gif handles animated playback)
|
||||||
}
|
AsyncImage(
|
||||||
|
model = resolvedUrl,
|
||||||
AndroidView(
|
contentDescription = content.body,
|
||||||
factory = { ctx ->
|
contentScale = ContentScale.Fit,
|
||||||
PlayerView(ctx).apply {
|
modifier = Modifier
|
||||||
player = exoPlayer
|
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
|
||||||
useController = false
|
.height(200.dp)
|
||||||
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
|
.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)
|
||||||
}
|
}
|
||||||
},
|
ExoPlayer.Builder(context).build().apply {
|
||||||
modifier = Modifier
|
setMediaSource(mediaSource)
|
||||||
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
|
prepare()
|
||||||
.height(200.dp)
|
playWhenReady = true
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
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
|
@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 token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
val mediaItem = MediaItem.fromUri(Uri.parse(url))
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
val mediaSource = if (url.startsWith("file://")) {
|
||||||
if (token != null) {
|
ProgressiveMediaSource.Factory(androidx.media3.datasource.FileDataSource.Factory())
|
||||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
.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 {
|
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,27 +1208,36 @@ 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(
|
||||||
val currentText by rememberUpdatedState(text)
|
apiKey = gifApiKey,
|
||||||
AndroidView(
|
onGifSelected = { gif ->
|
||||||
factory = { ctx -> EmojiPickerView(ctx) },
|
showEmojiPackPicker = false
|
||||||
update = { view ->
|
onSendGif(gif.fullUrl)
|
||||||
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
|
||||||
text = currentText + emojiViewItem.emoji
|
|
||||||
showEmojiPackPicker = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
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,
|
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,44 +1443,28 @@ 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 = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "Unicode") else listOf("Unicode")
|
||||||
val tabs = listOf("Unicode") + emojiPacks.map { it.displayName }
|
val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1
|
||||||
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
||||||
tabs.forEachIndexed { index, title ->
|
tabs.forEachIndexed { index, title ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTab == index,
|
selected = selectedTab == index,
|
||||||
onClick = { selectedTab = index },
|
onClick = { selectedTab = index },
|
||||||
text = { Text(title, maxLines = 1) },
|
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),
|
|
||||||
)
|
)
|
||||||
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))
|
when {
|
||||||
AndroidView(
|
selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
|
||||||
|
packs = emojiPacks,
|
||||||
|
onEmojiSelected = { entry ->
|
||||||
|
currentOnReact(entry.mxcUrl)
|
||||||
|
showEmojiPicker = false
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
)
|
||||||
|
else -> 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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