DMs baseline

This commit is contained in:
2026-03-03 15:11:54 +00:00
parent 82890d85ba
commit 8c0cbac246
5 changed files with 211 additions and 13 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@@ -15,7 +15,9 @@ 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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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
@@ -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.MemberList
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline 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.SpaceList
import com.example.fluffytrix.ui.screens.main.components.UserProfileSheet
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -62,6 +65,23 @@ 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)
data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?)
var profileSheet by remember { mutableStateOf<ProfileSheetState?>(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 keyboardController = LocalSoftwareKeyboardController.current
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
val density = LocalDensity.current val density = LocalDensity.current
@@ -156,12 +176,17 @@ 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,
onStartDm = { userId -> viewModel.startDm(userId) },
memberNames = remember(members) { members.associate { it.userId to it.displayName } },
) )
AnimatedVisibility(visible = showMemberList) { AnimatedVisibility(visible = showMemberList) {
MemberList( MemberList(
members = members, members = members,
contentPadding = padding, contentPadding = padding,
onMemberClick = { member ->
profileSheet = ProfileSheetState(member.userId, member.displayName, member.avatarUrl)
},
) )
} }
} }

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.EditedContent import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.EventOrTransactionId import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.Membership 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.MessageType
import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.ProfileDetails 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.SyncService
import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.TimelineConfiguration import org.matrix.rustcomponents.sdk.TimelineConfiguration
@@ -522,9 +525,12 @@ class MainViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val room = client.getRoom(roomId) 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 channelNameCache[roomId] = name
_channelName.value = name if (_selectedChannel.value == roomId) _channelName.value = name
} catch (_: Exception) { } } catch (_: Exception) { }
} }
} }
@@ -756,12 +762,12 @@ class MainViewModel(
senderAvatar = avatarUrl(profile.avatarUrl) senderAvatar = avatarUrl(profile.avatarUrl)
} }
else -> { else -> {
senderName = senderNameCache[localpart] ?: localpart senderName = senderNameCache[sender] ?: localpart
senderAvatar = senderAvatarCache[localpart] senderAvatar = senderAvatarCache[sender]
} }
} }
senderNameCache[localpart] = senderName senderNameCache[sender] = senderName
if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar if (senderAvatar != null) senderAvatarCache[sender] = senderAvatar
val reactions = if (content is TimelineItemContent.MsgLike) { val reactions = if (content is TimelineItemContent.MsgLike) {
content.content.reactions content.content.reactions
@@ -771,7 +777,7 @@ class MainViewModel(
val msg = MessageItem( val msg = MessageItem(
eventId = eventId, eventId = eventId,
senderId = localpart, senderId = sender,
senderName = senderName, senderName = senderName,
senderAvatarUrl = senderAvatar, senderAvatarUrl = senderAvatar,
content = msgContent, content = msgContent,
@@ -934,9 +940,8 @@ class MainViewModel(
} }
memberCache[roomId] = memberList memberCache[roomId] = memberList
memberList.forEach { m -> memberList.forEach { m ->
val localpart = m.userId.removePrefix("@").substringBefore(":") senderAvatarCache[m.userId] = avatarUrl(m.avatarUrl)
senderAvatarCache[localpart] = avatarUrl(m.avatarUrl) senderNameCache[m.userId] = m.displayName
senderNameCache[localpart] = m.displayName
} }
// Backfill avatars into cached messages // Backfill avatars into cached messages
messageCache[roomId]?.let { cached -> 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() { fun logout() {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
viewModelScope.launch { viewModelScope.launch {

View File

@@ -1,6 +1,7 @@
package com.example.fluffytrix.ui.screens.main.components package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -31,6 +32,7 @@ import com.example.fluffytrix.ui.screens.main.MemberItem
fun MemberList( fun MemberList(
members: List<MemberItem>, members: List<MemberItem>,
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues(),
onMemberClick: (MemberItem) -> Unit = {},
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -56,6 +58,7 @@ fun MemberList(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onMemberClick(member) }
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),

View File

@@ -90,8 +90,11 @@ import androidx.compose.runtime.mutableStateMapOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.material.icons.automirrored.filled.Reply 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.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.filled.PlayCircleFilled import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -138,6 +141,9 @@ private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} } private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } } private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } }
private val LocalCurrentUserId = compositionLocalOf<String?> { null } private val LocalCurrentUserId = compositionLocalOf<String?> { 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<Map<String, String>> { emptyMap() }
private val senderColors = arrayOf( private val senderColors = arrayOf(
Color(0xFF5865F2), Color(0xFF5865F2),
@@ -196,10 +202,15 @@ 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(),
onViewProfile: (userId: String, displayName: String, avatarUrl: String?) -> Unit = { _, _, _ -> },
onStartDm: (String) -> Unit = {},
memberNames: Map<String, String> = emptyMap(),
) { ) {
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) }
var contextMenuMessage by remember { mutableStateOf<MessageItem?>(null) } var contextMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?)
var profileSheet by remember { mutableStateOf<ProfileSheetState?>(null) }
if (fullscreenImageUrl != null) { if (fullscreenImageUrl != null) {
FullscreenImageViewer( 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) { val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) {
{ eventId, emoji -> { eventId, emoji ->
if (selectedThread != null) onSendThreadReaction(eventId, emoji) if (selectedThread != null) onSendThreadReaction(eventId, emoji)
@@ -254,6 +279,8 @@ fun MessageTimeline(
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url }, LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
LocalReactionHandler provides reactionHandler, LocalReactionHandler provides reactionHandler,
LocalCurrentUserId provides currentUserId, LocalCurrentUserId provides currentUserId,
LocalUserProfileHandler provides { userId, displayName, avatarUrl -> profileSheet = ProfileSheetState(userId, displayName, avatarUrl) },
LocalMemberNames provides memberNames,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@@ -501,6 +528,7 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
if (reactions.isEmpty()) return if (reactions.isEmpty()) return
val onReact = LocalReactionHandler.current val onReact = LocalReactionHandler.current
val currentUserId = LocalCurrentUserId.current val currentUserId = LocalCurrentUserId.current
val memberNames = LocalMemberNames.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 reactionDetailEmoji by remember { mutableStateOf<String?>(null) }
@@ -523,7 +551,10 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
reactionDetailSenders.forEach { sender -> 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 senderColor = remember(message.senderName) { colorForSender(message.senderName) }
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) } val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
val reply = message.replyTo val reply = message.replyTo
val onViewProfile = LocalUserProfileHandler.current
Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) { Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) {
if (reply != null) { if (reply != null) {
@@ -594,12 +626,15 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {
AsyncImage( AsyncImage(
model = message.senderAvatarUrl, model = message.senderAvatarUrl,
contentDescription = null, 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, contentScale = ContentScale.Crop,
) )
} else { } else {
Box( 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, contentAlignment = Alignment.Center,
) { ) {
Text( 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")
}
}
}
}
}