feat: screener for message requests (#24)

Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
2026-06-19 09:06:49 +00:00
parent 91e4e3b43d
commit 154906017e
13 changed files with 414 additions and 86 deletions

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM480,800Q534,800 584,782.5Q634,765 676,732L228,284Q195,326 177.5,376Q160,426 160,480Q160,614 253,707Q346,800 480,800ZM732,676Q765,634 782.5,584Q800,534 800,480Q800,346 707,253Q614,160 480,160Q426,160 376,177.5Q326,195 284,228L732,676ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM366,650L480,536L594,650L650,594L536,480L650,366L594,310L480,424L366,310L310,366L424,480L310,594L366,650ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
</vector>

View File

@@ -178,7 +178,7 @@ fun App(viewModel: NostrViewModel) {
NewIdentityScreen()
}
entry<Screen.Chat> { key ->
ChatScreen(id = key.id)
ChatScreen(id = key.id, screening = key.screening)
}
entry<Screen.NewChat> {
NewChatScreen()

View File

@@ -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

View File

@@ -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<UnsignedEvent>() }
@@ -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,7 +222,12 @@ fun ChatScreen(id: Long) {
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (messages.isNotEmpty()) {
if (requireScreening) {
room?.let { ScreenerCard(it) }
}
when (messages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
.weight(1f)
@@ -228,7 +247,9 @@ fun ChatScreen(id: Long) {
}
}
}
} else {
}
false -> {
Box(
modifier = Modifier
.weight(1f)
@@ -254,6 +275,38 @@ fun ChatScreen(id: Long) {
}
}
}
}
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 },
@@ -265,9 +318,135 @@ fun ChatScreen(id: Long) {
}
}
}
}
}
)
}
@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<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(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

View File

@@ -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<Room>) {
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<Room>) {
@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,
)
}
},

View File

@@ -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(

View File

@@ -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)) }
)
}
}

View File

@@ -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<String> {
fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
// Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }

View File

@@ -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<PublicKey>) {
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<RelayUrl, List<Filter>>()
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<RelayUrl, List<Filter>>()
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<PublicKey> {
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<PublicKey>()
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)
}
}
}

View File

@@ -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<PublicKey> {
return try {
nostr.mutualContacts(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
setOf()
}
}
}
fun PublicKey.short(): String {