diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 3dc0cc9..8185f79 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -52,6 +52,7 @@ import su.reya.coop.screens.MyQrScreen import su.reya.coop.screens.NewChatScreen import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.OnboardingScreen +import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.ScanScreen val LocalNostrViewModel = staticCompositionLocalOf { @@ -124,7 +125,7 @@ fun App() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { }, + onDismissRequest = { viewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -242,6 +243,11 @@ fun App() { onBack = { navController.popBackStack() }, ) } + composable { backStackEntry -> + RelayScreen( + onBack = { navController.popBackStack() }, + ) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 15bf958..1b4ddb4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -26,4 +26,7 @@ sealed interface Screen { @Serializable data object MyQr : Screen + + @Serializable + data object Relay : Screen } 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 4f26a3e..c6ec696 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -1,6 +1,5 @@ package su.reya.coop.screens -import android.content.ClipData import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -60,7 +59,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res @@ -269,11 +267,20 @@ fun HomeScreen( ) { val pubkey = viewModel.currentUser() val shortPubkey = pubkey?.short() ?: "Not available" + val userName = userProfile?.asRecord()?.displayName ?: userProfile?.asRecord()?.name ?: "No name" + val dismissAndRun: (suspend () -> Unit) -> Unit = { action -> + scope.launch { + sheetState.hide() + showBottomSheet = false + action() + } + } + Column( modifier = Modifier .padding(16.dp) @@ -311,13 +318,7 @@ fun HomeScreen( ) { OutlinedButton( onClick = { - scope.launch { - if (pubkey != null) { - val text = pubkey.toBech32(); - val entry = ClipData.newPlainText("text", text) - clipboard.setClipEntry(entry.toClipEntry()) - } - } + dismissAndRun { navController.navigate(Screen.MyQr) } }, ) { Text(text = shortPubkey) @@ -340,7 +341,7 @@ fun HomeScreen( } } Spacer(modifier = Modifier.size(16.dp)) - BottomMenuList() + BottomMenuList(onDismiss = dismissAndRun) } } } @@ -394,15 +395,17 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun BottomMenuList() { +fun BottomMenuList( + onDismiss: (suspend () -> Unit) -> Unit +) { + val navController = LocalNavController.current val viewModel = LocalNostrViewModel.current val defaultMenuList = listOf( - "Messaging Relays" to { }, - "Spam Filter" to { }, + "Relay Management" to { navController.navigate(Screen.Relay) }, + "Spams & Blocks" to { }, "Contacts" to { }, - "Settings" to { }, - "About" to { } + "Settings" to { } ) Column( @@ -415,7 +418,7 @@ fun BottomMenuList() { ) { defaultMenuList.forEachIndexed { index, (title, action) -> SegmentedListItem( - onClick = { action() }, + onClick = { onDismiss { action() } }, shapes = ListItemDefaults.segmentedShapes( index = index, count = defaultMenuList.size diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt new file mode 100644 index 0000000..4a25f8d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -0,0 +1,236 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalSnackbarHostState + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RelayScreen( + onBack: () -> Unit +) { + val snackbarHostState = LocalSnackbarHostState.current + val viewModel = LocalNostrViewModel.current + + val msgRelayList = remember { mutableStateListOf() } + val relayList = remember { mutableStateMapOf() } + + val inboxRelays by remember { + derivedStateOf { + relayList.filter { it.value == RelayMetadata.READ || it.value == null }.keys.toList() + } + } + + val outboxRelays by remember { + derivedStateOf { + relayList.filter { it.value == RelayMetadata.WRITE || it.value == null }.keys.toList() + } + } + + LaunchedEffect(Unit) { + relayList.putAll(viewModel.currentUserRelayList()) + msgRelayList.addAll(viewModel.currentUserMsgRelayList()) + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + title = { + Text( + text = "Relay Management", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + } + ) + }, + content = { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Messaging Relay List", + style = MaterialTheme.typography.titleMediumEmphasized, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + if (msgRelayList.isNotEmpty()) { + msgRelayList.forEachIndexed { index, relayUrl -> + SegmentedListItem( + onClick = { }, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = msgRelayList.size + ), + content = { Text(text = relayUrl.toString()) }, + ) + } + } else { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No relays configured", + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + } + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Inbox Relays", + style = MaterialTheme.typography.titleMediumEmphasized, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + if (inboxRelays.isNotEmpty()) { + inboxRelays.forEachIndexed { index, relayUrl -> + SegmentedListItem( + onClick = { }, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = inboxRelays.size + ), + content = { Text(text = relayUrl.toString()) }, + ) + } + } else { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No relays configured", + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + } + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Outbox Relays", + style = MaterialTheme.typography.titleMediumEmphasized, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + if (outboxRelays.isNotEmpty()) { + outboxRelays.forEachIndexed { index, relayUrl -> + SegmentedListItem( + onClick = { }, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = outboxRelays.size + ), + content = { Text(text = relayUrl.toString()) }, + ) + } + } else { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No relays configured", + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + } + } + } + } + } + } + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 4dc2d8e..54b5a70 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -51,6 +51,7 @@ import rust.nostr.sdk.TagKind import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift +import rust.nostr.sdk.extractRelayList import rust.nostr.sdk.giftWrapAsync import rust.nostr.sdk.initLogger import rust.nostr.sdk.nip17ExtractRelayList @@ -611,6 +612,18 @@ class Nostr { } } + suspend fun getRelayList(publicKey: PublicKey): Map { + try { + val kind = Kind.fromStd(KindStandard.RELAY_LIST) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get relay list: ${e.message}", e) + } + } + suspend fun getChatRooms(): Set? { try { val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index aeb2087..3699031 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -25,6 +25,7 @@ import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent @@ -386,6 +387,24 @@ class NostrViewModel( } } + suspend fun currentUserRelayList(): Map { + try { + return nostr.getRelayList(nostr.signer.currentUser!!) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyMap() + } + } + + suspend fun currentUserMsgRelayList(): List { + try { + return nostr.getMsgRelays(nostr.signer.currentUser!!) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyList() + } + } + fun createChatRoom(to: List): Long { if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")