mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat: make relay groups discoverable, shareable and deep-linkable
Turns NIP-29 groups from a joined-only feature into something users can find, share and open from a link: - RelayGroupBrowseScreen (Route.RelayGroupBrowse): paste any relay URL to browse the channels it hosts, pick a relay you're already on, or try a popular public relay. Reached from a new "Find channels" FAB entry and from the grouped server list. - RelayGroupServerList always shows a "Find channels" row (even with no joined groups), so a new user has a starting point instead of an empty view; extracted RelayGroupServerRow for reuse by the browse screen. - Share action in the group top bar: fires the system share sheet with a njump link to the group's naddr, available to members and non-members. - Cold-start deep links: a kind-39000 group naddr now routes straight to the group chat using the naddr's relay hint, instead of falling through to the generic note-redirect screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Route.RelayGroupBrowse> {
|
||||
RelayGroupBrowseScreen(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
|
||||
@@ -653,6 +653,8 @@ sealed class Route {
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable object RelayGroupBrowse : Route()
|
||||
|
||||
@Serializable data class ChannelMetadataEdit(
|
||||
val id: String? = null,
|
||||
) : Route()
|
||||
|
||||
+169
@@ -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),
|
||||
)
|
||||
}
|
||||
+35
-4
@@ -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,
|
||||
|
||||
+30
@@ -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) {
|
||||
|
||||
+19
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1964,6 +1964,12 @@
|
||||
<string name="relay_group_edit_confirm">Save</string>
|
||||
<string name="relay_group_menu_members">Members</string>
|
||||
<string name="relay_group_menu_edit">Edit channel</string>
|
||||
<string name="relay_group_browse_title">Find channels</string>
|
||||
<string name="relay_group_browse_description">Browse the channels hosted on a group relay. Paste a relay address, or pick one below.</string>
|
||||
<string name="relay_group_browse_relay_label">Relay address</string>
|
||||
<string name="relay_group_browse_go">Browse</string>
|
||||
<string name="relay_group_browse_your_relays">Relays you\'re on</string>
|
||||
<string name="relay_group_browse_popular">Popular relays</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
|
||||
Reference in New Issue
Block a user