feat: screener for message requests #24

Merged
reya merged 6 commits from feat/screening into master 2026-06-19 09:06:50 +00:00
9 changed files with 204 additions and 136 deletions
Showing only changes of commit cde3df98f2 - Show all commits

View File

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

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

View File

@@ -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<UnsignedEvent>() }
@@ -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,7 +217,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 +242,9 @@ fun ChatScreen(id: Long) {
}
}
}
} else {
}
false -> {
Box(
modifier = Modifier
.weight(1f)
@@ -254,6 +270,36 @@ fun ChatScreen(id: Long) {
}
}
}
}
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 },
@@ -265,9 +311,94 @@ fun ChatScreen(id: Long) {
}
}
}
}
}
)
}
@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(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

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),

View File

@@ -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<Timestamp?>(null) }
var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(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
}
}
}
}
}

View File

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

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,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<PublicKey>) {
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)

View File

@@ -822,15 +822,6 @@ 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)