forked from reya/coop-mobile
386 lines
14 KiB
Kotlin
386 lines
14 KiB
Kotlin
package su.reya.coop.screens
|
|
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.IntrinsicSize
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.aspectRatio
|
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.widthIn
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material3.Badge
|
|
import androidx.compose.material3.BadgedBox
|
|
import androidx.compose.material3.FilledTonalIconButton
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.IconButtonDefaults
|
|
import androidx.compose.material3.LoadingIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.SnackbarHost
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextField
|
|
import androidx.compose.material3.TextFieldDefaults
|
|
import androidx.compose.material3.TopAppBar
|
|
import androidx.compose.material3.TopAppBarDefaults
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateListOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import coop.composeapp.generated.resources.Res
|
|
import coop.composeapp.generated.resources.ic_arrow_back
|
|
import coop.composeapp.generated.resources.ic_send
|
|
import org.jetbrains.compose.resources.painterResource
|
|
import rust.nostr.sdk.UnsignedEvent
|
|
import su.reya.coop.LocalNavigator
|
|
import su.reya.coop.LocalNostrViewModel
|
|
import su.reya.coop.LocalSnackbarHostState
|
|
import su.reya.coop.Screen
|
|
import su.reya.coop.formatAsGroupHeader
|
|
import su.reya.coop.roomId
|
|
import su.reya.coop.shared.Avatar
|
|
import su.reya.coop.shared.displayNameFlow
|
|
import su.reya.coop.shared.pictureFlow
|
|
|
|
@Composable
|
|
fun ChatScreen(id: Long) {
|
|
val snackbarHostState = LocalSnackbarHostState.current
|
|
val navigator = LocalNavigator.current
|
|
val viewModel = LocalNostrViewModel.current
|
|
|
|
// Get chat room by ID
|
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
|
|
|
// Show empty screen
|
|
if (room == null) {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text(
|
|
text = "Something went wrong.",
|
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
|
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
|
|
|
|
var text by remember { mutableStateOf("") }
|
|
var loading by remember { mutableStateOf(true) }
|
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
|
|
|
val listState = rememberLazyListState()
|
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
|
|
|
val groupedMessages = remember(messages.toList()) {
|
|
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
|
}
|
|
|
|
LaunchedEffect(id) {
|
|
// Start loading spinner
|
|
loading = true
|
|
|
|
// Get messages
|
|
val initialMessages = viewModel.getChatRoomMessages(id)
|
|
messages.clear()
|
|
messages.addAll(initialMessages)
|
|
|
|
// Stop loading spinner
|
|
loading = false
|
|
|
|
// Get msg relays for each member
|
|
viewModel.chatRoomConnect(id)
|
|
|
|
// Handle new messages
|
|
viewModel.newEvents.collect { event ->
|
|
if (event.roomId() == id) {
|
|
if (event.id() !in messages.map { it.id() }) {
|
|
messages.add(0, event)
|
|
}
|
|
} else {
|
|
// If the event is not in the current room, it's a new message from another user
|
|
newOtherMessages++
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(messages.size) {
|
|
if (messages.isNotEmpty()) {
|
|
listState.animateScrollToItem(0)
|
|
}
|
|
}
|
|
|
|
Scaffold(
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
topBar = {
|
|
TopAppBar(
|
|
title = {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.clickable {
|
|
room!!.members.firstOrNull()?.let { pubkey ->
|
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
|
}
|
|
}
|
|
) {
|
|
if (loading) {
|
|
LoadingIndicator(
|
|
modifier = Modifier.size(32.dp),
|
|
)
|
|
} else {
|
|
Avatar(
|
|
picture = picture,
|
|
description = displayName,
|
|
size = 32.dp,
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.size(8.dp))
|
|
Text(
|
|
text = displayName,
|
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
)
|
|
}
|
|
},
|
|
navigationIcon = {
|
|
BadgedBox(
|
|
badge = {
|
|
if (newOtherMessages > 0) {
|
|
Badge {
|
|
Text(newOtherMessages.toString())
|
|
}
|
|
}
|
|
}
|
|
) {
|
|
IconButton(onClick = { navigator.goBack() }) {
|
|
Icon(
|
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
contentDescription = "Back"
|
|
)
|
|
}
|
|
}
|
|
},
|
|
colors = TopAppBarDefaults.topAppBarColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
)
|
|
)
|
|
},
|
|
content = { innerPadding ->
|
|
Surface(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(top = innerPadding.calculateTopPadding()),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
|
) {
|
|
if (messages.isNotEmpty()) {
|
|
LazyColumn(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.fillMaxWidth(),
|
|
contentPadding = PaddingValues(16.dp),
|
|
reverseLayout = true,
|
|
state = listState,
|
|
) {
|
|
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
|
items(
|
|
messagesInGroup,
|
|
key = { it.id()?.toBech32()!! }) { event ->
|
|
ChatMessage(event)
|
|
}
|
|
item {
|
|
DateSeparator(dateHeader)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.fillMaxWidth(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
) {
|
|
Text(
|
|
text = "No messages yet",
|
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
fontWeight = FontWeight.SemiBold
|
|
),
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
Text(
|
|
text = "Your conversations will appear here.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.outline
|
|
)
|
|
}
|
|
}
|
|
}
|
|
ChatInput(
|
|
value = text,
|
|
onValueChange = { text = it },
|
|
onSend = {
|
|
viewModel.sendMessage(id, text)
|
|
text = ""
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun DateSeparator(date: String) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(vertical = 16.dp),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text(
|
|
text = date,
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.outline
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ChatMessage(
|
|
rumor: UnsignedEvent
|
|
) {
|
|
val viewModel = LocalNostrViewModel.current
|
|
val currentUser = viewModel.currentUser()
|
|
val isMine = rumor.author() == currentUser
|
|
|
|
val bubbleShape = if (isMine) {
|
|
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
|
} else {
|
|
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
|
}
|
|
|
|
val containerColor =
|
|
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
|
|
|
|
val contentColor =
|
|
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(vertical = 4.dp),
|
|
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
|
) {
|
|
Column(
|
|
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
|
) {
|
|
Surface(
|
|
color = containerColor,
|
|
contentColor = contentColor,
|
|
shape = bubbleShape,
|
|
modifier = Modifier
|
|
.widthIn(max = 280.dp)
|
|
.clickable(
|
|
onClick = {
|
|
val id = rumor.id()
|
|
if (id != null) {
|
|
val sent = viewModel.isMessageSent(id)
|
|
println("Sent: $sent")
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
Text(
|
|
text = rumor.content(),
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
style = MaterialTheme.typography.bodyMedium
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ChatInput(
|
|
value: String,
|
|
onValueChange: (String) -> Unit,
|
|
onSend: () -> Unit
|
|
) {
|
|
|
|
Surface(modifier = Modifier.fillMaxWidth()) {
|
|
Row(
|
|
modifier = Modifier
|
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
.height(IntrinsicSize.Min),
|
|
verticalAlignment = Alignment.Bottom
|
|
) {
|
|
TextField(
|
|
value = value,
|
|
onValueChange = onValueChange,
|
|
placeholder = { Text("Message") },
|
|
shape = RoundedCornerShape(28.dp),
|
|
colors = TextFieldDefaults.colors(
|
|
focusedIndicatorColor = Color.Transparent,
|
|
unfocusedIndicatorColor = Color.Transparent
|
|
),
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Spacer(modifier = Modifier.size(8.dp))
|
|
FilledTonalIconButton(
|
|
onClick = onSend,
|
|
modifier = Modifier
|
|
.fillMaxHeight()
|
|
.aspectRatio(1f),
|
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
) {
|
|
Icon(
|
|
painter = painterResource(Res.drawable.ic_send),
|
|
contentDescription = "Send"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|