From 8c0cbac2461056706e1eb36456fdfd59fcf032eb Mon Sep 17 00:00:00 2001 From: mrfluffy Date: Tue, 3 Mar 2026 15:11:54 +0000 Subject: [PATCH] DMs baseline --- .idea/deviceManager.xml | 13 +++ .../fluffytrix/ui/screens/main/MainScreen.kt | 25 +++++ .../ui/screens/main/MainViewModel.kt | 95 +++++++++++++++++-- .../ui/screens/main/components/MemberList.kt | 3 + .../main/components/MessageTimeline.kt | 88 ++++++++++++++++- 5 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 .idea/deviceManager.xml diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index 9902310..e920ba8 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.ime import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput @@ -28,6 +30,7 @@ import com.example.fluffytrix.ui.screens.main.components.ChannelList import com.example.fluffytrix.ui.screens.main.components.MemberList import com.example.fluffytrix.ui.screens.main.components.MessageTimeline import com.example.fluffytrix.ui.screens.main.components.SpaceList +import com.example.fluffytrix.ui.screens.main.components.UserProfileSheet import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @@ -62,6 +65,23 @@ fun MainScreen( val preferencesManager: PreferencesManager = koinInject() val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false) + data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?) + var profileSheet by remember { mutableStateOf(null) } + + profileSheet?.let { sheet -> + UserProfileSheet( + userId = sheet.userId, + displayName = sheet.displayName, + avatarUrl = sheet.avatarUrl, + currentUserId = currentUserId, + onDismiss = { profileSheet = null }, + onStartDm = { + profileSheet = null + viewModel.startDm(sheet.userId) + }, + ) + } + val keyboardController = LocalSoftwareKeyboardController.current val imeInsets = WindowInsets.ime val density = LocalDensity.current @@ -156,12 +176,17 @@ fun MainScreen( onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) }, onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) }, emojiPacks = emojiPacks, + onStartDm = { userId -> viewModel.startDm(userId) }, + memberNames = remember(members) { members.associate { it.userId to it.displayName } }, ) AnimatedVisibility(visible = showMemberList) { MemberList( members = members, contentPadding = padding, + onMemberClick = { member -> + profileSheet = ProfileSheetState(member.userId, member.displayName, member.avatarUrl) + }, ) } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index f3248ed..f405531 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.EditedContent import org.matrix.rustcomponents.sdk.EventOrTransactionId import org.matrix.rustcomponents.sdk.Membership @@ -32,6 +33,8 @@ import org.matrix.rustcomponents.sdk.MembershipState import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.RoomPreset +import org.matrix.rustcomponents.sdk.RoomVisibility import org.matrix.rustcomponents.sdk.SyncService import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.TimelineConfiguration @@ -522,9 +525,12 @@ class MainViewModel( viewModelScope.launch(Dispatchers.IO) { try { val room = client.getRoom(roomId) - val name = room?.displayName() ?: roomId + val sdkName = room?.displayName() + // Don't overwrite a real seeded name with a generic SDK placeholder + val isGeneric = sdkName == null || sdkName == "Empty Room" || sdkName.startsWith("!") + val name = if (isGeneric) channelNameCache[roomId] ?: sdkName ?: roomId else sdkName channelNameCache[roomId] = name - _channelName.value = name + if (_selectedChannel.value == roomId) _channelName.value = name } catch (_: Exception) { } } } @@ -756,12 +762,12 @@ class MainViewModel( senderAvatar = avatarUrl(profile.avatarUrl) } else -> { - senderName = senderNameCache[localpart] ?: localpart - senderAvatar = senderAvatarCache[localpart] + senderName = senderNameCache[sender] ?: localpart + senderAvatar = senderAvatarCache[sender] } } - senderNameCache[localpart] = senderName - if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar + senderNameCache[sender] = senderName + if (senderAvatar != null) senderAvatarCache[sender] = senderAvatar val reactions = if (content is TimelineItemContent.MsgLike) { content.content.reactions @@ -771,7 +777,7 @@ class MainViewModel( val msg = MessageItem( eventId = eventId, - senderId = localpart, + senderId = sender, senderName = senderName, senderAvatarUrl = senderAvatar, content = msgContent, @@ -934,9 +940,8 @@ class MainViewModel( } memberCache[roomId] = memberList memberList.forEach { m -> - val localpart = m.userId.removePrefix("@").substringBefore(":") - senderAvatarCache[localpart] = avatarUrl(m.avatarUrl) - senderNameCache[localpart] = m.displayName + senderAvatarCache[m.userId] = avatarUrl(m.avatarUrl) + senderNameCache[m.userId] = m.displayName } // Backfill avatars into cached messages messageCache[roomId]?.let { cached -> @@ -1672,6 +1677,76 @@ class MainViewModel( } } + fun startDm(userId: String) { + val client = authRepository.getClient() ?: return + val normalizedUserId = if (userId.startsWith("@")) userId else "@$userId" + android.util.Log.d("MainVM", "startDm called with userId='$userId' normalized='$normalizedUserId'") + viewModelScope.launch(Dispatchers.IO) { + try { + // Reuse existing DM if one already exists + val existingRoom = try { client.getDmRoom(normalizedUserId) } catch (_: Exception) { null } + val roomId: String + if (existingRoom != null) { + roomId = existingRoom.id() + } else { + val newRoomId = client.createRoom( + CreateRoomParameters( + name = null, + topic = null, + isEncrypted = true, + isDirect = true, + visibility = RoomVisibility.Private, + preset = RoomPreset.TRUSTED_PRIVATE_CHAT, + invite = emptyList(), + avatar = null, + powerLevelContentOverride = null, + joinRuleOverride = null, + historyVisibilityOverride = null, + canonicalAlias = null, + isSpace = false, + ) + ) + // Poll until the room appears in the local store, then invite + var room = client.getRoom(newRoomId) + if (room == null) { + repeat(10) { + kotlinx.coroutines.delay(500) + room = client.getRoom(newRoomId) + if (room != null) return@repeat + } + } + if (room != null) { + try { + room!!.inviteUserById(normalizedUserId) + android.util.Log.d("MainVM", "Invited $normalizedUserId to $newRoomId") + } catch (e: Exception) { + android.util.Log.e("MainVM", "inviteUserById failed: ${e.message}", e) + } + } else { + android.util.Log.e("MainVM", "Room $newRoomId not found after waiting, skipping invite") + } + roomId = newRoomId + + // Seed the channel name from the invited user's profile so it + // shows their name immediately rather than "Empty Room" + val displayName = try { + client.getProfile(normalizedUserId).displayName?.takeIf { it.isNotBlank() } + ?: normalizedUserId.removePrefix("@").substringBefore(":") + } catch (_: Exception) { + normalizedUserId.removePrefix("@").substringBefore(":") + } + channelNameCache[roomId] = displayName + } + withContext(kotlinx.coroutines.Dispatchers.Main) { + selectChannel(roomId) + _showChannelList.value = false + } + } catch (e: Exception) { + android.util.Log.e("MainVM", "startDm failed", e) + } + } + } + fun logout() { ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) viewModelScope.launch { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt index 411fb3c..35fdf08 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt @@ -1,6 +1,7 @@ package com.example.fluffytrix.ui.screens.main.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,6 +32,7 @@ import com.example.fluffytrix.ui.screens.main.MemberItem fun MemberList( members: List, contentPadding: PaddingValues = PaddingValues(), + onMemberClick: (MemberItem) -> Unit = {}, ) { Column( modifier = Modifier @@ -56,6 +58,7 @@ fun MemberList( Row( modifier = Modifier .fillMaxWidth() + .clickable { onMemberClick(member) } .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 9c8b146..8dbb167 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -90,8 +90,11 @@ 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.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material.icons.filled.PlayCircleFilled import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -138,6 +141,9 @@ private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} } private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} } private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } } private val LocalCurrentUserId = compositionLocalOf { null } +private val LocalUserProfileHandler = compositionLocalOf<(userId: String, displayName: String, avatarUrl: String?) -> Unit> { { _, _, _ -> } } +// Map of userId -> displayName for resolving reaction sender names +private val LocalMemberNames = compositionLocalOf> { emptyMap() } private val senderColors = arrayOf( Color(0xFF5865F2), @@ -196,10 +202,15 @@ fun MessageTimeline( onSendReaction: (String, String) -> Unit = { _, _ -> }, onSendThreadReaction: (String, String) -> Unit = { _, _ -> }, emojiPacks: List = emptyList(), + onViewProfile: (userId: String, displayName: String, avatarUrl: String?) -> Unit = { _, _, _ -> }, + onStartDm: (String) -> Unit = {}, + memberNames: Map = emptyMap(), ) { var fullscreenImageUrl by remember { mutableStateOf(null) } var fullscreenVideoUrl by remember { mutableStateOf(null) } var contextMenuMessage by remember { mutableStateOf(null) } + data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?) + var profileSheet by remember { mutableStateOf(null) } if (fullscreenImageUrl != null) { FullscreenImageViewer( @@ -242,6 +253,20 @@ fun MessageTimeline( ) } + profileSheet?.let { sheet -> + UserProfileSheet( + userId = sheet.userId, + displayName = sheet.displayName, + avatarUrl = sheet.avatarUrl, + currentUserId = currentUserId, + onDismiss = { profileSheet = null }, + onStartDm = { + profileSheet = null + onStartDm(sheet.userId) + }, + ) + } + val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) { { eventId, emoji -> if (selectedThread != null) onSendThreadReaction(eventId, emoji) @@ -254,6 +279,8 @@ fun MessageTimeline( LocalVideoPlayer provides { url -> fullscreenVideoUrl = url }, LocalReactionHandler provides reactionHandler, LocalCurrentUserId provides currentUserId, + LocalUserProfileHandler provides { userId, displayName, avatarUrl -> profileSheet = ProfileSheetState(userId, displayName, avatarUrl) }, + LocalMemberNames provides memberNames, ) { Column( modifier = modifier @@ -501,6 +528,7 @@ private fun ReactionRow(eventId: String, reactions: Map>) { if (reactions.isEmpty()) return val onReact = LocalReactionHandler.current val currentUserId = LocalCurrentUserId.current + val memberNames = LocalMemberNames.current val authRepository: AuthRepository = koinInject() val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } } var reactionDetailEmoji by remember { mutableStateOf(null) } @@ -523,7 +551,10 @@ private fun ReactionRow(eventId: String, reactions: Map>) { text = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { reactionDetailSenders.forEach { sender -> - Text(sender, style = MaterialTheme.typography.bodyMedium) + Text( + memberNames[sender] ?: sender.removePrefix("@").substringBefore(":"), + style = MaterialTheme.typography.bodyMedium, + ) } } }, @@ -584,6 +615,7 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = { val senderColor = remember(message.senderName) { colorForSender(message.senderName) } val time = remember(message.timestamp) { formatTimestamp(message.timestamp) } val reply = message.replyTo + val onViewProfile = LocalUserProfileHandler.current Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) { if (reply != null) { @@ -594,12 +626,15 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = { AsyncImage( model = message.senderAvatarUrl, contentDescription = null, - modifier = Modifier.size(40.dp).clip(CircleShape), + modifier = Modifier.size(40.dp).clip(CircleShape).clickable { + onViewProfile(message.senderId, message.senderName, message.senderAvatarUrl) + }, contentScale = ContentScale.Crop, ) } else { Box( - modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)), + modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)) + .clickable { onViewProfile(message.senderId, message.senderName, null) }, contentAlignment = Alignment.Center, ) { Text( @@ -1820,3 +1855,50 @@ private fun CustomEmojiGrid( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserProfileSheet( + userId: String, + displayName: String, + avatarUrl: String?, + currentUserId: String?, + onDismiss: () -> Unit, + onStartDm: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = displayName, + modifier = Modifier.size(80.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + val color = remember(displayName) { colorForSender(displayName) } + Box( + modifier = Modifier.size(80.dp).clip(CircleShape).background(color.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center, + ) { + Text(displayName.take(1).uppercase(), fontSize = 32.sp, fontWeight = FontWeight.Bold, color = color) + } + } + Text(displayName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(userId, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (userId != currentUserId) { + Button(onClick = onStartDm, modifier = Modifier.fillMaxWidth()) { + Text("Message") + } + } + } + } +}