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