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/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
index 6c16207..559ebf6 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -360,103 +359,100 @@ 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(
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..419d986 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,63 @@ 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.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.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
@@ -60,6 +89,8 @@ fun RelayScreen() {
}
}
+ var openAddRelayDialog by remember { mutableStateOf(false) }
+
LaunchedEffect(Unit) {
relayList.putAll(viewModel.currentUserRelayList())
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
@@ -86,9 +117,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
@@ -233,4 +288,165 @@ fun RelayScreen() {
}
}
)
-}
\ No newline at end of file
+
+ if (openAddRelayDialog) {
+ AddRelayDialog(onDismissRequest = { openAddRelayDialog = false })
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun AddRelayDialog(onDismissRequest: () -> 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)
+ "Inbox" -> viewModel.addInboxRelay(relayAddress)
+ "Outbox" -> viewModel.addOutboxRelay(relayAddress)
+ }
+ 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 746c9b9..7311152 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
@@ -670,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")
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
index bc1f7b6..8fccde8 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -547,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!!)
@@ -556,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")