From e13f72d03d0c3548d4262d77bde43d6bc014f63f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 18 Jun 2026 08:17:08 +0700 Subject: [PATCH 1/6] update profile screen --- .../androidMain/kotlin/su/reya/coop/App.kt | 2 +- .../kotlin/su/reya/coop/Navigation.kt | 2 +- .../su/reya/coop/screens/ProfileScreen.kt | 35 ++++++++++++++----- .../su/reya/coop/screens/RequestListScreen.kt | 5 ++- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index ed281a6..cd603da 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -184,7 +184,7 @@ fun App(viewModel: NostrViewModel) { NewChatScreen() } entry { key -> - ProfileScreen(pubkey = key.pubkey) + ProfileScreen(pubkey = key.pubkey, screening = key.screening) } 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 6a53f67..213aad0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -30,7 +30,7 @@ sealed interface Screen : NavKey { data class Chat(val id: Long) : Screen @Serializable - data class Profile(val pubkey: String) : Screen + data class Profile(val pubkey: String, val screening: Boolean = false) : Screen @Serializable data object ContactList : Screen 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 2b10250..c2b7f6a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -12,6 +12,7 @@ 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 @@ -23,7 +24,6 @@ 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 @@ -54,27 +54,28 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ProfileScreen(pubkey: String) { - val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - +fun ProfileScreen(pubkey: String, screening: Boolean = false) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val viewModel = LocalNostrViewModel.current - val scope = rememberCoroutineScope() + + val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return + val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) - val profile = metadata?.asRecord() + val displayName = profile?.displayName ?: profile?.name ?: "No name" val nip05 = profile?.nip05 ?: pubkey.short() val picture = profile?.picture + val details = remember(profile) { listOf( "Username:" to (profile?.name ?: "None"), "Website:" to (profile?.website ?: "None"), - "Lightning Address:" to (profile?.lud16 ?: "None"), + "₿ Lightning Address:" to (profile?.lud16 ?: "None"), ) } @@ -82,8 +83,15 @@ fun ProfileScreen(pubkey: String) { containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar( - title = { }, + CenterAlignedTopAppBar( + title = { + if (screening) { + Text( + text = "Screening", + style = MaterialTheme.typography.titleMediumEmphasized + ) + } + }, navigationIcon = { IconButton(onClick = { navigator.goBack() }) { Icon( @@ -233,6 +241,15 @@ fun ProfileScreen(pubkey: String) { ) } } + + 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 75ee8da..fbeb0a2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -71,7 +71,10 @@ fun RequestListScreen() { containerColor = MaterialTheme.colorScheme.surfaceContainer, ), title = { - Text("New Requests", style = MaterialTheme.typography.titleMediumEmphasized) + Text( + text = "New Requests", + style = MaterialTheme.typography.titleMediumEmphasized + ) }, navigationIcon = { IconButton(onClick = { navigator.goBack() }) { -- 2.49.1 From 31a20122d321853fa179e4c5fe36966d038759a4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 18 Jun 2026 08:57:38 +0700 Subject: [PATCH 2/6] add verify steps --- .../su/reya/coop/screens/ProfileScreen.kt | 26 ++++++++++++++-- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 30 +++++++++++++++++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 19 ++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) 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 c2b7f6a..d635d80 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -27,16 +27,19 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.toShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +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 import androidx.compose.ui.platform.LocalContext 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_chat @@ -44,6 +47,7 @@ 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 @@ -64,9 +68,9 @@ fun ProfileScreen(pubkey: String, screening: Boolean = false) { val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - val profile = metadata?.asRecord() + val metadata by metadataFlow.collectAsStateWithLifecycle() + val profile = metadata?.asRecord() val displayName = profile?.displayName ?: profile?.name ?: "No name" val nip05 = profile?.nip05 ?: pubkey.short() val picture = profile?.picture @@ -79,6 +83,22 @@ fun ProfileScreen(pubkey: String, screening: Boolean = false) { ) } + var addressVerified by remember { mutableStateOf(false) } + var lastActivity by remember { mutableStateOf(null) } + + 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 } + } + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 5baa07e..66e70e6 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -947,4 +947,34 @@ class Nostr { throw IllegalStateException("Failed to search nostr: ${e.message}", e) } } + + 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) + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + val events = client?.fetchEvents( + target = ReqTarget.manual(target), + timeout = Duration.parse("3s") + ) + + return events?.first()?.createdAt() + } catch (e: Exception) { + throw IllegalStateException("Failed to get latest activity: ${e.message}", e) + } + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b324b9c..5544d9c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -35,6 +35,7 @@ import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Tag +import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage @@ -820,6 +821,24 @@ 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) + } catch (e: Exception) { + showError("Error: ${e.message}") + null + } + } } fun PublicKey.short(): String { -- 2.49.1 From 7a31749b4ff8e0a8a204edfb6e2bc4a2ec6e379b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 18 Jun 2026 09:47:05 +0700 Subject: [PATCH 3/6] add verify contact steps --- .../su/reya/coop/screens/ProfileScreen.kt | 6 ++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 63 ++++++++++++++++++- .../kotlin/su/reya/coop/NostrViewModel.kt | 18 ++++++ 3 files changed, 86 insertions(+), 1 deletion(-) 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 d635d80..c5dec81 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -85,6 +85,8 @@ 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) { @@ -95,6 +97,10 @@ fun ProfileScreen(pubkey: String, screening: Boolean = false) { } // 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 } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 66e70e6..3e51b67 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -296,7 +296,11 @@ class Nostr { if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) { if (isSignedByUser(event = event)) { - onContactListUpdate(event.tags().publicKeys()) + val pubkeys = event.tags().publicKeys(); + // Get mutual contacts + getMutualContacts(pubkeys) + // Emit contact list update + onContactListUpdate(pubkeys) } } @@ -313,6 +317,7 @@ class Nostr { // Logic to notify UI after processing // Cancel previous tracker if it exists eoseTrackerJob?.cancel() + // Start a new tracker eoseTrackerJob = launch { delay(10000.milliseconds) // Wait for 10 seconds @@ -360,6 +365,27 @@ class Nostr { } } + private suspend fun getMutualContacts(pubkeys: List) { + try { + val kind = Kind.fromStd(KindStandard.CONTACT_LIST); + val filter = Filter().kind(kind).authors(pubkeys).limit(200u) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "mutual-contacts", + closeOn = opts, + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) + } + } + private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { try { val filter = Filter().identifier(giftId.toHex()) @@ -977,4 +1003,39 @@ class Nostr { throw IllegalStateException("Failed to get latest activity: ${e.message}", e) } } + + suspend fun verifyContact(pubkey: PublicKey): Boolean { + try { + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).author(pubkey).limit(1u) + + val events = client?.database()?.query(filter) + val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() + + return pubkeys.contains(pubkey) + } catch (e: Exception) { + throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) + } + } + + suspend fun mutualContacts(pubkey: PublicKey): Set { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).pubkey(pubkey).limit(1u) + + val events = client?.database()?.query(filter) + val contacts = mutableSetOf() + + events?.toVec()?.filter { it.author() != currentUser }?.forEach { event -> + contacts.add(event.author()) + } + + return contacts.toSet() + } catch (e: Exception) { + throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) + } + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 5544d9c..3c32fe8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -839,6 +839,24 @@ class NostrViewModel( null } } + + suspend fun verifyContact(pubkey: PublicKey): Boolean { + return try { + nostr.verifyContact(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + false + } + } + + suspend fun mutualContacts(pubkey: PublicKey): Set { + return try { + nostr.mutualContacts(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + setOf() + } + } } fun PublicKey.short(): String { -- 2.49.1 From cde3df98f2dc610e0803a7e69ac787f9cc58002a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 18 Jun 2026 14:38:23 +0700 Subject: [PATCH 4/6] 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) -- 2.49.1 From b1cb8b0fea17efb2ec576665c27b65d205650397 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 19 Jun 2026 14:53:05 +0700 Subject: [PATCH 5/6] update chat rooms query --- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 3 ++- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 19 ++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) 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 847120e..f1e75b6 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -734,7 +734,8 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { Text( text = room.lastMessage!!, style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + maxLines = 1, ) } }, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index da7e469..0aca391 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -399,15 +399,11 @@ class Nostr { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { try { - // Construct the room id - val roomId = rumor.roomId() - // Construct reference tags val tags = listOf( Tag.identifier(giftId.toHex()), Tag.publicKey(rumor.author()), - Tag.event(rumor.id()!!), - Tag.custom("r", listOf(roomId.toString())), + Tag.custom("r", listOf(rumor.roomId().toString())), Tag.custom("k", listOf("14")) ) @@ -748,8 +744,8 @@ class Nostr { val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kTag = SingleLetterTag.lowercase(Alphabet.K) - // Get all events sent by the user - val filter = Filter().kind(kind).pubkey(userPubkey).customTags(kTag, listOf("14", "dm")) + // Get all DM events + val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) val events = client?.database()?.query(filter) // Collect rooms @@ -765,12 +761,13 @@ class Nostr { // Check if the room already exists if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { - val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE) - val pubkeys = newRoom.members.toList() - val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys) + val rTag = SingleLetterTag.lowercase(Alphabet.R) + val filter = Filter().kind(kind).pubkey(userPubkey) + .customTag(rTag, newRoom.id.toString()) // Determine if it's an ongoing room - val isOngoing = client?.database()?.query(filter)?.isEmpty() ?: false + val isOngoing = + client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false // Append room to map roomsMap[newRoom.id] = -- 2.49.1 From 19bce35fd44a6e369f8418b3541eb24394b06c0d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 19 Jun 2026 16:06:30 +0700 Subject: [PATCH 6/6] update screener card --- .../composeResources/drawable/ic_block.xml | 10 ++ .../composeResources/drawable/ic_cancel.xml | 9 ++ .../drawable/ic_check_circle.xml | 9 ++ .../composeResources/drawable/ic_warning.xml | 9 ++ .../kotlin/su/reya/coop/screens/ChatScreen.kt | 98 ++++++++++++++----- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 5 +- 6 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_block.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_cancel.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_check_circle.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_warning.xml diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_block.xml b/composeApp/src/androidMain/composeResources/drawable/ic_block.xml new file mode 100644 index 0000000..137fd90 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_block.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_cancel.xml b/composeApp/src/androidMain/composeResources/drawable/ic_cancel.xml new file mode 100644 index 0000000..e12530b --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_check_circle.xml b/composeApp/src/androidMain/composeResources/drawable/ic_check_circle.xml new file mode 100644 index 0000000..a247a73 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_warning.xml b/composeApp/src/androidMain/composeResources/drawable/ic_warning.xml new file mode 100644 index 0000000..08484c4 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_warning.xml @@ -0,0 +1,9 @@ + + + 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 acd38ec..fde4acf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -23,6 +23,7 @@ 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.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon @@ -31,7 +32,6 @@ 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 @@ -60,6 +60,8 @@ 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_cancel +import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_send import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource @@ -72,10 +74,13 @@ 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.roomId import su.reya.coop.shared.Avatar +import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.nameFlow import su.reya.coop.shared.pictureFlow +import su.reya.coop.short @Composable fun ChatScreen(id: Long, screening: Boolean = false) { @@ -275,11 +280,13 @@ fun ChatScreen(id: Long, screening: Boolean = false) { when (requireScreening) { true -> { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - FilledTonalButton( - onClick = { navigator.navigate(Screen.Home) }, + Button( + onClick = { navigator.goBack() }, modifier = Modifier.weight(1f) ) { Text( @@ -287,7 +294,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { style = MaterialTheme.typography.titleMedium, ) } - Button( + FilledTonalButton( onClick = { requireScreening = false }, modifier = Modifier.weight(1f) ) { @@ -316,6 +323,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { ) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return @@ -334,7 +342,7 @@ fun ScreenerCard(room: Room) { val displayName = profile?.displayName ?: profile?.name ?: "No name" val picture = profile?.picture - LaunchedEffect(Unit) { + LaunchedEffect(pubkey) { scope.launch { // Check contact viewModel.verifyContact(pubkey).let { isContact = it } @@ -345,55 +353,95 @@ fun ScreenerCard(room: Room) { } } - OutlinedCard( - modifier = Modifier.padding(16.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 48.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Avatar( picture = picture, description = "Profile picture", - modifier = Modifier.size(48.dp), + modifier = Modifier.size(120.dp), shape = MaterialShapes.Cookie12Sided.toShape(), ) - Text( - text = displayName, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMediumEmphasized, - ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = displayName, + 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(8.dp), + 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 ) } - lastActivity?.toHumanDatetime()?.let { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Last activity: $it" - ) - } + 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 + ) } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 0aca391..0ef6116 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -992,8 +992,11 @@ class Nostr { suspend fun verifyContact(pubkey: PublicKey): Boolean { try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) - val filter = Filter().kind(kind).author(pubkey).limit(1u) + val filter = Filter().kind(kind).author(currentUser).limit(1u) val events = client?.database()?.query(filter) val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() -- 2.49.1