diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_check.xml b/composeApp/src/androidMain/composeResources/drawable/ic_check.xml
new file mode 100644
index 0000000..65443e0
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_check.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
index 6eefaf2..bd27100 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
@@ -6,23 +6,11 @@ import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
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.MotionScheme
import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
@@ -38,13 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
@@ -54,7 +36,6 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
-import kotlinx.coroutines.launch
import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen
@@ -95,7 +76,6 @@ fun App(viewModel: NostrViewModel) {
val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
- val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
@@ -219,61 +199,6 @@ fun App(viewModel: NostrViewModel) {
}
}
)
-
- // Show the relay setup dialog if the msg relay list is empty
- if (isRelayListEmpty) {
- ModalBottomSheet(
- onDismissRequest = { viewModel.dismissRelayWarning() },
- 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,
- )
- }
- }
- }
- }
}
}
}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt
index 58b016e..6c306eb 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt
@@ -55,7 +55,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send
-import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
@@ -67,7 +66,6 @@ import su.reya.coop.roomId
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
-import su.reya.coop.short
@Composable
fun ChatScreen(id: Long) {
@@ -77,9 +75,7 @@ fun ChatScreen(id: Long) {
// Get chat room by ID
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
- val room by remember(id) {
- derivedStateOf { chatRooms.firstOrNull { it.id == id } }
- }
+ val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen
if (room == null) {
@@ -88,7 +84,7 @@ fun ChatScreen(id: Long) {
contentAlignment = Alignment.Center
) {
Text(
- text = "Chat room not found",
+ text = "Something went wrong.",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
@@ -114,23 +110,14 @@ fun ChatScreen(id: Long) {
// Start loading spinner
loading = true
+ // Get msg relays for each member
+ viewModel.chatRoomConnect(id)
+
// Get messages
val initialMessages = viewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
-
- // Get msg relays for each member
- val results = viewModel.chatRoomConnect(id)
- results.forEach { (member, relays) ->
- if (relays.isNotEmpty()) {
- val metadata = viewModel.getMetadata(member).first { it != null }
- val profile = metadata?.asRecord()
- val name = profile?.displayName ?: profile?.name ?: member.short()
-
- snackbarHostState.showSnackbar("Connected to messaging relays for $name")
- }
- }
-
+
// Stop loading spinner
loading = false
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 3c85ade..559ebf6 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
@@ -13,9 +13,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -72,12 +73,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
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.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
+import coop.composeapp.generated.resources.ic_close
import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_scanner
@@ -93,6 +97,7 @@ import su.reya.coop.Screen
import su.reya.coop.ago
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
+import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@@ -111,17 +116,19 @@ fun HomeScreen() {
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
+ val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope()
- val sheetState = rememberModalBottomSheetState()
+ val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
+ var isBusy by remember { mutableStateOf(false) }
var isNotificationEnabled by remember {
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
@@ -352,102 +359,248 @@ fun HomeScreen() {
}
}
}
-
- if (showBottomSheet) {
- ModalBottomSheet(
- onDismissRequest = { showBottomSheet = false },
- sheetState = sheetState,
- modifier = Modifier
- .imePadding()
- .navigationBarsPadding(),
- ) {
- 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)
- .fillMaxWidth(),
- ) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Box(
- modifier = Modifier
- .size(84.dp)
- .clip(MaterialShapes.Cookie9Sided.toShape()),
- contentAlignment = Alignment.Center
- ) {
- Avatar(
- picture = userProfile?.asRecord()?.picture,
- description = userProfile?.asRecord()?.displayName,
- shape = MaterialShapes.Cookie9Sided.toShape(),
- modifier = Modifier.fillMaxSize()
- )
- }
- Spacer(modifier = Modifier.size(8.dp))
- Box(
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = userName,
- style = MaterialTheme.typography.titleLargeEmphasized,
- )
- }
- Spacer(modifier = Modifier.size(8.dp))
- Row(
- verticalAlignment = Alignment.CenterVertically,
- ) {
- OutlinedButton(
- onClick = {
- scope.launch {
- pubkey?.let {
- val bech32 = it.toBech32()
- val data =
- ClipData.newPlainText(bech32, bech32)
- clipboardManager.setClipEntry(ClipEntry(data))
- }
- }
- },
- ) {
- Text(text = shortPubkey)
- }
- FilledIconButton(
- onClick = {
- dismissAndRun { navigator.navigate(Screen.MyQr) }
- },
- shape = MaterialShapes.Square.toShape()
- ) {
- Icon(
- painter = painterResource(Res.drawable.ic_qr),
- contentDescription = "My QR"
- )
- }
- }
- }
- Spacer(modifier = Modifier.size(16.dp))
- BottomMenuList(onDismiss = dismissAndRun)
- }
- }
- }
}
}
},
)
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showBottomSheet = false },
+ sheetState = sheetState,
+ ) {
+ 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)
+ .fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(84.dp)
+ .clip(MaterialShapes.Cookie9Sided.toShape()),
+ contentAlignment = Alignment.Center
+ ) {
+ Avatar(
+ picture = userProfile?.asRecord()?.picture,
+ description = userProfile?.asRecord()?.displayName,
+ shape = MaterialShapes.Cookie9Sided.toShape(),
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = userName,
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedButton(
+ onClick = {
+ scope.launch {
+ pubkey?.let {
+ val bech32 = it.toBech32()
+ val data =
+ ClipData.newPlainText(bech32, bech32)
+ clipboardManager.setClipEntry(ClipEntry(data))
+ }
+ }
+ },
+ ) {
+ Text(text = shortPubkey)
+ }
+ FilledIconButton(
+ onClick = {
+ dismissAndRun { navigator.navigate(Screen.MyQr) }
+ },
+ shape = MaterialShapes.Square.toShape()
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_qr),
+ contentDescription = "My QR"
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.size(16.dp))
+ BottomMenuList(onDismiss = dismissAndRun)
+ }
+ }
+ }
+
+ // Show the relay setup dialog if the msg relay list is empty
+ if (isRelayListEmpty) {
+ ModalBottomSheet(
+ onDismissRequest = { viewModel.dismissRelayWarning() },
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.5f)
+ .padding(horizontal = 24.dp)
+ .navigationBarsPadding(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Messaging Relays are missing",
+ style = MaterialTheme.typography.titleLargeEmphasized.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = getExpressiveFontFamily()
+ ),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Surface(
+ modifier = Modifier.size(24.dp),
+ shape = MaterialShapes.Circle.toShape(),
+ color = MaterialTheme.colorScheme.error,
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_close),
+ contentDescription = "X",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onError
+ )
+ }
+ }
+ Text(
+ text = "Other people won't be able to send you messages.",
+ style = MaterialTheme.typography.titleSmallEmphasized,
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Surface(
+ modifier = Modifier.size(24.dp),
+ shape = MaterialShapes.Circle.toShape(),
+ color = MaterialTheme.colorScheme.error,
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_close),
+ contentDescription = "X",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onError
+ )
+ }
+ }
+ Text(
+ text = "You cannot store your messages.",
+ style = MaterialTheme.typography.titleSmallEmphasized,
+ )
+ }
+ 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.bodySmall.copy(
+ fontStyle = FontStyle.Italic,
+ ),
+ )
+ Text(
+ text = "If you believe this is a mistake, please click the Retry button to check again.",
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontStyle = FontStyle.Italic,
+ ),
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ if (isBusy) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ } else {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TextButton(
+ enabled = !isBusy,
+ onClick = {
+ scope.launch {
+ isBusy = true
+ try {
+ viewModel.refetchMsgRelays(currentUser)
+ } catch (e: Exception) {
+ snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
+ }
+ isBusy = false
+ }
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(ButtonDefaults.MediumContainerHeight),
+ ) {
+ Text(
+ text = "Retry",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ }
+ Button(
+ enabled = !isBusy,
+ onClick = {
+ scope.launch {
+ viewModel.useDefaultMsgRelayList()
+ sheetState.hide()
+ }
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(ButtonDefaults.MediumContainerHeight),
+ ) {
+ Text(
+ text = "Use Default",
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt
index 847df9d..8c23271 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt
@@ -3,34 +3,65 @@ 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.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.RadioButton
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.TextButton
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTooltipState
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.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
+import coop.composeapp.generated.resources.ic_check
+import coop.composeapp.generated.resources.ic_close
+import coop.composeapp.generated.resources.ic_plus
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
@@ -45,6 +76,7 @@ fun RelayScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
+ val scope = rememberCoroutineScope()
val msgRelayList = remember { mutableStateListOf() }
val relayList = remember { mutableStateMapOf() }
@@ -60,6 +92,9 @@ fun RelayScreen() {
}
}
+ var openAddRelayDialog by remember { mutableStateOf(false) }
+ var relayToDelete by remember { mutableStateOf(null) }
+
LaunchedEffect(Unit) {
relayList.putAll(viewModel.currentUserRelayList())
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
@@ -86,9 +121,33 @@ fun RelayScreen() {
contentDescription = "Back"
)
}
- }
+ },
)
},
+ floatingActionButton = {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
+ TooltipAnchorPosition.Above,
+ spacingBetweenTooltipAndAnchor = 8.dp,
+ ),
+ tooltip = {
+ PlainTooltip { Text("New Relay") }
+ },
+ state = rememberTooltipState(),
+ ) {
+ ExtendedFloatingActionButton(
+ onClick = { openAddRelayDialog = true },
+ expanded = false,
+ icon = {
+ Icon(
+ painter = painterResource(Res.drawable.ic_plus),
+ contentDescription = "New Relay"
+ )
+ },
+ text = { Text("New Relay") },
+ )
+ }
+ },
content = { innerPadding ->
Column(
modifier = Modifier
@@ -113,7 +172,8 @@ fun RelayScreen() {
if (msgRelayList.isNotEmpty()) {
msgRelayList.forEachIndexed { index, relayUrl ->
SegmentedListItem(
- onClick = { },
+ onClick = { /* No action */ },
+ onLongClick = { relayToDelete = relayUrl.toString() },
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = msgRelayList.size
@@ -233,4 +293,223 @@ fun RelayScreen() {
}
}
)
-}
\ No newline at end of file
+
+ if (openAddRelayDialog) {
+ AddRelayDialog(
+ onDismissRequest = { openAddRelayDialog = false },
+ onMsgRelayAdded = { newRelay ->
+ msgRelayList.add(RelayUrl.parse(newRelay))
+ },
+ onRelayAdded = { newRelay, metadata ->
+ relayList[RelayUrl.parse(newRelay)] = metadata
+ }
+ )
+ }
+
+ if (relayToDelete != null) {
+ AlertDialog(
+ onDismissRequest = { relayToDelete = null },
+ title = { Text("Remove Relay") },
+ text = { Text("Are you sure you want to remove $relayToDelete?") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ scope.launch {
+ if (msgRelayList.size == 1) {
+ snackbarHostState.showSnackbar("You must have at least one relay")
+ relayToDelete = null
+ return@launch
+ }
+ try {
+ viewModel.removeMsgRelay(relayToDelete!!)
+ msgRelayList.removeIf { it.toString() == relayToDelete }
+ relayToDelete = null
+ } catch (e: Exception) {
+ snackbarHostState.showSnackbar("Failed to remove relay")
+ }
+ }
+ }
+ ) {
+ Text("Confirm")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { relayToDelete = null }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun AddRelayDialog(
+ onDismissRequest: () -> Unit,
+ onMsgRelayAdded: (newRelay: String) -> Unit,
+ onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
+) {
+ val viewModel = LocalNostrViewModel.current
+ val snackbarHostState = LocalSnackbarHostState.current
+
+ val scope = rememberCoroutineScope()
+ val focusRequester = remember { FocusRequester() }
+
+ var relayAddress by remember { mutableStateOf("") }
+ var isError by remember { mutableStateOf(false) }
+ val roles = listOf("Messaging", "Inbox", "Outbox")
+ val (selected, onSelected) = remember { mutableStateOf(roles[0]) }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Dialog(
+ onDismissRequest = { onDismissRequest() },
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnBackPress = true,
+ decorFitsSystemWindows = false,
+ ),
+ ) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ containerColor = MaterialTheme.colorScheme.surface,
+ topBar = {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ title = {
+ Text(
+ text = "New Relay",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { onDismissRequest() }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_close),
+ contentDescription = "Close"
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = {
+ scope.launch {
+ if (!isError) {
+ when (selected) {
+ "Messaging" -> {
+ viewModel.addMsgRelay(relayAddress)
+ onMsgRelayAdded(relayAddress)
+ }
+
+ "Inbox" -> {
+ viewModel.addInboxRelay(relayAddress)
+ onRelayAdded(relayAddress, RelayMetadata.WRITE)
+ }
+
+ "Outbox" -> {
+ viewModel.addOutboxRelay(relayAddress)
+ onRelayAdded(relayAddress, RelayMetadata.READ)
+ }
+ }
+ onDismissRequest()
+ }
+ }
+ }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_check),
+ contentDescription = "Add"
+ )
+ }
+ },
+ )
+ },
+ content = { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(innerPadding)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedTextField(
+ value = relayAddress,
+ onValueChange = { relayAddress = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ isError = isError,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ isError = relayAddress.isNotEmpty() && !verifyRelayUrl(relayAddress)
+ }
+ ),
+ singleLine = true,
+ label = { Text(text = "Relay Address") },
+ placeholder = { Text(text = "wss://relay.example.com") },
+ supportingText = {
+ if (isError) {
+ Text(text = "Invalid format. Must start with wss://")
+ } else {
+ Text(text = "Only add relays you trust.")
+ }
+ },
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = "Relay Roles",
+ style = MaterialTheme.typography.titleMediumEmphasized
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectableGroup(),
+ ) {
+ roles.forEach { text ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .selectable(
+ onClick = { onSelected(text) },
+ selected = (text == selected),
+ role = Role.RadioButton
+ )
+ .padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = (text == selected),
+ onClick = null
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ }
+}
+
+fun verifyRelayUrl(url: String): Boolean {
+ return try {
+ RelayUrl.parse(url)
+ true
+ } catch (e: Exception) {
+ println("Failed to parse relay url: ${e.message}")
+ false
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
index aad656c..7311152 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
@@ -190,11 +190,6 @@ class Nostr {
}
}
- suspend fun exit() {
- signer.switch(Keys.generate())
- deviceSigner = null
- }
-
suspend fun setSigner(new: AsyncNostrSigner) {
try {
signer.switch(new)
@@ -453,9 +448,10 @@ class Nostr {
client?.addRelay(
url = relay,
capabilities =
- if (metadata == RelayMetadata.READ) RelayCapabilities.read()
- else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
- else RelayCapabilities.none()
+ when (metadata) {
+ RelayMetadata.READ -> RelayCapabilities.read()
+ RelayMetadata.WRITE -> RelayCapabilities.write()
+ }
)
client?.connectRelay(relay)
}
@@ -466,7 +462,7 @@ class Nostr {
suspend fun getDefaultMsgRelayList(): List {
// Construct a list of messaging relays
val msgRelayList = listOf(
- RelayUrl.parse("wss://relay.0xchat.com"),
+ RelayUrl.parse("wss://auth.nostr1.com"),
RelayUrl.parse("wss://nip17.com"),
)
@@ -649,6 +645,19 @@ class Nostr {
}
}
+ suspend fun fetchMsgRelays(publicKey: PublicKey): List {
+ try {
+ val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
+ val filter = Filter().kind(kind).author(publicKey).limit(1u)
+ val target = ReqTarget.auto(listOf(filter))
+ val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
+
+ return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
+ }
+ }
+
suspend fun getRelayList(publicKey: PublicKey): Map {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
@@ -661,6 +670,20 @@ class Nostr {
}
}
+ suspend fun setRelaylist(relays: Map) {
+ try {
+ val event = EventBuilder.relayList(relays).finalizeAsync(signer)
+
+ client?.sendEvent(
+ event = event,
+ target = SendEventTarget.broadcast(),
+ ackPolicy = AckPolicy.none(),
+ )
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
+ }
+ }
+
suspend fun getChatRooms(): Set? {
try {
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
@@ -721,33 +744,25 @@ class Nostr {
}
}
- suspend fun chatRoomConnect(members: List): Map> {
+ suspend fun chatRoomConnect(members: List) {
try {
- val results = mutableMapOf>()
-
members.forEach { member ->
- results[member] = mutableListOf()
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(member).limit(1u)
val stream = client?.streamEvents(
target = ReqTarget.auto(listOf(filter)),
- id = "room-${member.toBech32().substring(0, 10)}",
+ id = null,
timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose
)
stream?.next()?.let { res ->
if (res.event != null) {
- // Connect to the msg relays
connectMsgRelays(res.event!!)
- // Mark the member as connected
- results[member]?.add(res.relayUrl)
}
}
}
-
- return results
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
}
@@ -757,10 +772,8 @@ class Nostr {
try {
val urls = nip17ExtractRelayList(event);
for (url in urls) {
- if (client?.relay(url) == null) {
- client?.addRelay(url)
- client?.connectRelay(url)
- }
+ client?.addRelay(url, RelayCapabilities.gossip())
+ client?.connectRelay(url)
}
} catch (e: Exception) {
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
index 0088e66..8fccde8 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -146,20 +146,6 @@ class NostrViewModel(
}
}
- private fun processIncomingEvent(event: UnsignedEvent) {
- val roomId = event.roomId()
- val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
-
- if (existingRoom == null) {
- nostr.signer.currentUser?.let { user ->
- val newRoom = Room.new(event, user)
- _chatRooms.update { (it + newRoom).sortedDescending().toSet() }
- }
- } else {
- updateRoomList(roomId, event)
- }
- }
-
private suspend fun runObserver() = coroutineScope {
// Observe new messages
launch {
@@ -298,13 +284,11 @@ class NostrViewModel(
nostr.getUserMetadata()
// Small delay to ensure all relays are connected
- delay(3000.milliseconds)
+ delay(2.seconds)
// Check if the relay list is empty
val relays = nostr.getMsgRelays(pubkey)
- if (relays.isEmpty()) {
- _isRelayListEmpty.value = true
- }
+ if (relays.isEmpty()) _isRelayListEmpty.value = true
break
}
@@ -540,6 +524,11 @@ class NostrViewModel(
return externalSignerHandler?.isAvailable() == true
}
+ suspend fun refetchMsgRelays(pubkey: PublicKey) {
+ val relays = nostr.fetchMsgRelays(pubkey)
+ if (relays.isNotEmpty()) dismissRelayWarning()
+ }
+
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.getDefaultMsgRelayList()
@@ -558,6 +547,42 @@ class NostrViewModel(
}
}
+ suspend fun addInboxRelay(relay: String) {
+ try {
+ val relayUrl = RelayUrl.parse(relay)
+ val relays = currentUserRelayList().toMutableMap()
+ relays[relayUrl] = RelayMetadata.WRITE
+
+ nostr.setRelaylist(relays)
+ } catch (e: Exception) {
+ showError("Error: ${e.message}")
+ }
+ }
+
+ suspend fun addOutboxRelay(relay: String) {
+ try {
+ val relayUrl = RelayUrl.parse(relay)
+ val relays = currentUserRelayList().toMutableMap()
+ relays[relayUrl] = RelayMetadata.READ
+
+ nostr.setRelaylist(relays)
+ } catch (e: Exception) {
+ showError("Error: ${e.message}")
+ }
+ }
+
+ suspend fun removeRelay(relay: String) {
+ try {
+ val relayUrl = RelayUrl.parse(relay)
+ val relays = currentUserRelayList().toMutableMap()
+ relays.remove(relayUrl)
+
+ nostr.setRelaylist(relays)
+ } catch (e: Exception) {
+ showError("Error: ${e.message}")
+ }
+ }
+
suspend fun currentUserMsgRelayList(): List {
try {
return nostr.getMsgRelays(nostr.signer.currentUser!!)
@@ -567,6 +592,30 @@ class NostrViewModel(
}
}
+ suspend fun addMsgRelay(relay: String) {
+ try {
+ val relayUrl = RelayUrl.parse(relay)
+ val relays = currentUserMsgRelayList().toMutableSet()
+ relays.add(relayUrl)
+
+ nostr.setMsgRelays(relays.toList())
+ } catch (e: Exception) {
+ showError("Error: ${e.message}")
+ }
+ }
+
+ suspend fun removeMsgRelay(relay: String) {
+ try {
+ val relayUrl = RelayUrl.parse(relay)
+ val relays = currentUserMsgRelayList().toMutableSet()
+ relays.remove(relayUrl)
+
+ nostr.setMsgRelays(relays.toList())
+ } catch (e: Exception) {
+ showError("Error: ${e.message}")
+ }
+ }
+
fun createChatRoom(to: List): Long {
try {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
@@ -644,20 +693,16 @@ class NostrViewModel(
return emptyList()
}
- suspend fun chatRoomConnect(roomId: Long): Map> {
- try {
- val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
- val members = room.members
+ fun chatRoomConnect(roomId: Long) {
+ viewModelScope.launch {
+ try {
+ val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
+ val members = room.members
- return runCatching {
nostr.chatRoomConnect(members.toList())
- }.getOrElse { e ->
+ } catch (e: Exception) {
showError("Error: ${e.message}")
- members.associateWith { emptyList() }
}
- } catch (e: Exception) {
- showError("Error: ${e.message}")
- return emptyMap()
}
}