mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Rewrite using ChatPanel
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -6,12 +6,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.material.icons.Icons
|
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.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.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
@@ -42,7 +56,7 @@ actual fun DebugApiScreen() {
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.navigateUp() }) {
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = null)
|
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
@@ -155,7 +169,11 @@ actual fun DebugApiScreen() {
|
|||||||
modifier = Modifier.fillMaxWidth()
|
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(
|
||||||
text = "DM Send Test",
|
text = "DM Send Test",
|
||||||
@@ -210,7 +228,11 @@ actual fun DebugApiScreen() {
|
|||||||
Text(text = "Send DM")
|
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(
|
||||||
text = "DM Decryption Test",
|
text = "DM Decryption Test",
|
||||||
|
|||||||
@@ -188,8 +188,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
composable("dm/{otherUserId}") { backStackEntry ->
|
composable("dm/{otherUserId}") { backStackEntry ->
|
||||||
val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0
|
val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0
|
||||||
DmScreen(
|
DmScreen(
|
||||||
otherUserId = otherUserId,
|
otherUserId = otherUserId
|
||||||
onBack = { navController.navigateUp() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ data class ChatPanelState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val hasMoreMessages: Boolean = false,
|
val hasMoreMessages: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val typingUsers: List<TypingUser> = emptyList()
|
val typingUsers: List<TypingUser> = 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 UI control methods
|
||||||
abstract fun showCallButton(): Boolean
|
abstract fun showCallButton(): Boolean
|
||||||
abstract fun getTypingHandler(): TypingHandler
|
abstract fun getTypingHandler(): TypingHandler
|
||||||
|
|
||||||
|
open val showUsernamesInMessages: Boolean
|
||||||
|
get() = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
|||||||
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
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.height
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -67,6 +69,7 @@ import ru.fromchat.api.WebSocketUpdatesData
|
|||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.chat.Avatar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -162,7 +165,9 @@ fun ChatScreen(
|
|||||||
data = update.data
|
data = update.data
|
||||||
)
|
)
|
||||||
when (update.type) {
|
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}")
|
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -179,7 +184,7 @@ fun ChatScreen(
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"newMessage", "messageEdited", "messageDeleted" -> {
|
"newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted" -> {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
panel.handleWebSocketMessage(message)
|
panel.handleWebSocketMessage(message)
|
||||||
}
|
}
|
||||||
@@ -205,6 +210,18 @@ fun ChatScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
panelState.titleAvatar?.let { avatar ->
|
||||||
|
Avatar(
|
||||||
|
profilePictureUrl = avatar.profilePictureUrl,
|
||||||
|
displayName = avatar.displayName,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
text = panelState.title,
|
text = panelState.title,
|
||||||
@@ -226,6 +243,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.navigateUp() }) {
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
@@ -360,7 +378,8 @@ fun ChatScreen(
|
|||||||
},
|
},
|
||||||
onTapPosition = { offset ->
|
onTapPosition = { offset ->
|
||||||
tapOffset = offset
|
tapOffset = offset
|
||||||
}
|
},
|
||||||
|
showUsername = panel.showUsernamesInMessages
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ fun MessageItem(
|
|||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
onLongPress: () -> Unit,
|
onLongPress: () -> Unit,
|
||||||
onTapPosition: (Offset) -> Unit = {},
|
onTapPosition: (Offset) -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
showUsername: Boolean = true
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = true,
|
visible = true,
|
||||||
@@ -78,8 +79,7 @@ fun MessageItem(
|
|||||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
if (!isAuthor) {
|
if (!isAuthor && showUsername) {
|
||||||
// Avatar at bottom
|
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = message.profile_picture,
|
profilePictureUrl = message.profile_picture,
|
||||||
displayName = message.username,
|
displayName = message.username,
|
||||||
@@ -132,7 +132,7 @@ fun MessageItem(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Username inside bubble (for received messages)
|
// Username inside bubble (for received messages)
|
||||||
if (!isAuthor) {
|
if (showUsername && !isAuthor) {
|
||||||
Text(
|
Text(
|
||||||
text = message.username,
|
text = message.username,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -172,6 +172,7 @@ fun MessageItem(
|
|||||||
Column(
|
Column(
|
||||||
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
) {
|
) {
|
||||||
|
if (showUsername) {
|
||||||
Text(
|
Text(
|
||||||
text = replyTo.username,
|
text = replyTo.username,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -179,6 +180,7 @@ fun MessageItem(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontSize = 11.sp
|
fontSize = 11.sp
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
@@ -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<List<TypingUser>>(emptyList())
|
||||||
|
override val typingUsers: StateFlow<List<TypingUser>> = _typingUsers.asStateFlow()
|
||||||
|
override fun sendTyping() {}
|
||||||
|
override fun stopTyping() {}
|
||||||
|
override fun handleTypingEvent(userId: Int, username: String) {}
|
||||||
|
override fun handleStopTypingEvent(userId: Int) {}
|
||||||
|
}
|
||||||
@@ -1,276 +1,39 @@
|
|||||||
package ru.fromchat.ui.dm
|
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.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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
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.ApiClient
|
||||||
import ru.fromchat.api.DmEnvelope
|
import ru.fromchat.ui.chat.ChatScreen
|
||||||
import ru.fromchat.api.WebSocketManager
|
|
||||||
import ru.fromchat.api.WebSocketMessage
|
|
||||||
import ru.fromchat.crypto.decryptEnvelope
|
|
||||||
|
|
||||||
private data class DecryptedDmMessage(
|
|
||||||
val id: Int,
|
|
||||||
val text: String,
|
|
||||||
val timestamp: String,
|
|
||||||
val isOutgoing: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DmScreen(
|
fun DmScreen(
|
||||||
otherUserId: Int,
|
otherUserId: Int,
|
||||||
onBack: () -> Unit
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val messages = remember { mutableStateListOf<DecryptedDmMessage>() }
|
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
|
||||||
var status by remember { mutableStateOf<String?>(null) }
|
|
||||||
var input by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
val json = Json { ignoreUnknownKeys = true }
|
val panel = remember(otherUserId) {
|
||||||
|
DmPanel(
|
||||||
suspend fun loadHistory() {
|
otherUserId = otherUserId,
|
||||||
isLoading = true
|
coroutineScope = coroutineScope,
|
||||||
runCatching {
|
currentUserId = currentUserId
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(otherUserId) {
|
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 {
|
coroutineScope.launch {
|
||||||
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
panel.loadMessages()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
ChatScreen(
|
||||||
topBar = {
|
panel = panel,
|
||||||
TopAppBar(
|
currentUserId = currentUserId,
|
||||||
title = { Text(text = "DM with user $otherUserId") },
|
modifier = modifier.fillMaxSize()
|
||||||
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<JsonElement> {
|
|
||||||
val results = mutableListOf<JsonElement>()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user