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 {