forked from reya/coop-mobile
feat: Relay Management (#19)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/19
This commit is contained in:
@@ -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<RelayUrl>() }
|
||||
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
||||
|
||||
@@ -60,6 +92,9 @@ fun RelayScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||
var relayToDelete by remember { mutableStateOf<String?>(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() {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user