From a7215c72836c8201258711a1da381392e577fa54 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 21 May 2026 18:25:04 +0700 Subject: [PATCH] add verify msg relay --- .../androidMain/kotlin/su/reya/coop/App.kt | 84 ++++++++++++++++++- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 12 ++- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 39 ++++++++- .../kotlin/su/reya/coop/NostrViewModel.kt | 37 ++++++++ 4 files changed, 164 insertions(+), 8 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index a5f5223..9d505f3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,27 +1,49 @@ package su.reya.coop import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.expressiveLightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import kotlinx.coroutines.launch import su.reya.coop.coop.storage.SecretStore import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.HomeScreen @@ -43,11 +65,12 @@ val LocalNavController = staticCompositionLocalOf { error("No NavController provided") } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun App() { val context = LocalContext.current val navController = rememberNavController() + val scope = rememberCoroutineScope() val darkMode = isSystemInDarkTheme() // Snackbar @@ -82,6 +105,8 @@ fun App() { LocalNavController provides navController, ) { val emptySecret by viewModel.emptySecret.collectAsState(initial = null) + val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() + val sheetState = rememberModalBottomSheetState() LaunchedEffect(emptySecret) { // Navigate to the home screen if the secret is already set @@ -95,6 +120,61 @@ fun App() { // Show loading screen while initializing if (emptySecret == null) return@CompositionLocalProvider + // Show the relay setup dialog if the msg relay list is empty + if (isRelayListEmpty) { + ModalBottomSheet( + onDismissRequest = { }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.5f) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Messaging Relays are required", + style = MaterialTheme.typography.headlineSmallEmphasized.copy( + fontWeight = FontWeight.SemiBold, + ), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.", + style = MaterialTheme.typography.bodyLarge.copy( + fontStyle = FontStyle.Italic, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + scope.launch { + viewModel.useDefaultMsgRelayList() + sheetState.hide() + } + }, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.MediumContainerHeight), + ) { + Text( + text = "Continue", + style = MaterialTheme.typography.titleMediumEmphasized, + ) + } + } + } + } + NavHost( navController = navController, startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding @@ -159,4 +239,4 @@ fun App() { } } } -} \ No newline at end of file +} 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 89116fb..a05de92 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -60,6 +60,7 @@ 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 import coop.composeapp.generated.resources.ic_new_chat @@ -214,7 +215,7 @@ fun HomeScreen( ) } ) { - if (!isPartialProcessedGiftWrap && chatRooms.isEmpty()) { + if (!isPartialProcessedGiftWrap) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -226,10 +227,15 @@ fun HomeScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = "No chats yet", - style = MaterialTheme.typography.titleLargeEmphasized, + style = MaterialTheme.typography.titleLargeEmphasized.copy( + fontWeight = FontWeight.SemiBold + ), color = MaterialTheme.colorScheme.onSurface ) Text( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index bc58a27..4dc2d8e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -348,7 +348,7 @@ class Nostr { is RelayMessageEnum.EndOfStoredEvents -> { val subscriptionId = message.subscriptionId - if (subscriptionId == "messages") { + if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") { onSubscriptionClose() } } @@ -471,7 +471,7 @@ class Nostr { return relayList } - private suspend fun getMsgRelayList(): List { + suspend fun getDefaultMsgRelayList(): List { // Construct a list of messaging relays val msgRelayList = listOf( RelayUrl.parse("wss://relay.0xchat.com"), @@ -500,7 +500,7 @@ class Nostr { ) // Send messaging relay list event - val msgRelayList = getMsgRelayList() + val msgRelayList = getDefaultMsgRelayList() val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys) client?.sendEvent( @@ -578,6 +578,39 @@ class Nostr { } } + suspend fun setMsgRelays(urls: List) { + try { + val event = EventBuilder.nip17RelayList(urls).signAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none(), + ) + + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS); + val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u) + val target = ReqTarget.auto(listOf(filter)) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe(target = target, closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } + + suspend fun getMsgRelays(publicKey: PublicKey): List { + try { + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get msg relays: ${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 60a31fc..aeb2087 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,6 +7,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,6 +52,9 @@ class NostrViewModel( private val _isPartialProcessedGiftWrap = MutableStateFlow(false) val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() + private val _isRelayListEmpty = MutableStateFlow(false) + val isRelayListEmpty = _isRelayListEmpty.asStateFlow() + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() @@ -69,6 +73,7 @@ class NostrViewModel( startMetadataBatchHandler() getCacheMetadata() login() + observeSignerAndCheckRelays() } override fun onCleared() { @@ -202,6 +207,25 @@ class NostrViewModel( } } + private fun observeSignerAndCheckRelays() { + viewModelScope.launch { + while (true) { + val pubkey = nostr.signer.currentUser + + if (pubkey != null) { + delay(3000) + val relays = nostr.getMsgRelays(pubkey) + if (relays.isEmpty()) { + _isRelayListEmpty.value = true + } + break + } + + delay(1000) + } + } + } + private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { viewModelScope.launch { @@ -234,6 +258,10 @@ class NostrViewModel( } } + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + private suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get("app_keys") @@ -349,6 +377,15 @@ class NostrViewModel( } } + suspend fun useDefaultMsgRelayList() { + try { + val defaultRelays = nostr.getDefaultMsgRelayList() + nostr.setMsgRelays(defaultRelays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + 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")