From ed92ab807df3cb790666017af0d283864ee1f136 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 3 Feb 2026 17:35:56 +0300 Subject: [PATCH] Rewrite using ChatPanel Signed-off-by: denis0001-dev --- .../ru/fromchat/ui/debug/DebugApiScreen.kt | 34 ++- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 3 +- .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 12 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 61 ++-- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 24 +- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 157 ++++++++++ .../kotlin/ru/fromchat/ui/dm/DmScreen.kt | 267 +----------------- 7 files changed, 265 insertions(+), 293 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt index 365f937..ebb9ecd 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt @@ -6,12 +6,26 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.BugReport -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.pr0gramm3r101.utils.settings.settings @@ -42,7 +56,7 @@ actual fun DebugApiScreen() { TopAppBar( navigationIcon = { IconButton(onClick = { navController.navigateUp() }) { - Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = null) + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } }, title = { @@ -155,7 +169,11 @@ actual fun DebugApiScreen() { modifier = Modifier.fillMaxWidth() ) - Divider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) Text( text = "DM Send Test", @@ -210,7 +228,11 @@ actual fun DebugApiScreen() { Text(text = "Send DM") } - Divider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) Text( text = "DM Decryption Test", diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 2175d05..dac2e17 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -188,8 +188,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { composable("dm/{otherUserId}") { backStackEntry -> val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0 DmScreen( - otherUserId = otherUserId, - onBack = { navController.navigateUp() } + otherUserId = otherUserId ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index cb17819..c2c4d8d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -23,7 +23,14 @@ data class ChatPanelState( val isLoading: Boolean = false, val hasMoreMessages: Boolean = false, val isLoadingMore: Boolean = false, - val typingUsers: List = emptyList() + val typingUsers: List = emptyList(), + val titleAvatar: AvatarInfo? = null +) + +@Serializable +data class AvatarInfo( + val displayName: String, + val profilePictureUrl: String? = null ) /** @@ -308,5 +315,8 @@ abstract class ChatPanel( // Abstract UI control methods abstract fun showCallButton(): Boolean abstract fun getTypingHandler(): TypingHandler + + open val showUsernamesInMessages: Boolean + get() = true } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index d64028c..cc1d7ad 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -67,6 +69,7 @@ import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.back import ru.fromchat.core.Logger import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.chat.Avatar @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable @@ -155,14 +158,16 @@ fun ChatScreen( val updatesMessage = json.decodeFromJsonElement(data) Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates") // Process each update in the batch - updatesMessage.updates.forEach { update -> + updatesMessage.updates.forEach { update -> Logger.d("ChatScreen", "Processing update: type=${update.type}, data=${update.data != null}") val wsMessage = WebSocketMessage( type = update.type, data = update.data ) when (update.type) { - "newMessage", "messageEdited", "messageDeleted", "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> { + "newMessage", "messageEdited", "messageDeleted", + "dmNew", "dmEdited", "dmDeleted", + "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> { Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}") scope.launch { try { @@ -179,7 +184,7 @@ fun ChatScreen( e.printStackTrace() } } - "newMessage", "messageEdited", "messageDeleted" -> { + "newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted" -> { scope.launch { panel.handleWebSocketMessage(message) } @@ -205,24 +210,37 @@ fun ChatScreen( topBar = { TopAppBar( title = { - Column(Modifier.fillMaxWidth()) { - Text( - text = panelState.title, - style = MaterialTheme.typography.titleLarge - ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + panelState.titleAvatar?.let { avatar -> + Avatar( + profilePictureUrl = avatar.profilePictureUrl, + displayName = avatar.displayName, + size = 36.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + } - AnimatedContent( - targetState = currentTypingUsers.isNotEmpty(), - transitionSpec = { - fadeIn() togetherWith fadeOut() - }, - label = "typing_status" - ) { hasTyping -> - if (hasTyping) { - TypingIndicator( - typingUsers = currentTypingUsers.map { it.username }, - modifier = Modifier.padding(top = 2.dp) - ) + Column(Modifier.fillMaxWidth()) { + Text( + text = panelState.title, + style = MaterialTheme.typography.titleLarge + ) + + AnimatedContent( + targetState = currentTypingUsers.isNotEmpty(), + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "typing_status" + ) { hasTyping -> + if (hasTyping) { + TypingIndicator( + typingUsers = currentTypingUsers.map { it.username }, + modifier = Modifier.padding(top = 2.dp) + ) + } } } } @@ -360,7 +378,8 @@ fun ChatScreen( }, onTapPosition = { offset -> tapOffset = offset - } + }, + showUsername = panel.showUsernamesInMessages ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 01d9e5b..cb6a45e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -49,7 +49,8 @@ fun MessageItem( isAuthor: Boolean, onLongPress: () -> Unit, onTapPosition: (Offset) -> Unit = {}, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showUsername: Boolean = true ) { AnimatedVisibility( visible = true, @@ -78,8 +79,7 @@ fun MessageItem( horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom ) { - if (!isAuthor) { - // Avatar at bottom + if (!isAuthor && showUsername) { Avatar( profilePictureUrl = message.profile_picture, displayName = message.username, @@ -132,7 +132,7 @@ fun MessageItem( ) { Column { // Username inside bubble (for received messages) - if (!isAuthor) { + if (showUsername && !isAuthor) { Text( text = message.username, style = MaterialTheme.typography.labelMedium, @@ -172,13 +172,15 @@ fun MessageItem( Column( Modifier.padding(horizontal = 8.dp, vertical = 6.dp) ) { - Text( - text = replyTo.username, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - fontSize = 11.sp - ) + if (showUsername) { + Text( + text = replyTo.username, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + fontSize = 11.sp + ) + } Text( text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "", style = MaterialTheme.typography.bodySmall, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt new file mode 100644 index 0000000..d54760f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -0,0 +1,157 @@ +package ru.fromchat.ui.dm + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import ru.fromchat.api.ApiClient +import ru.fromchat.api.DmEnvelope +import ru.fromchat.api.Message +import ru.fromchat.api.WebSocketMessage +import ru.fromchat.core.Logger +import ru.fromchat.crypto.decryptEnvelope +import ru.fromchat.ui.chat.ChatPanel +import ru.fromchat.ui.chat.TypingHandler +import ru.fromchat.ui.chat.TypingUser +import ru.fromchat.ui.chat.AvatarInfo + +class DmPanel( + private val otherUserId: Int, + coroutineScope: CoroutineScope, + currentUserId: Int? +) : ChatPanel( + id = "dm-$otherUserId", + currentUserId = currentUserId, + scope = coroutineScope +) { + private val typingHandler = NoopTypingHandler() + private val json = Json { ignoreUnknownKeys = true } + private var otherDisplayName: String = "User $otherUserId" + private var otherProfilePicture: String? = null + + init { + updateState { it.copy(title = "Direct message") } + coroutineScope.launch(Dispatchers.IO) { + runCatching { + ApiClient.getProfileById(otherUserId) + }.onSuccess { profile -> + val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username + otherDisplayName = displayName + otherProfilePicture = profile.profilePicture + updateState { + it.copy( + title = displayName, + titleAvatar = AvatarInfo( + displayName = displayName, + profilePictureUrl = otherProfilePicture + ) + ) + } + } + } + } + + override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { + ApiClient.sendDm(recipientId = otherUserId, plaintext = content) + } + + override suspend fun loadMessages() { + setLoading(true) + runCatching { + ApiClient.getDmHistory(otherUserId) + }.onSuccess { response -> + clearMessages() + response.messages.mapNotNull { envelope -> + decryptEnvelope(envelope, currentUserId)?.let { plaintext -> + createMessage(envelope, plaintext) + } + }.forEach { addMessage(it) } + setHasMoreMessages(false) + }.onFailure { error -> + Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error) + } + setLoading(false) + } + + override suspend fun loadMoreMessages() { + // Not implemented yet + } + + override suspend fun handleWebSocketMessage(message: WebSocketMessage) { + when (message.type) { + "dmNew" -> message.data?.let { processEnvelope(it) } + "updates" -> { + val updates = message.data?.jsonObject?.get("updates")?.jsonArray ?: return + for (update in updates) { + val obj = update.jsonObject + if (obj["type"]?.jsonPrimitive?.content == "dmNew") { + obj["data"]?.let { processEnvelope(it) } + } + } + } + } + } + + private fun processEnvelope(element: JsonElement) { + val envelope = runCatching { + json.decodeFromJsonElement(DmEnvelope.serializer(), element) + }.getOrNull() ?: return + if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return + scope.launch(Dispatchers.IO) { + val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + if (plaintext != null) { + addMessage(createMessage(envelope, plaintext)) + } + } + } + + private fun createMessage(envelope: DmEnvelope, plaintext: String): Message { + val username = if (envelope.senderId == currentUserId) { + "You" + } else { + otherDisplayName + } + return Message( + id = envelope.id, + user_id = envelope.senderId, + content = plaintext, + timestamp = envelope.timestamp, + is_read = envelope.recipientId == currentUserId, + is_edited = false, + username = username, + profile_picture = null, + verified = null, + reply_to = null, + client_message_id = null, + reactions = null + ) + } + + override suspend fun handleEditMessage(messageId: Int, content: String) {} + + override suspend fun handleDeleteMessage(messageId: Int) {} + + override fun showCallButton(): Boolean = false + + override fun getTypingHandler(): TypingHandler = typingHandler + + override val showUsernamesInMessages: Boolean + get() = false +} + +private class NoopTypingHandler : TypingHandler { + private val _typingUsers = MutableStateFlow>(emptyList()) + override val typingUsers: StateFlow> = _typingUsers.asStateFlow() + override fun sendTyping() {} + override fun stopTyping() {} + override fun handleTypingEvent(userId: Int, username: String) {} + override fun handleStopTypingEvent(userId: Int) {} +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt index befd114..016fbe2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt @@ -1,276 +1,39 @@ package ru.fromchat.ui.dm -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.serializer import ru.fromchat.api.ApiClient -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.WebSocketManager -import ru.fromchat.api.WebSocketMessage -import ru.fromchat.crypto.decryptEnvelope +import ru.fromchat.ui.chat.ChatScreen -private data class DecryptedDmMessage( - val id: Int, - val text: String, - val timestamp: String, - val isOutgoing: Boolean -) - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DmScreen( otherUserId: Int, - onBack: () -> Unit + modifier: Modifier = Modifier ) { val coroutineScope = rememberCoroutineScope() - val messages = remember { mutableStateListOf() } - var isLoading by remember { mutableStateOf(false) } - var status by remember { mutableStateOf(null) } - var input by remember { mutableStateOf("") } - val currentUserId = ApiClient.user?.id - val json = Json { ignoreUnknownKeys = true } - - suspend fun loadHistory() { - isLoading = true - runCatching { - ApiClient.getDmHistory(otherUserId) - }.onSuccess { response -> - val decrypted = response.messages.mapNotNull { envelope -> - val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() - plaintext?.let { - DecryptedDmMessage( - id = envelope.id, - text = it, - timestamp = envelope.timestamp, - isOutgoing = currentUserId != null && envelope.senderId == currentUserId - ) - } - } - messages.clear() - messages.addAll(decrypted) - status = null - }.onFailure { - status = it.message ?: "Failed to load DMs" - } - isLoading = false + val panel = remember(otherUserId) { + DmPanel( + otherUserId = otherUserId, + coroutineScope = coroutineScope, + currentUserId = currentUserId + ) } LaunchedEffect(otherUserId) { - loadHistory() - } - - DisposableEffect(otherUserId) { - val handler: (WebSocketMessage) -> Unit = handler@ { msg -> - val payloads = extractDmPayloads(msg) - for (payload in payloads) { - val envelope = runCatching { - json.decodeFromJsonElement(DmEnvelope.serializer(), payload) - }.getOrNull() ?: continue - if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) continue - coroutineScope.launch { - val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() - if (plaintext == null) return@launch - val newMessage = DecryptedDmMessage( - id = envelope.id, - text = plaintext, - timestamp = envelope.timestamp, - isOutgoing = currentUserId != null && envelope.senderId == currentUserId - ) - if (messages.none { it.id == newMessage.id }) { - messages.add(newMessage) - } - } - } - } - WebSocketManager.addGlobalMessageHandler(handler) - onDispose { - WebSocketManager.removeGlobalMessageHandler(handler) + coroutineScope.launch { + panel.loadMessages() } } - Scaffold( - topBar = { - TopAppBar( - title = { Text(text = "DM with user $otherUserId") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Back") - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - LazyColumn( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (messages.isEmpty()) { - item { - Text( - text = "No messages yet.", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - } - } else { - items(messages, key = { it.id }) { message -> - MessageBubble(message = message) - } - } - } - } - - status?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - Divider() - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Bottom - ) { - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.weight(1f), - label = { Text("Message") }, - singleLine = false - ) - Button( - onClick = { - if (input.isBlank()) return@Button - coroutineScope.launch { - runCatching { - ApiClient.sendDm(recipientId = otherUserId, plaintext = input.trim()) - }.onSuccess { - input = "" - loadHistory() - }.onFailure { - status = it.message ?: "Failed to send DM" - } - } - } - ) { - Text(text = "Send") - } - } - } - } -} - -@Composable -private fun MessageBubble(message: DecryptedDmMessage) { - val alignment = if (message.isOutgoing) Alignment.End else Alignment.Start - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = alignment - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.85f), - shape = MaterialTheme.shapes.medium - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text(text = message.text, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.size(4.dp)) - Text( - text = message.timestamp, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -private fun extractDmPayloads(msg: WebSocketMessage): List { - val results = mutableListOf() - when (msg.type) { - "dmNew" -> msg.data?.let { results.add(it) } - "updates" -> { - val updatesArray = msg.data - ?.jsonObject - ?.get("updates") - ?.jsonArray - ?: JsonArray(emptyList()) - for (item in updatesArray) { - val obj = item.jsonObject - if (obj["type"]?.jsonPrimitive?.content == "dmNew") { - obj["data"]?.let { results.add(it) } - } - } - } - } - return results + ChatScreen( + panel = panel, + currentUserId = currentUserId, + modifier = modifier.fillMaxSize() + ) }