From 154906017e054a2c0661f14107edbcfd3a8ff931 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 19 Jun 2026 09:06:49 +0000 Subject: [PATCH] feat: screener for message requests (#24) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/24 --- .../composeResources/drawable/ic_block.xml | 10 + .../composeResources/drawable/ic_cancel.xml | 9 + .../drawable/ic_check_circle.xml | 9 + .../composeResources/drawable/ic_warning.xml | 9 + .../androidMain/kotlin/su/reya/coop/App.kt | 2 +- .../kotlin/su/reya/coop/Navigation.kt | 4 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 288 ++++++++++++++---- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 13 +- .../su/reya/coop/screens/ProfileScreen.kt | 15 +- .../su/reya/coop/screens/RequestListScreen.kt | 7 +- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 104 ++++++- .../kotlin/su/reya/coop/NostrViewModel.kt | 28 ++ 13 files changed, 414 insertions(+), 86 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/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index ed281a6..47f75ba 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -178,7 +178,7 @@ fun App(viewModel: NostrViewModel) { NewIdentityScreen() } entry { key -> - ChatScreen(id = key.id) + ChatScreen(id = key.id, screening = key.screening) } entry { NewChatScreen() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 6a53f67..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,7 +27,7 @@ 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) : 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..fde4acf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -22,11 +22,15 @@ 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.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.SnackbarHost @@ -36,39 +40,50 @@ 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_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 +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.humanReadable import su.reya.coop.roomId 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 @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 +107,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 +171,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 +222,231 @@ 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() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { navigator.goBack() }, + modifier = Modifier.weight(1f) + ) { + Text( + text = "Reject", + style = MaterialTheme.typography.titleMedium, + ) + } + FilledTonalButton( + 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 = "" + } + ) + } + } } } } ) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@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(pubkey) { + 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 } + } + } + + 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 = picture, + description = "Profile picture", + modifier = Modifier.size(120.dp), + shape = MaterialShapes.Cookie12Sided.toShape(), + ) + 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(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( @@ -345,7 +524,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..f1e75b6 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), @@ -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/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index 2b10250..87ffd06 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.toShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -37,6 +36,7 @@ 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 @@ -55,26 +55,27 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfileScreen(pubkey: String) { - val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - 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 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 + 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"), ) } @@ -83,7 +84,7 @@ fun ProfileScreen(pubkey: String) { snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { }, + title = { /* empty */ }, navigationIcon = { IconButton(onClick = { navigator.goBack() }) { Icon( 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..3e2a67c 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() }) { @@ -143,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 5baa07e..0ef6116 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()) @@ -373,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")) ) @@ -722,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 @@ -739,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] = @@ -947,4 +970,61 @@ class Nostr { throw IllegalStateException("Failed to search nostr: ${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) + } + } + + 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(currentUser).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 b324b9c..052c5d9 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,33 @@ class NostrViewModel( } return emptyList() } + + suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { + return try { + nostr.verifyActivity(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + 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 {