add verify msg relay

This commit is contained in:
2026-05-21 18:25:04 +07:00
parent 39d899b249
commit a7215c7283
4 changed files with 164 additions and 8 deletions

View File

@@ -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<NavController> {
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() {
}
}
}
}
}

View File

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

View File

@@ -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<RelayUrl> {
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
// 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<RelayUrl>) {
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<RelayUrl> {
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<Room>? {
try {
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")

View File

@@ -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<UnsignedEvent>(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<PublicKey>): Long {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")