DMs baseline
This commit is contained in:
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal 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>
|
||||
@@ -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<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 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<MemberItem>,
|
||||
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),
|
||||
|
||||
@@ -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<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(
|
||||
Color(0xFF5865F2),
|
||||
@@ -196,10 +202,15 @@ fun MessageTimeline(
|
||||
onSendReaction: (String, String) -> Unit = { _, _ -> },
|
||||
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
|
||||
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 fullscreenVideoUrl by remember { mutableStateOf<String?>(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) {
|
||||
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<String, List<String>>) {
|
||||
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<String?>(null) }
|
||||
@@ -523,7 +551,10 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user