add screener card

This commit is contained in:
2026-06-18 14:38:23 +07:00
parent 7a31749b4f
commit cde3df98f2
9 changed files with 204 additions and 136 deletions

View File

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

View File

@@ -12,7 +12,7 @@ sealed interface Screen : NavKey {
return when (data.host) { return when (data.host) {
// Matches coop://chat/{id} // 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} // Matches coop://profile/{pubkey}
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) } "profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
else -> null else -> null
@@ -27,10 +27,10 @@ sealed interface Screen : NavKey {
data object RequestList : Screen data object RequestList : Screen
@Serializable @Serializable
data class Chat(val id: Long) : Screen data class Chat(val id: Long, val screening: Boolean = false) : Screen
@Serializable @Serializable
data class Profile(val pubkey: String, val screening: Boolean = false) : Screen data class Profile(val pubkey: String) : Screen
@Serializable @Serializable
data object ContactList : Screen 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -36,39 +40,45 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroupHeader
import su.reya.coop.roomId import su.reya.coop.roomId
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow import su.reya.coop.shared.pictureFlow
@Composable @Composable
fun ChatScreen(id: Long) { fun ChatScreen(id: Long, screening: Boolean = false) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
@@ -92,12 +102,13 @@ fun ChatScreen(id: Long) {
return return
} }
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...") val displayName by remember(room) { room!!.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null) val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) } var newOtherMessages by remember { mutableIntStateOf(0) }
var requireScreening by remember { mutableStateOf(screening) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val messages = remember { mutableStateListOf<UnsignedEvent>() } val messages = remember { mutableStateListOf<UnsignedEvent>() }
@@ -155,9 +166,7 @@ fun ChatScreen(id: Long) {
} }
) { ) {
if (loading) { if (loading) {
LoadingIndicator( LoadingIndicator(modifier = Modifier.size(32.dp))
modifier = Modifier.size(32.dp),
)
} else { } else {
Avatar( Avatar(
picture = picture, picture = picture,
@@ -208,7 +217,12 @@ fun ChatScreen(id: Long) {
.fillMaxSize() .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
) { ) {
if (messages.isNotEmpty()) { if (requireScreening) {
room?.let { ScreenerCard(it) }
}
when (messages.isNotEmpty()) {
true -> {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -228,7 +242,9 @@ fun ChatScreen(id: Long) {
} }
} }
} }
} else { }
false -> {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .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( ChatInput(
value = text, value = text,
onValueChange = { text = it }, 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 @Composable
fun DateSeparator(date: String) { fun DateSeparator(date: String) {
Box( Box(
@@ -345,7 +476,6 @@ fun ChatInput(
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit onSend: () -> Unit
) { ) {
Surface(modifier = Modifier.fillMaxWidth()) { Surface(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -100,8 +100,8 @@ import su.reya.coop.RoomKind
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.ago import su.reya.coop.ago
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow import su.reya.coop.shared.pictureFlow
import su.reya.coop.short import su.reya.coop.short
@@ -627,11 +627,11 @@ fun NewRequests(requests: List<Room>) {
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstName by remember(firstRoom) { val firstName by remember(firstRoom) {
firstRoom?.displayNameFlow(viewModel) ?: flowOf("") firstRoom?.nameFlow(viewModel) ?: flowOf("")
}.collectAsStateWithLifecycle("Loading...") }.collectAsStateWithLifecycle("Loading...")
val secondName by remember(secondRoom) { val secondName by remember(secondRoom) {
secondRoom?.displayNameFlow(viewModel) ?: flowOf("") secondRoom?.nameFlow(viewModel) ?: flowOf("")
}.collectAsStateWithLifecycle("") }.collectAsStateWithLifecycle("")
val supportingText = when { val supportingText = when {
@@ -704,8 +704,8 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -24,15 +23,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -47,7 +44,6 @@ import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@@ -58,7 +54,7 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ProfileScreen(pubkey: String, screening: Boolean = false) { fun ProfileScreen(pubkey: String) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.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( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
CenterAlignedTopAppBar( TopAppBar(
title = { title = { /* empty */ },
if (screening) {
Text(
text = "Screening",
style = MaterialTheme.typography.titleMediumEmphasized
)
}
},
navigationIcon = { navigationIcon = {
IconButton(onClick = { navigator.goBack() }) { IconButton(onClick = { navigator.goBack() }) {
Icon( 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 -> items(requests.toList(), key = { it.id }) { room ->
ChatRoom( ChatRoom(
room = room, 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.Room
import su.reya.coop.short 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 // Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) } 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 (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
val pubkeys = event.tags().publicKeys(); val pubkeys = event.tags().publicKeys()
// Get mutual contacts // Get mutual contacts
getMutualContacts(pubkeys) getMutualContacts(pubkeys)
// Emit contact list update // Emit contact list update
@@ -367,7 +367,7 @@ class Nostr {
private suspend fun getMutualContacts(pubkeys: List<PublicKey>) { private suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
try { 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 filter = Filter().kind(kind).authors(pubkeys).limit(200u)
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) 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? { suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
try { try {
val filter = Filter().author(pubkey).limit(3u) val filter = Filter().author(pubkey).limit(3u)

View File

@@ -822,15 +822,6 @@ class NostrViewModel(
return emptyList() 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? { suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
return try { return try {
nostr.verifyActivity(pubkey) nostr.verifyActivity(pubkey)