diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 66d837e82d..68fab013a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent @@ -230,10 +231,11 @@ fun uriToRoute( } is NAddress -> { - routeFor( - note = LocalCache.getOrCreateAddressableNote(nip19.address()), - loggedIn = account, - ) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag()) + relayGroupDirectRoute(nip19) + ?: routeFor( + note = LocalCache.getOrCreateAddressableNote(nip19.address()), + loggedIn = account, + ) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag()) } is NEmbed -> { @@ -311,3 +313,15 @@ private fun calendarDirectRoute(nip19: NAddress): Route? = -> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag) else -> null } + +/** + * Direct route for a NIP-29 group `naddr` (kind 39000) shared as a link. The group + * only exists on its host relay, so the naddr's relay hint is mandatory — the group + * id alone can't be resolved. With a hint we open the group chat straight away + * (cold start included); without one there's nowhere to look, so fall through. + */ +private fun relayGroupDirectRoute(nip19: NAddress): Route? { + if (nip19.kind != GroupMetadataEvent.KIND) return null + val relay = nip19.relay.firstOrNull() ?: return null + return Route.RelayGroup(nip19.dTag, relay.url) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 4ad8e382ed..b7ab28a34f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemC import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupBrowseScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen @@ -586,6 +587,13 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupBrowseScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 003dd924cc..4201f5b486 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -653,6 +653,8 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable object RelayGroupBrowse : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt new file mode 100644 index 0000000000..48100715db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * A handful of widely-used public NIP-29 relays to seed discovery for a user who + * hasn't joined any group yet. Not exhaustive and not an endorsement — just a + * starting point; anyone can paste any relay URL above. + */ +private val POPULAR_RELAYS = + listOf( + "wss://groups.0xchat.com", + "wss://communities.nos.social", + ) + +/** + * Discovery entry point for NIP-29 groups: paste any relay URL to browse the + * channels it hosts (opening [Route.RelayGroupServer]), pick from relays you're + * already on, or try a popular public relay. This is the "find new groups" flow + * that the joined-only [RelayGroupServerList] can't offer. + */ +@Composable +fun RelayGroupBrowseScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var relayUrl by remember { mutableStateOf("") } + + val joined by accountViewModel.account.relayGroupList.liveRelayGroupServers + .collectAsStateWithLifecycle() + + fun open(url: String) { + val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) ?: return + nav.nav(Route.RelayGroupServer(normalized.url)) + } + + Scaffold( + topBar = { + TopBarExtensibleWithBackButton( + title = { + Text( + text = stringRes(R.string.relay_group_browse_title), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + popBack = nav::popBack, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringRes(R.string.relay_group_browse_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + OutlinedTextField( + value = relayUrl, + onValueChange = { relayUrl = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_browse_relay_label)) }, + placeholder = { Text("wss://…") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { open(relayUrl) }), + modifier = Modifier.weight(1f), + ) + } + + Button( + onClick = { open(relayUrl) }, + enabled = relayUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(stringRes(R.string.relay_group_browse_go)) + } + + if (joined.isNotEmpty()) { + SectionHeader(stringRes(R.string.relay_group_browse_your_relays)) + joined.sorted().forEach { server -> + RelayGroupServerRow(server, accountViewModel) { open(server) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + + val suggestions = POPULAR_RELAYS.filter { it !in joined } + if (suggestions.isNotEmpty()) { + SectionHeader(stringRes(R.string.relay_group_browse_popular)) + suggestions.forEach { server -> + RelayGroupServerRow(server, accountViewModel) { open(server) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index 1cae9ff857..4e49307b8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -45,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -62,18 +64,47 @@ fun RelayGroupServerList( val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers .collectAsStateWithLifecycle() - if (servers.isEmpty()) return - Column(Modifier.fillMaxWidth()) { servers.sorted().forEach { server -> - RelayServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) } + RelayGroupServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) } HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } + + // Always offer a discovery entry, so a user with no groups yet still has + // somewhere to start. + FindChannelsRow { nav.nav(Route.RelayGroupBrowse) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } @Composable -private fun RelayServerRow( +private fun FindChannelsRow(onClick: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.relay_group_browse_title), + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +/** One relay row: NIP-11 avatar + name, used by the grouped list and the browse screen. */ +@Composable +fun RelayGroupServerRow( relayUrl: String, accountViewModel: AccountViewModel, onClick: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 928574d21d..0d464c76af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +import android.content.Context +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -41,6 +43,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -53,6 +56,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -137,6 +141,17 @@ fun RelayGroupTopBar( } }, actions = { + val naddr = channel.toNAddr() + if (naddr != null) { + val context = LocalContext.current + IconButton(onClick = { shareRelayGroup(context, naddr) }) { + Icon( + symbol = MaterialSymbols.Share, + contentDescription = stringRes(R.string.quick_action_share), + modifier = Modifier.size(20.dp), + ) + } + } when { displayMembership == RelayGroupMembership.PENDING -> { Text( @@ -226,6 +241,21 @@ fun RelayGroupTopBar( } } +/** Fire the system share sheet with a njump web link to the group's naddr. */ +private fun shareRelayGroup( + context: Context, + naddr: String, +) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, njumpLink(naddr)) + putExtra(Intent.EXTRA_TITLE, stringRes(context, R.string.quick_action_share_browser_link)) + } + context.startActivity(Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))) +} + /** A small colored pill naming the user's role/status in the group. */ @Composable private fun RoleBadge(membership: RelayGroupMembership) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index 10f0ec9639..d9eacb8d29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -121,6 +121,25 @@ fun ChannelFabColumn(nav: INav) { } Spacer(modifier = Modifier.height(20.dp)) + + FloatingActionButton( + onClick = { + nav.nav(Route.RelayGroupBrowse) + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = stringRes(R.string.relay_group_browse_title), + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index da102bd701..eff9d71e78 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1964,6 +1964,12 @@ Save Members Edit channel + Find channels + Browse the channels hosted on a group relay. Paste a relay address, or pick one below. + Relay address + Browse + Relays you\'re on + Popular relays %1$d member %1$d members