From cde3df98f2dc610e0803a7e69ac787f9cc58002a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 18 Jun 2026 14:38:23 +0700 Subject: [PATCH] add screener card --- .../androidMain/kotlin/su/reya/coop/App.kt | 4 +- .../kotlin/su/reya/coop/Navigation.kt | 6 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 240 ++++++++++++++---- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 10 +- .../su/reya/coop/screens/ProfileScreen.kt | 50 +--- .../su/reya/coop/screens/RequestListScreen.kt | 2 +- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 15 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 11 +- 9 files changed, 204 insertions(+), 136 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index cd603da..47f75ba 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -178,13 +178,13 @@ fun App(viewModel: NostrViewModel) { NewIdentityScreen() } entry { key -> - ChatScreen(id = key.id) + ChatScreen(id = key.id, screening = key.screening) } entry { NewChatScreen() } entry { key -> - ProfileScreen(pubkey = key.pubkey, screening = key.screening) + ProfileScreen(pubkey = key.pubkey) } entry { UpdateProfileScreen() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 213aad0..e7b72a9 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -12,7 +12,7 @@ sealed interface Screen : NavKey { return when (data.host) { // Matches coop://chat/{id} - "chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) } + "chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it, false) } // Matches coop://profile/{pubkey} "profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) } else -> null @@ -27,10 +27,10 @@ sealed interface Screen : NavKey { data object RequestList : Screen @Serializable - data class Chat(val id: Long) : Screen + data class Chat(val id: Long, val screening: Boolean = false) : Screen @Serializable - data class Profile(val pubkey: String, val screening: Boolean = false) : Screen + data class Profile(val pubkey: String) : Screen @Serializable data object ContactList : Screen diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 3e4694f..acd38ec 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -22,12 +22,16 @@ 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.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.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface @@ -36,39 +40,45 @@ 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.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.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.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_arrow_back import coop.composeapp.generated.resources.ic_send +import kotlinx.coroutines.launch 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.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.roomId import su.reya.coop.shared.Avatar -import su.reya.coop.shared.displayNameFlow +import su.reya.coop.shared.nameFlow import su.reya.coop.shared.pictureFlow @Composable -fun ChatScreen(id: Long) { +fun ChatScreen(id: Long, screening: Boolean = false) { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val viewModel = LocalNostrViewModel.current @@ -92,12 +102,13 @@ fun ChatScreen(id: Long) { return } - val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...") - val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null) + val displayName by remember(room) { room!!.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...") + val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null) var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } + var requireScreening by remember { mutableStateOf(screening) } val listState = rememberLazyListState() val messages = remember { mutableStateListOf() } @@ -155,9 +166,7 @@ fun ChatScreen(id: Long) { } ) { if (loading) { - LoadingIndicator( - modifier = Modifier.size(32.dp), - ) + LoadingIndicator(modifier = Modifier.size(32.dp)) } else { Avatar( picture = picture, @@ -208,66 +217,188 @@ fun ChatScreen(id: Long) { .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) + 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( + 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), + + false -> { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center ) { - 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 - ) + 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 = "" + + when (requireScreening) { + true -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledTonalButton( + onClick = { navigator.navigate(Screen.Home) }, + modifier = Modifier.weight(1f) + ) { + Text( + text = "Reject", + style = MaterialTheme.typography.titleMedium, + ) + } + Button( + onClick = { requireScreening = false }, + modifier = Modifier.weight(1f) + ) { + Text( + text = "Accept", + style = MaterialTheme.typography.titleMedium, + ) + } + } } - ) + + else -> { + ChatInput( + value = text, + onValueChange = { text = it }, + onSend = { + viewModel.sendMessage(id, text) + text = "" + } + ) + } + } } } } ) } +@Composable +fun ScreenerCard(room: Room) { + val pubkey = room.members.firstOrNull() ?: return + + val viewModel = LocalNostrViewModel.current + val scope = rememberCoroutineScope() + + var isContact by remember { mutableStateOf(false) } + var mutualContacts by remember { mutableStateOf>(emptySet()) } + var lastActivity by remember { mutableStateOf(null) } + + val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadata by metadataFlow.collectAsStateWithLifecycle() + + val profile = metadata?.asRecord() + val displayName = profile?.displayName ?: profile?.name ?: "No name" + val picture = profile?.picture + + LaunchedEffect(Unit) { + scope.launch { + // Check contact + viewModel.verifyContact(pubkey).let { isContact = it } + // Get mutual contacts + viewModel.mutualContacts(pubkey).let { mutualContacts = it } + // Get the last activity + viewModel.verifyActivity(pubkey)?.let { lastActivity = it } + } + } + + OutlinedCard( + modifier = Modifier.padding(16.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar( + picture = picture, + description = "Profile picture", + modifier = Modifier.size(48.dp), + shape = MaterialShapes.Cookie12Sided.toShape(), + ) + Text( + text = displayName, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMediumEmphasized, + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (isContact) "Contact" else "Not a contact", + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common", + ) + } + lastActivity?.toHumanDatetime()?.let { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Last activity: $it" + ) + } + } + } + } +} + @Composable fun DateSeparator(date: String) { Box( @@ -345,7 +476,6 @@ fun ChatInput( onValueChange: (String) -> Unit, onSend: () -> Unit ) { - Surface(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 01b8d72..847120e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -100,8 +100,8 @@ import su.reya.coop.RoomKind import su.reya.coop.Screen import su.reya.coop.ago import su.reya.coop.shared.Avatar -import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.shared.nameFlow import su.reya.coop.shared.pictureFlow import su.reya.coop.short @@ -627,11 +627,11 @@ fun NewRequests(requests: List) { val secondRoom = requests.getOrNull(1) val firstName by remember(firstRoom) { - firstRoom?.displayNameFlow(viewModel) ?: flowOf("") + firstRoom?.nameFlow(viewModel) ?: flowOf("") }.collectAsStateWithLifecycle("Loading...") val secondName by remember(secondRoom) { - secondRoom?.displayNameFlow(viewModel) ?: flowOf("") + secondRoom?.nameFlow(viewModel) ?: flowOf("") }.collectAsStateWithLifecycle("") val supportingText = when { @@ -704,8 +704,8 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val viewModel = LocalNostrViewModel.current - val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") - val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) + val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...") + val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null) ListItem( modifier = Modifier.clickable(onClick = onClick), diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index c5dec81..87ffd06 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon @@ -24,15 +23,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text +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.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.draw.clip @@ -47,7 +44,6 @@ import coop.composeapp.generated.resources.ic_share import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.Timestamp import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @@ -58,7 +54,7 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ProfileScreen(pubkey: String, screening: Boolean = false) { +fun ProfileScreen(pubkey: String) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current @@ -83,41 +79,12 @@ fun ProfileScreen(pubkey: String, screening: Boolean = false) { ) } - var addressVerified by remember { mutableStateOf(false) } - var lastActivity by remember { mutableStateOf(null) } - var isContact by remember { mutableStateOf(false) } - var mutualContacts by remember { mutableStateOf>(emptySet()) } - - LaunchedEffect(screening) { - if (screening) { - scope.launch { - // Verify NIP-05 address if present - profile?.nip05?.let { it -> - viewModel.verifyAddress(pubkey, it).let { addressVerified = it } - } - // Get the last activity - viewModel.verifyActivity(pubkey)?.let { lastActivity = it } - // Check contact - viewModel.verifyContact(pubkey).let { isContact = it } - // Get mutual contacts - viewModel.mutualContacts(pubkey).let { mutualContacts = it } - } - } - } - Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - CenterAlignedTopAppBar( - title = { - if (screening) { - Text( - text = "Screening", - style = MaterialTheme.typography.titleMediumEmphasized - ) - } - }, + TopAppBar( + title = { /* empty */ }, navigationIcon = { IconButton(onClick = { navigator.goBack() }) { Icon( @@ -267,15 +234,6 @@ fun ProfileScreen(pubkey: String, screening: Boolean = false) { ) } } - - if (screening) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), - ) { - // TODO - } - } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index fbeb0a2..3e2a67c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -146,7 +146,7 @@ fun RequestListScreen() { items(requests.toList(), key = { it.id }) { room -> ChatRoom( room = room, - onClick = { navigator.navigate(Screen.Chat(room.id)) } + onClick = { navigator.navigate(Screen.Chat(room.id, true)) } ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt index b87f789..a7c0cbd 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt @@ -8,7 +8,7 @@ import su.reya.coop.NostrViewModel import su.reya.coop.Room import su.reya.coop.short -fun Room.displayNameFlow(viewModel: NostrViewModel): Flow { +fun Room.nameFlow(viewModel: NostrViewModel): Flow { // Return early if there's a custom subject/room name subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 3e51b67..da7e469 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -296,7 +296,7 @@ class Nostr { if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) { if (isSignedByUser(event = event)) { - val pubkeys = event.tags().publicKeys(); + val pubkeys = event.tags().publicKeys() // Get mutual contacts getMutualContacts(pubkeys) // Emit contact list update @@ -367,7 +367,7 @@ class Nostr { private suspend fun getMutualContacts(pubkeys: List) { try { - val kind = Kind.fromStd(KindStandard.CONTACT_LIST); + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) val filter = Filter().kind(kind).authors(pubkeys).limit(200u) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) @@ -974,17 +974,6 @@ class Nostr { } } - suspend fun verifyAddress(pubkey: PublicKey, address: String): Boolean { - try { - val address = Nip05Address.parse(address) - val profile = profileFromAddress(HttpClient(), address) - - return profile.publicKey() == pubkey - } catch (e: Exception) { - throw IllegalStateException("Failed to verify address: ${e.message}", e) - } - } - suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { try { val filter = Filter().author(pubkey).limit(3u) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 3c32fe8..052c5d9 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -821,16 +821,7 @@ class NostrViewModel( } return emptyList() } - - suspend fun verifyAddress(pubkey: PublicKey, address: String): Boolean { - return try { - nostr.verifyAddress(pubkey, address) - } catch (e: Exception) { - showError("Error: ${e.message}") - false - } - } - + suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { return try { nostr.verifyActivity(pubkey)