feat: Relay Management (#19)
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
|
||||
</vector>
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,14 +359,15 @@ fun HomeScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
val pubkey = viewModel.currentUser()
|
||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||
@@ -444,11 +452,156 @@ fun HomeScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@Composable
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<RelayUrl> {
|
||||
// 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<RelayUrl> {
|
||||
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<RelayUrl, RelayMetadata?> {
|
||||
try {
|
||||
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||
@@ -661,6 +670,20 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||
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<Room>? {
|
||||
try {
|
||||
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
@@ -721,33 +744,25 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun chatRoomConnect(members: List<PublicKey>): Map<PublicKey, List<RelayUrl>> {
|
||||
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||
try {
|
||||
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
|
||||
|
||||
members.forEach { member ->
|
||||
results[member] = mutableListOf<RelayUrl>()
|
||||
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,11 +772,9 @@ class Nostr {
|
||||
try {
|
||||
val urls = nip17ExtractRelayList(event);
|
||||
for (url in urls) {
|
||||
if (client?.relay(url) == null) {
|
||||
client?.addRelay(url)
|
||||
client?.addRelay(url, RelayCapabilities.gossip())
|
||||
client?.connectRelay(url)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -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<RelayUrl> {
|
||||
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<PublicKey>): 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<PublicKey, List<RelayUrl>> {
|
||||
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 ->
|
||||
showError("Error: ${e.message}")
|
||||
members.associateWith { emptyList() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user