chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
4 changed files with 164 additions and 8 deletions
Showing only changes of commit a7215c7283 - Show all commits

View File

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

View File

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

View File

@@ -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")

View File

@@ -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")