add verify msg relay
This commit is contained in:
@@ -1,27 +1,49 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
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.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.expressiveLightColorScheme
|
import androidx.compose.material3.expressiveLightColorScheme
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
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.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.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.ChatScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
@@ -43,11 +65,12 @@ val LocalNavController = staticCompositionLocalOf<NavController> {
|
|||||||
error("No NavController provided")
|
error("No NavController provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val darkMode = isSystemInDarkTheme()
|
val darkMode = isSystemInDarkTheme()
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
@@ -82,6 +105,8 @@ fun App() {
|
|||||||
LocalNavController provides navController,
|
LocalNavController provides navController,
|
||||||
) {
|
) {
|
||||||
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
|
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
|
||||||
|
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
LaunchedEffect(emptySecret) {
|
LaunchedEffect(emptySecret) {
|
||||||
// Navigate to the home screen if the secret is already set
|
// Navigate to the home screen if the secret is already set
|
||||||
@@ -95,6 +120,61 @@ fun App() {
|
|||||||
// Show loading screen while initializing
|
// Show loading screen while initializing
|
||||||
if (emptySecret == null) return@CompositionLocalProvider
|
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(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
||||||
@@ -159,4 +239,4 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.toClipEntry
|
import androidx.compose.ui.platform.toClipEntry
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
@@ -214,7 +215,7 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (!isPartialProcessedGiftWrap && chatRooms.isEmpty()) {
|
if (!isPartialProcessedGiftWrap) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -226,10 +227,15 @@ fun HomeScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No chats yet",
|
text = "No chats yet",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ class Nostr {
|
|||||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||||
val subscriptionId = message.subscriptionId
|
val subscriptionId = message.subscriptionId
|
||||||
|
|
||||||
if (subscriptionId == "messages") {
|
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
|
||||||
onSubscriptionClose()
|
onSubscriptionClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,7 +471,7 @@ class Nostr {
|
|||||||
return relayList
|
return relayList
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getMsgRelayList(): List<RelayUrl> {
|
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||||
// Construct a list of messaging relays
|
// Construct a list of messaging relays
|
||||||
val msgRelayList = listOf(
|
val msgRelayList = listOf(
|
||||||
RelayUrl.parse("wss://relay.0xchat.com"),
|
RelayUrl.parse("wss://relay.0xchat.com"),
|
||||||
@@ -500,7 +500,7 @@ class Nostr {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Send messaging relay list event
|
// Send messaging relay list event
|
||||||
val msgRelayList = getMsgRelayList()
|
val msgRelayList = getDefaultMsgRelayList()
|
||||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
|
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
|
||||||
|
|
||||||
client?.sendEvent(
|
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>? {
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
try {
|
try {
|
||||||
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -51,6 +52,9 @@ class NostrViewModel(
|
|||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
|
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
@@ -69,6 +73,7 @@ class NostrViewModel(
|
|||||||
startMetadataBatchHandler()
|
startMetadataBatchHandler()
|
||||||
getCacheMetadata()
|
getCacheMetadata()
|
||||||
login()
|
login()
|
||||||
|
observeSignerAndCheckRelays()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
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) {
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
if (seenPublicKeys.add(pubkey)) {
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -234,6 +258,10 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
private suspend fun getOrInitAppKeys(): Keys {
|
||||||
val secret = secretStore.get("app_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 {
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||||
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
|||||||
Reference in New Issue
Block a user