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() } }