This commit is contained in:
2026-02-22 01:26:41 +00:00
parent 42486ac5df
commit 3ca324d34f
13 changed files with 469 additions and 49 deletions

View File

@@ -103,6 +103,8 @@ dependencies {
// Coil (image loading)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.gif)
implementation(libs.coil.video)
// Media3 (video playback)
implementation(libs.media3.exoplayer)

View File

@@ -6,6 +6,8 @@ import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.gif.AnimatedImageDecoder
import coil3.video.VideoFrameDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.di.appModule
@@ -45,6 +47,8 @@ class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
return ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
add(AnimatedImageDecoder.Factory())
add(VideoFrameDecoder.Factory())
}
.memoryCache {
MemoryCache.Builder()

View File

@@ -1,17 +1,19 @@
package com.example.fluffytrix.di
import android.app.Application
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.ui.screens.login.LoginViewModel
import com.example.fluffytrix.ui.screens.main.MainViewModel
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
import org.koin.android.ext.koin.androidApplication
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val appModule = module {
viewModel { MainViewModel(androidApplication(), get(), get()) }
viewModel { LoginViewModel(get()) }
viewModel { VerificationViewModel(get()) }
viewModel { MainViewModel(get(), get()) }
}
val dataModule = module {

View File

@@ -55,6 +55,7 @@ fun MainScreen(
messages = messages,
onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) },
onSendFile = { viewModel.sendFile(it) },
modifier = Modifier.weight(1f),
contentPadding = padding,
)

View File

@@ -1,5 +1,8 @@
package com.example.fluffytrix.ui.screens.main
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -21,7 +24,12 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.folivo.trixnity.client.room
import net.folivo.trixnity.client.room.message.file
import net.folivo.trixnity.client.room.message.image
import net.folivo.trixnity.client.room.message.text
import net.folivo.trixnity.client.room.message.video
import io.ktor.http.ContentType
import kotlinx.coroutines.flow.flowOf
import net.folivo.trixnity.client.store.Room
import net.folivo.trixnity.client.store.isEncrypted
import net.folivo.trixnity.client.user
@@ -32,10 +40,13 @@ import net.folivo.trixnity.core.model.events.m.room.Membership
import net.folivo.trixnity.core.model.events.m.room.RoomMessageEventContent
import net.folivo.trixnity.core.model.events.m.space.ChildEventContent
enum class UnreadStatus { NONE, UNREAD, MENTIONED }
data class SpaceItem(
val id: RoomId,
val name: String,
val avatarUrl: String?,
val unreadStatus: UnreadStatus = UnreadStatus.NONE,
)
data class ChannelItem(
@@ -43,16 +54,19 @@ data class ChannelItem(
val name: String,
val isEncrypted: Boolean,
val avatarUrl: String? = null,
val unreadStatus: UnreadStatus = UnreadStatus.NONE,
)
sealed interface MessageContent {
data class Text(val body: String, val urls: List<String> = 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 File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent
}
private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""")
private val gifUrlRegex = Regex("""https?://(tenor\.com|giphy\.com|gfycat\.com|media\d*\.giphy\.com)/""", RegexOption.IGNORE_CASE)
@Immutable
data class MessageItem(
@@ -71,6 +85,7 @@ data class MemberItem(
)
class MainViewModel(
private val application: Application,
private val authRepository: AuthRepository,
private val preferencesManager: PreferencesManager,
) : ViewModel() {
@@ -110,6 +125,9 @@ class MainViewModel(
private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList())
private val _spaceChildren = MutableStateFlow<Set<RoomId>?>(null)
private val _roomUnreadStatus = MutableStateFlow<Map<RoomId, UnreadStatus>>(emptyMap())
private val _spaceChildrenMap = MutableStateFlow<Map<RoomId, Set<RoomId>>>(emptyMap())
// Per-room caches
private val messageCache = mutableMapOf<RoomId, MutableList<MessageItem>>()
private val messageIds = mutableMapOf<RoomId, MutableSet<String>>()
@@ -178,31 +196,49 @@ class MainViewModel(
cachedRoomData = allResolved
val joinedRooms = allResolved.values.filter { it.membership == Membership.JOIN }
// Derive per-room unread status from server-provided counts
val syncUnread = mutableMapOf<RoomId, UnreadStatus>()
for (room in joinedRooms) {
if (room.roomId == _selectedChannel.value) continue
val count = room.unreadMessageCount ?: 0
if (count > 0) syncUnread[room.roomId] = UnreadStatus.UNREAD
}
_roomUnreadStatus.value = syncUnread
val allSpaces = joinedRooms
.filter { it.createEventContent?.type is RoomType.Space }
// Collect child space IDs so we only show top-level spaces
val childSpaceIds = mutableSetOf<RoomId>()
val allSpaceIds = allSpaces.map { it.roomId }.toSet()
val spaceChildMap = mutableMapOf<RoomId, Set<RoomId>>()
coroutineScope {
allSpaces.map { space ->
async {
try {
val children = client.room.getAllState(space.roomId, ChildEventContent::class)
.firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList()
children.filter { it in allSpaceIds }
} catch (_: Exception) { emptyList() }
space.roomId to children
} catch (_: Exception) { space.roomId to emptyList() }
}
}.awaitAll().forEach { childSpaceIds.addAll(it) }
}.awaitAll().forEach { (spaceId, children) ->
spaceChildMap[spaceId] = children.toSet()
childSpaceIds.addAll(children.filter { it in allSpaceIds })
}
}
_spaceChildrenMap.value = spaceChildMap
_spaces.value = allSpaces
.filter { it.roomId !in childSpaceIds }
.map { room ->
val childRooms = spaceChildMap[room.roomId] ?: emptySet()
val spaceUnread = childRooms.mapNotNull { syncUnread[it] }
.maxByOrNull { it.ordinal } ?: UnreadStatus.NONE
SpaceItem(
id = room.roomId,
name = room.name?.explicitName ?: room.roomId.full,
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96),
unreadStatus = spaceUnread,
)
}
@@ -223,14 +259,23 @@ class MainViewModel(
private fun observeSpaceFiltering() {
viewModelScope.launch {
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap) { allChannels, children, spaceId, orderMap ->
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap, _roomUnreadStatus) { args ->
val allChannels = args[0] as List<ChannelItem>
val children = args[1] as Set<RoomId>?
val spaceId = args[2] as RoomId?
val orderMap = args[3] as Map<String, List<String>>
val unreadMap = args[4] as Map<RoomId, UnreadStatus>
val filtered = if (children == null) allChannels
else allChannels.filter { it.id in children }
val withUnread = filtered.map { ch ->
val status = unreadMap[ch.id] ?: UnreadStatus.NONE
if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch
}
val savedOrder = spaceId?.let { orderMap[it.full] }
if (savedOrder != null) {
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
filtered.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE }
} else filtered
withUnread.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE }
} else withUnread
}.collect { _channels.value = it }
}
}
@@ -361,19 +406,29 @@ class MainViewModel(
private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? {
return when (content) {
is RoomMessageEventContent.FileBased.Image -> MessageContent.Image(
body = content.body,
url = mxcToDownloadUrl(baseUrl, content.url) ?: return null,
width = content.info?.width?.toInt(),
height = content.info?.height?.toInt(),
)
is RoomMessageEventContent.FileBased.Video -> MessageContent.Video(
body = content.body,
url = mxcToDownloadUrl(baseUrl, content.url),
thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300),
width = content.info?.width?.toInt(),
height = content.info?.height?.toInt(),
)
is RoomMessageEventContent.FileBased.Image -> {
val isGif = content.info?.mimeType == "image/gif"
val url = mxcToDownloadUrl(baseUrl, content.url) ?: return null
if (isGif) MessageContent.Gif(
body = content.body, url = url,
width = content.info?.width?.toInt(), height = content.info?.height?.toInt(),
) else MessageContent.Image(
body = content.body, url = url,
width = content.info?.width?.toInt(), height = content.info?.height?.toInt(),
)
}
is RoomMessageEventContent.FileBased.Video -> {
val isGif = content.info?.mimeType == "image/gif" || gifUrlRegex.containsMatchIn(content.body)
val url = mxcToDownloadUrl(baseUrl, content.url)
if (isGif && url != null) MessageContent.Gif(
body = content.body, url = url,
width = content.info?.width?.toInt(), height = content.info?.height?.toInt(),
) else MessageContent.Video(
body = content.body, url = url,
thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300) ?: url,
width = content.info?.width?.toInt(), height = content.info?.height?.toInt(),
)
}
is RoomMessageEventContent.FileBased.Audio -> MessageContent.File(
body = content.body,
fileName = content.fileName ?: content.body,
@@ -490,6 +545,53 @@ class MainViewModel(
}
}
fun sendFile(uri: Uri) {
val roomId = _selectedChannel.value ?: return
val client = authRepository.getClient() ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
val contentResolver = application.contentResolver
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) cursor.getString(idx) else null
} else null
} ?: "file"
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@launch
val byteArrayFlow = flowOf(bytes)
val contentType = ContentType.parse(mimeType)
val size = bytes.size.toLong()
client.room.sendMessage(roomId) {
when {
mimeType.startsWith("image/") -> image(
body = fileName,
image = byteArrayFlow,
type = contentType,
size = size,
fileName = fileName,
)
mimeType.startsWith("video/") -> video(
body = fileName,
video = byteArrayFlow,
type = contentType,
size = size,
fileName = fileName,
)
else -> file(
body = fileName,
file = byteArrayFlow,
type = contentType,
size = size,
fileName = fileName,
)
}
}
} catch (_: Exception) { }
}
}
fun selectSpace(spaceId: RoomId) {
if (_selectedSpace.value == spaceId) {
_showChannelList.value = !_showChannelList.value
@@ -502,6 +604,23 @@ class MainViewModel(
fun selectChannel(channelId: RoomId) {
_selectedChannel.value = channelId
// Clear unread status for the opened room
if (_roomUnreadStatus.value.containsKey(channelId)) {
_roomUnreadStatus.value = _roomUnreadStatus.value - channelId
updateSpaceUnreadStatus()
}
}
private fun updateSpaceUnreadStatus() {
val unreadMap = _roomUnreadStatus.value
val childrenMap = _spaceChildrenMap.value
val baseUrl = authRepository.getBaseUrl() ?: return
_spaces.value = _spaces.value.map { space ->
val childRooms = childrenMap[space.id] ?: emptySet()
val spaceUnread = childRooms.mapNotNull { unreadMap[it] }
.maxByOrNull { it.ordinal } ?: UnreadStatus.NONE
if (spaceUnread != space.unreadStatus) space.copy(unreadStatus = spaceUnread) else space
}
}
fun toggleChannelList() {

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -50,6 +51,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.example.fluffytrix.ui.screens.main.ChannelItem
import com.example.fluffytrix.ui.screens.main.UnreadStatus
import net.folivo.trixnity.core.model.RoomId
import kotlin.math.roundToInt
@@ -200,6 +202,20 @@ fun ChannelList(
)
Spacer(modifier = Modifier.width(4.dp))
}
if (channel.unreadStatus != UnreadStatus.NONE) {
Box(
modifier = Modifier
.size(8.dp)
.background(
if (channel.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red
else androidx.compose.ui.graphics.Color.Gray,
androidx.compose.foundation.shape.CircleShape,
),
)
Spacer(modifier = Modifier.width(4.dp))
} else {
Spacer(modifier = Modifier.width(12.dp))
}
Icon(
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
contentDescription = null,

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -43,6 +44,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import com.mikepenz.markdown.m3.Markdown
@@ -116,6 +119,7 @@ fun MessageTimeline(
messages: List<MessageItem>,
onToggleMemberList: () -> Unit,
onSendMessage: (String) -> Unit,
onSendFile: (Uri) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
@@ -229,7 +233,7 @@ fun MessageTimeline(
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
MessageInput(channelName ?: "message", onSendMessage)
MessageInput(channelName ?: "message", onSendMessage, onSendFile)
}
}
}
@@ -309,6 +313,7 @@ private fun MessageContentView(content: MessageContent) {
when (content) {
is MessageContent.Text -> TextContent(content)
is MessageContent.Image -> ImageContent(content)
is MessageContent.Gif -> GifContent(content)
is MessageContent.Video -> VideoContent(content)
is MessageContent.File -> FileContent(content)
}
@@ -346,6 +351,51 @@ private fun ImageContent(content: MessageContent.Image) {
)
}
@Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun GifContent(content: MessageContent.Gif) {
val context = LocalContext.current
val authRepository: AuthRepository = koinInject()
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
content.width.toFloat() / content.height.toFloat() else 16f / 9f
val exoPlayer = remember(content.url) {
val token = authRepository.getAccessToken()
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) {
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
}
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(content.url)))
ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = true
repeatMode = ExoPlayer.REPEAT_MODE_ALL
volume = 0f
}
}
DisposableEffect(content.url) {
onDispose { exoPlayer.release() }
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = false
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
}
},
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
@@ -536,19 +586,30 @@ private fun formatFileSize(bytes: Long): String {
}
@Composable
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit) {
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, onSendFile: (Uri) -> Unit) {
var text by remember { mutableStateOf("") }
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) onSendFile(uri)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = { filePickerLauncher.launch("*/*") }) {
Icon(
Icons.Default.AttachFile, "Attach file",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)),
singleLine = true,
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp),
maxLines = 8,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.example.fluffytrix.ui.screens.main.SpaceItem
import com.example.fluffytrix.ui.screens.main.UnreadStatus
import net.folivo.trixnity.core.model.RoomId
@Composable
@@ -89,32 +90,46 @@ fun SpaceList(
items(spaces, key = { it.id.full }) { space ->
val isSelected = space.id == selectedSpace
Box(
modifier = Modifier
.size(48.dp)
.clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
)
.clickable { onSpaceClick(space.id) },
contentAlignment = Alignment.Center,
) {
if (space.avatarUrl != null) {
var imageError by remember { mutableStateOf(false) }
if (!imageError) {
AsyncImage(
model = space.avatarUrl,
contentDescription = space.name,
modifier = Modifier.size(48.dp).clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape),
contentScale = ContentScale.Crop,
onError = { imageError = true },
Box(modifier = Modifier.size(48.dp)) {
Box(
modifier = Modifier
.size(48.dp)
.clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
)
.clickable { onSpaceClick(space.id) },
contentAlignment = Alignment.Center,
) {
if (space.avatarUrl != null) {
var imageError by remember { mutableStateOf(false) }
if (!imageError) {
AsyncImage(
model = space.avatarUrl,
contentDescription = space.name,
modifier = Modifier.size(48.dp).clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape),
contentScale = ContentScale.Crop,
onError = { imageError = true },
)
} else {
SpaceInitial(space.name, isSelected)
}
} else {
SpaceInitial(space.name, isSelected)
}
} else {
SpaceInitial(space.name, isSelected)
}
if (space.unreadStatus != UnreadStatus.NONE) {
Box(
modifier = Modifier
.size(8.dp)
.align(Alignment.TopEnd)
.background(
if (space.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red
else androidx.compose.ui.graphics.Color.Gray,
CircleShape,
),
)
}
}
}