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