diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 8d242ce..d2b4f17 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -35,7 +35,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import su.reya.coop.repository.ErrorRepository -import su.reya.coop.screens.ChatScreen +import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.ImportScreen diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt new file mode 100644 index 0000000..ede3bf9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt @@ -0,0 +1,177 @@ +package su.reya.coop.screens.chat + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.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_cancel +import coop.composeapp.generated.resources.ic_check_circle +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Timestamp +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.Room +import su.reya.coop.humanReadable +import su.reya.coop.shared.Avatar +import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.short + +@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>(emptySet()) } + var lastActivity by remember { mutableStateOf(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 + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt new file mode 100644 index 0000000..7f09365 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt @@ -0,0 +1,97 @@ +package su.reya.coop.screens.chat + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_add_circle +import coop.composeapp.generated.resources.ic_audio +import coop.composeapp.generated.resources.ic_send +import org.jetbrains.compose.resources.painterResource + +@Composable +fun ChatInput( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + onUpload: () -> Unit, + onMicClick: () -> 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)) + AnimatedContent( + targetState = value.isNotEmpty(), + transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) }, + label = "send_mic_transition" + ) { isNotEmpty -> + if (isNotEmpty) { + IconButton( + onClick = onSend, + modifier = Modifier.size(56.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + painter = painterResource(Res.drawable.ic_send), + contentDescription = "Send" + ) + } + } else { + FilledTonalIconButton( + onClick = onMicClick, + modifier = Modifier.size(56.dp), + ) { + Icon( + painter = painterResource(Res.drawable.ic_audio), + contentDescription = "Speech to Text" + ) + } + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt new file mode 100644 index 0000000..348bdce --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt @@ -0,0 +1,183 @@ +package su.reya.coop.screens.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.URL_REGEX +import su.reya.coop.formatAsTime +import su.reya.coop.isImageUrl +import su.reya.coop.removeImageUrls + +@Immutable +data class MessageUiModel( + val id: String, + val annotatedContent: AnnotatedString, + val images: List, + val timestamp: String, + val isMine: Boolean +) + +@Composable +fun rememberMessageUiModel( + event: UnsignedEvent, + currentUserPublicKey: PublicKey?, + contentColor: Color +): MessageUiModel { + return remember(event, currentUserPublicKey, contentColor) { + val content = event.content() + val images = URL_REGEX.findAll(content) + .map { it.value } + .filter { it.isImageUrl() } + .toList() + + val cleanedContent = content.removeImageUrls() + + val annotatedString = buildAnnotatedString { + var lastIndex = 0 + URL_REGEX.findAll(cleanedContent).forEach { matchResult -> + append(cleanedContent.substring(lastIndex, matchResult.range.first)) + val url = matchResult.value + pushLink( + LinkAnnotation.Url( + url = url, + styles = TextLinkStyles( + style = SpanStyle( + color = contentColor, + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Medium + ) + ) + ) + ) + append(url) + pop() + lastIndex = matchResult.range.last + 1 + } + append(cleanedContent.substring(lastIndex)) + } + + MessageUiModel( + id = event.id()?.toHex() ?: event.hashCode().toString(), + annotatedContent = annotatedString, + images = images, + timestamp = event.createdAt().formatAsTime(), + isMine = event.author() == currentUserPublicKey + ) + } +} + +@Composable +fun ChatMessage(model: MessageUiModel) { + var isMessageClicked by remember { mutableStateOf(false) } + + val bubbleShape = if (model.isMine) { + RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) + } else { + RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp) + } + + val containerColor = + if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer + + val contentColor = + if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart + ) { + Column( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isMessageClicked = !isMessageClicked + }, + horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (model.annotatedContent.isNotBlank()) { + Surface( + color = containerColor, + contentColor = contentColor, + shape = bubbleShape, + modifier = Modifier.widthIn(max = 280.dp) + ) { + Text( + text = model.annotatedContent, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + model.images.forEach { imageUrl -> + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.widthIn(max = 280.dp) + ) { + AsyncImage( + model = imageUrl, + contentDescription = "Image from chat", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.FillWidth + ) + } + } + AnimatedVisibility( + visible = isMessageClicked, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Text( + text = model.timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.align( + if (model.isMine) Alignment.End else Alignment.Start + ) + ) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt similarity index 53% rename from composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt rename to composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index b0e7aa8..2468f46 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -1,4 +1,4 @@ -package su.reya.coop.screens +package su.reya.coop.screens.chat import android.app.Activity import android.content.Intent @@ -6,17 +6,7 @@ import android.net.Uri import android.speech.RecognizerIntent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,7 +20,6 @@ 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 @@ -41,25 +30,18 @@ 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.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -71,34 +53,16 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage 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_audio -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 @@ -106,76 +70,10 @@ import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.Screen -import su.reya.coop.URL_REGEX import su.reya.coop.formatAsGroupHeader -import su.reya.coop.formatAsTime -import su.reya.coop.humanReadable -import su.reya.coop.isImageUrl 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 - -@Immutable -data class MessageModel( - val id: String, - val annotatedContent: AnnotatedString, - val images: List, - val timestamp: String, - val isMine: Boolean -) - -@Composable -fun rememberMessageModel( - event: UnsignedEvent, - currentUserPublicKey: PublicKey?, - contentColor: Color -): MessageModel { - return remember(event, currentUserPublicKey, contentColor) { - val content = event.content() - val images = URL_REGEX.findAll(content) - .map { it.value } - .filter { it.isImageUrl() } - .toList() - - val cleanedContent = URL_REGEX.replace(content) { result -> - if (result.value.isImageUrl()) "" else result.value - }.replace(Regex("\\s+"), " ").trim() - - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - URL_REGEX.findAll(cleanedContent).forEach { matchResult -> - append(cleanedContent.substring(lastIndex, matchResult.range.first)) - val url = matchResult.value - pushLink( - LinkAnnotation.Url( - url = url, - styles = TextLinkStyles( - style = SpanStyle( - color = contentColor, - textDecoration = TextDecoration.Underline, - fontWeight = FontWeight.Medium - ) - ) - ) - ) - append(url) - pop() - lastIndex = matchResult.range.last + 1 - } - append(cleanedContent.substring(lastIndex)) - } - - MessageModel( - id = event.id()?.toHex() ?: event.hashCode().toString(), - annotatedContent = annotatedString, - images = images, - timestamp = event.createdAt().formatAsTime(), - isMine = event.author() == currentUserPublicKey - ) - } -} @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -374,7 +272,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { key = { it.id()?.toHex() ?: it.hashCode().toString() } ) { event -> val isMine = currentUser?.publicKey == event.author() - val uiModel = rememberMessageModel( + val uiModel = rememberMessageUiModel( event = event, currentUserPublicKey = currentUser?.publicKey, contentColor = if (isMine) mineColor else otherColor @@ -485,285 +383,3 @@ fun ChatScreen(id: Long, screening: Boolean = false) { } ) } - -@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>(emptySet()) } - var lastActivity by remember { mutableStateOf(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(model: MessageModel) { - var isMessageClicked by remember { mutableStateOf(false) } - - val bubbleShape = if (model.isMine) { - RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) - } else { - RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp) - } - - val containerColor = - if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer - - val contentColor = - if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart - ) { - Column( - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - isMessageClicked = !isMessageClicked - }, - horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (model.annotatedContent.isNotBlank()) { - Surface( - color = containerColor, - contentColor = contentColor, - shape = bubbleShape, - modifier = Modifier.widthIn(max = 280.dp) - ) { - Text( - text = model.annotatedContent, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodyLarge - ) - } - } - model.images.forEach { imageUrl -> - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.widthIn(max = 280.dp) - ) { - AsyncImage( - model = imageUrl, - contentDescription = "Image from chat", - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.FillWidth - ) - } - } - AnimatedVisibility( - visible = isMessageClicked, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Text( - text = model.timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - modifier = Modifier.align( - if (model.isMine) Alignment.End else Alignment.Start - ) - ) - } - } - } -} - -@Composable -fun ChatInput( - value: String, - onValueChange: (String) -> Unit, - onSend: () -> Unit, - onUpload: () -> Unit, - onMicClick: () -> 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)) - AnimatedContent( - targetState = value.isNotEmpty(), - transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) }, - label = "send_mic_transition" - ) { isNotEmpty -> - if (isNotEmpty) { - IconButton( - onClick = onSend, - modifier = Modifier.size(56.dp), - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Icon( - painter = painterResource(Res.drawable.ic_send), - contentDescription = "Send" - ) - } - } else { - FilledTonalIconButton( - onClick = onMicClick, - modifier = Modifier.size(56.dp), - ) { - Icon( - painter = painterResource(Res.drawable.ic_audio), - contentDescription = "Speech to Text" - ) - } - } - } - } -}