forked from reya/coop-mobile
594 lines
23 KiB
Kotlin
594 lines
23 KiB
Kotlin
package su.reya.coop.screens
|
|
|
|
import android.net.Uri
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
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.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.ime
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.union
|
|
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.Button
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
import androidx.compose.material3.FilledTonalButton
|
|
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.MaterialShapes
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.ScaffoldDefaults
|
|
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.material3.toShape
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
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.rememberCoroutineScope
|
|
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.platform.LocalContext
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import coop.composeapp.generated.resources.Res
|
|
import coop.composeapp.generated.resources.ic_add_circle
|
|
import coop.composeapp.generated.resources.ic_arrow_back
|
|
import coop.composeapp.generated.resources.ic_cancel
|
|
import coop.composeapp.generated.resources.ic_check_circle
|
|
import coop.composeapp.generated.resources.ic_send
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import org.jetbrains.compose.resources.painterResource
|
|
import rust.nostr.sdk.PublicKey
|
|
import rust.nostr.sdk.Timestamp
|
|
import rust.nostr.sdk.UnsignedEvent
|
|
import su.reya.coop.LocalChatViewModel
|
|
import su.reya.coop.LocalNavigator
|
|
import su.reya.coop.LocalNostrViewModel
|
|
import su.reya.coop.LocalSnackbarHostState
|
|
import su.reya.coop.Room
|
|
import su.reya.coop.Screen
|
|
import su.reya.coop.formatAsGroupHeader
|
|
import su.reya.coop.humanReadable
|
|
import su.reya.coop.rememberUiState
|
|
import su.reya.coop.roomId
|
|
import su.reya.coop.shared.Avatar
|
|
import su.reya.coop.shared.getExpressiveFontFamily
|
|
import su.reya.coop.short
|
|
|
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
@Composable
|
|
fun ChatScreen(id: Long, screening: Boolean = false) {
|
|
val context = LocalContext.current
|
|
val snackbarHostState = LocalSnackbarHostState.current
|
|
val navigator = LocalNavigator.current
|
|
val nostrViewModel = LocalNostrViewModel.current
|
|
val chatViewModel = LocalChatViewModel.current
|
|
val scope = rememberCoroutineScope()
|
|
val listState = rememberLazyListState()
|
|
|
|
// Get current user
|
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
|
|
|
// Get chat room by ID
|
|
val chatRooms by chatViewModel.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 roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
|
|
var text by remember { mutableStateOf("") }
|
|
var loading by remember { mutableStateOf(true) }
|
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
|
var requireScreening by remember { mutableStateOf(screening) }
|
|
|
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
|
val groupedMessages = remember(messages.toList()) {
|
|
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
|
}
|
|
|
|
val sendFile = { uri: Uri ->
|
|
scope.launch {
|
|
try {
|
|
// Read file
|
|
val file = withContext(Dispatchers.IO) {
|
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
|
}
|
|
|
|
// Parse the file content type
|
|
val type = context.contentResolver.getType(uri)
|
|
|
|
// Send message
|
|
chatViewModel.sendFileMessage(id, file, type)
|
|
} catch (e: Exception) {
|
|
snackbarHostState.showSnackbar("Error: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
|
uri?.let { sendFile(it) }
|
|
}
|
|
|
|
LaunchedEffect(id) {
|
|
// Get messages
|
|
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
|
messages.clear()
|
|
messages.addAll(initialMessages)
|
|
|
|
// Stop loading spinner
|
|
loading = false
|
|
|
|
// Get msg relays for each member
|
|
chatViewModel.chatRoomConnect(id)
|
|
|
|
// Handle new messages
|
|
chatViewModel.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(
|
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
|
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 = roomState.picture,
|
|
description = roomState.name,
|
|
size = 32.dp,
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.size(8.dp))
|
|
Text(
|
|
text = roomState.name,
|
|
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 (requireScreening) {
|
|
room?.let { ScreenerCard(it) }
|
|
}
|
|
|
|
when (messages.isNotEmpty()) {
|
|
true -> {
|
|
LazyColumn(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.fillMaxWidth(),
|
|
contentPadding = PaddingValues(16.dp),
|
|
reverseLayout = true,
|
|
state = listState,
|
|
) {
|
|
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
|
items(
|
|
items = messagesInGroup,
|
|
key = { it.id()?.toBech32() ?: it.hashCode() }
|
|
) {
|
|
ChatMessage(
|
|
rumor = it,
|
|
isMine = currentUser?.publicKey == it.author()
|
|
)
|
|
}
|
|
item {
|
|
DateSeparator(dateHeader)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
false -> {
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
when (requireScreening) {
|
|
true -> {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
Button(
|
|
onClick = { navigator.goBack() },
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.size(ButtonDefaults.MediumContainerHeight)
|
|
) {
|
|
Text(
|
|
text = "Reject",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
)
|
|
}
|
|
FilledTonalButton(
|
|
onClick = { requireScreening = false },
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.size(ButtonDefaults.MediumContainerHeight)
|
|
) {
|
|
Text(
|
|
text = "Accept",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
else -> {
|
|
ChatInput(
|
|
value = text,
|
|
onValueChange = { text = it },
|
|
onSend = {
|
|
chatViewModel.sendMessage(id, text)
|
|
text = ""
|
|
},
|
|
onUpload = {
|
|
launcher.launch("image/*")
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
@Composable
|
|
fun ScreenerCard(room: Room) {
|
|
val pubkey = room.members.firstOrNull() ?: return
|
|
|
|
val nostrViewModel = LocalNostrViewModel.current
|
|
val scope = rememberCoroutineScope()
|
|
|
|
var isContact by remember { mutableStateOf(false) }
|
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
|
|
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
|
|
|
LaunchedEffect(pubkey) {
|
|
scope.launch {
|
|
// Check contact
|
|
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
|
// Get mutual contacts
|
|
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
|
// Get the last activity
|
|
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
|
}
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 16.dp)
|
|
.padding(top = 48.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
Column(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
Avatar(
|
|
picture = profile?.picture,
|
|
description = "Profile picture",
|
|
modifier = Modifier.size(120.dp),
|
|
shape = MaterialShapes.Cookie12Sided.toShape(),
|
|
)
|
|
Column(
|
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
Text(
|
|
text = profile?.name ?: "No name",
|
|
textAlign = TextAlign.Center,
|
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
fontFamily = getExpressiveFontFamily()
|
|
),
|
|
)
|
|
Text(
|
|
text = pubkey.short(),
|
|
textAlign = TextAlign.Center,
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.outline
|
|
)
|
|
}
|
|
}
|
|
Column(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
Icon(
|
|
painter = painterResource(
|
|
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
|
),
|
|
contentDescription = "Warning",
|
|
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
|
)
|
|
Text(
|
|
text = if (isContact) "Contact" else "Not a contact",
|
|
style = MaterialTheme.typography.labelMediumEmphasized
|
|
)
|
|
}
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
Icon(
|
|
painter = painterResource(
|
|
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
|
),
|
|
contentDescription = "Warning",
|
|
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
|
)
|
|
Text(
|
|
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
|
style = MaterialTheme.typography.labelMediumEmphasized
|
|
)
|
|
}
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
Icon(
|
|
painter = painterResource(Res.drawable.ic_check_circle),
|
|
contentDescription = "Warning",
|
|
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
|
)
|
|
Text(
|
|
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
|
style = MaterialTheme.typography.labelMediumEmphasized
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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,
|
|
isMine: Boolean = false,
|
|
) {
|
|
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)
|
|
) {
|
|
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,
|
|
onUpload: () -> Unit
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
verticalAlignment = Alignment.Bottom
|
|
) {
|
|
TextField(
|
|
modifier = Modifier.weight(1f),
|
|
shape = RoundedCornerShape(28.dp),
|
|
colors = TextFieldDefaults.colors(
|
|
focusedIndicatorColor = Color.Transparent,
|
|
unfocusedIndicatorColor = Color.Transparent
|
|
),
|
|
value = value,
|
|
onValueChange = onValueChange,
|
|
placeholder = { Text("Message") },
|
|
leadingIcon = {
|
|
IconButton(onClick = onUpload) {
|
|
Icon(
|
|
painter = painterResource(Res.drawable.ic_add_circle),
|
|
contentDescription = "Upload",
|
|
)
|
|
}
|
|
},
|
|
)
|
|
Spacer(modifier = Modifier.size(8.dp))
|
|
FilledTonalIconButton(
|
|
onClick = onSend,
|
|
modifier = Modifier.size(56.dp),
|
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
) {
|
|
Icon(
|
|
painter = painterResource(Res.drawable.ic_send),
|
|
contentDescription = "Send"
|
|
)
|
|
}
|
|
}
|
|
}
|