feat(bottom-bar): pin a whole NIP-29 relay or a single Concord channel

The bottom-bar settings picker only exposed one level of each grouped chat
system: NIP-29 let you pin an individual group but not the host relay, while
Concord let you pin a community but not an individual channel. Since a NIP-29
relay is the analog of a Concord community (the container) and a Concord
channel is the analog of a NIP-29 group (the item), both systems now offer
both levels.

- Add BottomBarEntry.RelayServer(relayUrl) -> Route.RelayGroupServer (the
  relay's home page of all joined groups) and BottomBarEntry.ConcordChannel(
  communityId, channelId, relays) -> Route.Concord (a specific channel), with
  stable @SerialName discriminators and stableKeys.
- Resolve their live avatar/label/route: the relay via its cached NIP-11 doc,
  the channel via the community session's folded Control Plane (community icon
  + channel name).
- Regroup the picker by container: each relay/community is an addable "server"
  row with its groups/channels nested beneath it, so you can add the whole
  server or a single room in both systems.
- Bootstrap a pinned channel's community list (importConcordCommunities and the
  pinned-community preloader now also read ConcordChannel entries).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012v531djZmQBxVoyCm45BNY
This commit is contained in:
Claude
2026-07-25 00:43:19 +00:00
parent b4e8719ee2
commit 73859f4e04
7 changed files with 275 additions and 21 deletions
@@ -235,7 +235,9 @@ internal fun rememberBottomBarSlot(
}
is BottomBarEntry.PublicChat,
is BottomBarEntry.RelayGroup,
is BottomBarEntry.RelayServer,
is BottomBarEntry.Concord,
is BottomBarEntry.ConcordChannel,
is BottomBarEntry.Geohash,
-> {
val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return null
@@ -38,6 +38,11 @@ import kotlinx.serialization.Serializable
* - [PublicChat], [RelayGroup] and [Concord] each pin one specific joined chat the user picked from
* their joined list (NIP-28 channel, NIP-29 relay group, or a Concord community). The bar resolves
* each to the chat's avatar + name from the local cache and to its chat/home route for navigation.
* - [RelayServer] and [ConcordChannel] pin the *other* level of the two grouped chat systems: a NIP-29
* host relay (whose home page lists every group on it) and one channel inside a Concord community.
* A NIP-29 relay is the analog of a Concord community (the container), and a Concord channel is the
* analog of a NIP-29 group (the item) — so together the five group entries let the user pin either
* the whole server or a single room in both systems.
*/
@Serializable
sealed interface BottomBarEntry {
@@ -70,6 +75,17 @@ sealed interface BottomBarEntry {
val relayUrl: String,
) : BottomBarEntry
/**
* A pinned NIP-29 host relay ("server"), keyed by its relay url; opens the relay's home page that
* lists every group the user has joined on it. The relay-level analog of pinning a whole [Concord]
* community, so both grouped chat systems can pin the container as well as an individual room.
*/
@Serializable
@SerialName("relayServer")
data class RelayServer(
val relayUrl: String,
) : BottomBarEntry
/**
* A pinned Concord community, keyed by its community id; opens the community's channel list.
*
@@ -88,6 +104,20 @@ sealed interface BottomBarEntry {
val relays: List<String> = emptyList(),
) : BottomBarEntry
/**
* A pinned Concord channel inside a community, keyed by the (community id, channel id) pair; opens
* that specific channel. The channel-level analog of pinning a single [RelayGroup]. [relays] carry
* the community's bootstrap relays (same reason as [Concord.relays]) so a pinned channel whose
* community list we haven't cached can still be resolved.
*/
@Serializable
@SerialName("concordChannel")
data class ConcordChannel(
val communityId: String,
val channelId: String,
val relays: List<String> = emptyList(),
) : BottomBarEntry
/** A pinned Bitchat geohash location channel, keyed by its geohash cell; opens the location chat. */
@Serializable
@SerialName("geohash")
@@ -107,7 +137,9 @@ val BottomBarEntry.stableKey: String
is BottomBarEntry.Favorite -> "favorite:$favoriteId"
is BottomBarEntry.PublicChat -> "publicChat:$channelId"
is BottomBarEntry.RelayGroup -> "relayGroup:$relayUrl|$groupId"
is BottomBarEntry.RelayServer -> "relayServer:$relayUrl"
is BottomBarEntry.Concord -> "concord:$communityId"
is BottomBarEntry.ConcordChannel -> "concordChannel:$communityId|$channelId"
is BottomBarEntry.Geohash -> "geohash:$geohash"
}
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.ChannelState
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -45,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.flow.MutableStateFlow
@@ -132,6 +134,29 @@ fun rememberRelayGroupEntryDisplay(
)
}
/**
* A pinned NIP-29 host relay ("server"). Its name/icon come from the relay's NIP-11 document (cached,
* shared with the browse/server rows), falling back to the display host; tapping opens the relay's
* home page listing every joined group on it.
*/
@Composable
fun rememberRelayServerEntryDisplay(
entry: BottomBarEntry.RelayServer,
accountViewModel: AccountViewModel,
): GroupEntryDisplay {
val relay = remember(entry.relayUrl) { RelayUrlNormalizer.normalizeOrNull(entry.relayUrl) }
val host = remember(relay, entry.relayUrl) { relay?.displayUrl() ?: entry.relayUrl }
// NIP-11 is its own HTTP cache (not a relay REQ), so this is safe even for the read-only picker.
val info = relay?.let { loadRelayInfo(it) }
val name = info?.value?.name?.takeIf { it.isNotBlank() } ?: host
return GroupEntryDisplay(
label = name,
robotSeed = entry.relayUrl,
model = info?.value?.icon?.ifBlank { null },
route = Route.RelayGroupServer(entry.relayUrl),
)
}
/** [observeChannelMetadata] tolerant of a null channel (unresolvable relay), so callers avoid an early return. */
@Composable
private fun observeChannelMetadataOrNull(
@@ -169,6 +194,40 @@ fun rememberConcordEntryDisplay(
)
}
/**
* A pinned Concord channel inside a community. The label is the channel's folded name; the avatar is
* the *community's* icon (so the tab reads as "this community's channel"), keyed by the channel id so
* distinct channels still get distinct robohash fallbacks. Tapping opens that channel directly.
*/
@Composable
fun rememberConcordChannelEntryDisplay(
entry: BottomBarEntry.ConcordChannel,
accountViewModel: AccountViewModel,
): GroupEntryDisplay {
val account = accountViewModel.account
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val session = remember(entry.communityId, revision) { account.concordSessions.sessionFor(entry.communityId) }
val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle()
val folded = state.takeIf { revision >= 0 }
val channelName =
folded
?.channels
?.get(entry.channelId)
?.definition
?.name
?.ifBlank { null }
val label = channelName ?: entry.channelId.take(8)
val model = rememberConcordImageModel(folded?.metadata?.icon, accountViewModel)
return GroupEntryDisplay(
label = label,
robotSeed = entry.channelId,
model = model,
route = Route.Concord(entry.communityId, entry.channelId),
)
}
/**
* A pinned geohash location channel. Anonymous by design — no metadata REQ; the row is a location
* pin robohash keyed by the cell, labelled with the geohash, opening the location chat.
@@ -198,7 +257,9 @@ fun rememberGroupEntryDisplay(
when (entry) {
is BottomBarEntry.PublicChat -> rememberPublicChatEntryDisplay(entry, accountViewModel, subscribe)
is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel, subscribe)
is BottomBarEntry.RelayServer -> rememberRelayServerEntryDisplay(entry, accountViewModel)
is BottomBarEntry.Concord -> rememberConcordEntryDisplay(entry, accountViewModel)
is BottomBarEntry.ConcordChannel -> rememberConcordChannelEntryDisplay(entry, accountViewModel)
is BottomBarEntry.Geohash -> rememberGeohashEntryDisplay(entry)
is BottomBarEntry.BuiltIn -> null
is BottomBarEntry.Favorite -> null
@@ -685,9 +685,13 @@ class AccountViewModel(
viewModelScope.launch(Dispatchers.IO) {
val pinnedRelays =
settings.uiSettingsFlow.bottomBarItems.value
.filterIsInstance<BottomBarEntry.Concord>()
.flatMap { it.relays }
.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
.flatMap {
when (it) {
is BottomBarEntry.Concord -> it.relays
is BottomBarEntry.ConcordChannel -> it.relays
else -> emptyList()
}
}.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
account.importConcordCommunities(pinnedRelays)
}
@@ -127,9 +127,14 @@ private fun bootstrapPinnedCommunities(accountViewModel: AccountViewModel) {
remember(items, communities) {
val known = communities.mapTo(HashSet()) { it.id }
items
.filterIsInstance<BottomBarEntry.Concord>()
.map { it.communityId }
.filterTo(sortedSetOf()) { it !in known }
.mapNotNull {
// Both a pinned community and a pinned channel need their community's list fetched.
when (it) {
is BottomBarEntry.Concord -> it.communityId
is BottomBarEntry.ConcordChannel -> it.communityId
else -> null
}
}.filterTo(sortedSetOf()) { it !in known }
}
LaunchedEffect(missingPinned) {
@@ -99,6 +99,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import kotlinx.coroutines.flow.MutableStateFlow
/** The chat catalog items whose picker row expands to a per-item picker (favorites / joined groups). */
private val ExpandableItems =
@@ -557,7 +560,7 @@ private fun PickerChildren(
label = fav.label,
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
indent = true,
indentLevel = 1,
)
}
}
@@ -573,20 +576,41 @@ private fun PickerChildren(
NavBarItem.RELAY_GROUPS -> {
val groups by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
val entries =
remember(groups) {
groups
.sortedBy { (it.name ?: it.groupId).lowercase() }
.map { BottomBarEntry.RelayGroup(it.groupId, it.relayUrl) }
if (groups.isEmpty()) {
EmptyChildHint(R.string.bottom_bar_settings_no_groups)
} else {
// Group joined groups by their host relay: the relay itself is addable (opens its home
// page listing every group on it) with each individual group nested beneath it. NIP-29
// relays are the container, like Concord communities below.
val byRelay =
remember(groups) {
groups
.groupBy { it.relayUrl }
.toList()
.sortedBy { it.first.lowercase() }
}
byRelay.forEach { (relayUrl, relayGroups) ->
key(relayUrl) {
RelayServerPickerGroup(relayUrl, relayGroups, pinnedKeys, accountViewModel, onTogglePin)
}
}
GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin)
}
}
NavBarItem.CONCORD -> {
val communities by accountViewModel.account.concordChannelList.liveCommunities
.collectAsStateWithLifecycle()
val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id, it.relays) } }
GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin)
if (communities.isEmpty()) {
EmptyChildHint(R.string.bottom_bar_settings_no_groups)
} else {
// Group by community: the community itself is addable (opens its channel list) with each
// channel nested beneath it — the mirror of the relay-group layout above.
communities.forEach { community ->
key(community.id) {
ConcordServerPickerGroup(community, pinnedKeys, accountViewModel, onTogglePin)
}
}
}
}
NavBarItem.GEOHASH_CHATS -> {
@@ -619,7 +643,110 @@ private fun GroupChildList(
label = display.label,
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
indent = true,
indentLevel = 1,
)
}
}
/**
* One NIP-29 host relay in the picker: the relay "server" row (level 1 — pin the whole relay, opening
* its home page of all joined groups) followed by each individual joined group nested at level 2.
*/
@Composable
private fun RelayServerPickerGroup(
relayUrl: String,
relayGroups: List<GroupTag>,
pinnedKeys: Set<String>,
accountViewModel: AccountViewModel,
onTogglePin: (BottomBarEntry) -> Unit,
) {
val serverEntry = remember(relayUrl) { BottomBarEntry.RelayServer(relayUrl) }
val serverDisplay = rememberGroupEntryDisplay(serverEntry, accountViewModel, subscribe = false)
AvailableRow(
leading = {
if (serverDisplay != null) {
GroupEntryAvatar(serverDisplay, 34.dp, accountViewModel)
} else {
LeadingGlyph(MaterialSymbols.Dns)
}
},
label = serverDisplay?.label ?: relayUrl,
pinned = serverEntry.stableKey in pinnedKeys,
onToggle = { onTogglePin(serverEntry) },
indentLevel = 1,
)
val sorted = remember(relayGroups) { relayGroups.sortedBy { (it.name ?: it.groupId).lowercase() } }
sorted.forEach { tag ->
val entry = BottomBarEntry.RelayGroup(tag.groupId, tag.relayUrl)
val display = rememberGroupEntryDisplay(entry, accountViewModel, subscribe = false) ?: return@forEach
AvailableRow(
leading = { GroupEntryAvatar(display, 30.dp, accountViewModel) },
label = display.label,
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
indentLevel = 2,
)
}
}
/**
* One Concord community in the picker: the community "server" row (level 1 — pin the whole community,
* opening its channel list) followed by each folded channel nested at level 2. Channels come from the
* community session's folded Control Plane; before it folds (or if its relays are dead) the list is
* empty and only the community itself can be pinned.
*/
@Composable
private fun ConcordServerPickerGroup(
community: ConcordCommunityListEntry,
pinnedKeys: Set<String>,
accountViewModel: AccountViewModel,
onTogglePin: (BottomBarEntry) -> Unit,
) {
val serverEntry = remember(community.id, community.relays) { BottomBarEntry.Concord(community.id, community.relays) }
val serverDisplay = rememberGroupEntryDisplay(serverEntry, accountViewModel, subscribe = false)
AvailableRow(
leading = {
if (serverDisplay != null) {
GroupEntryAvatar(serverDisplay, 34.dp, accountViewModel)
} else {
LeadingGlyph(MaterialSymbols.Group)
}
},
label = serverDisplay?.label ?: community.name.ifBlank { community.id.take(8) },
pinned = serverEntry.stableKey in pinnedKeys,
onToggle = { onTogglePin(serverEntry) },
indentLevel = 1,
)
val account = accountViewModel.account
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val session = remember(community.id, revision) { account.concordSessions.sessionFor(community.id) }
val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle()
val channels =
remember(state) {
state
?.channels
?.values
?.toList()
.orEmpty()
}
channels.forEach { channel ->
val entry = BottomBarEntry.ConcordChannel(community.id, channel.channelIdHex, community.relays)
val def = channel.definition
val icon =
when {
def.voice -> MaterialSymbols.Mic
def.private -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
}
AvailableRow(
leading = { LeadingGlyph(icon) },
label = def.name.ifBlank { channel.channelIdHex.take(8) },
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
indentLevel = 2,
)
}
}
@@ -628,20 +755,32 @@ private fun GroupChildList(
// Rows & shared bits
// ------------------------------------------------------------------------------------------------
/**
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
* NIP-29 group under its relay, or a Concord channel under its community).
*/
private fun indentPadding(level: Int) =
when (level) {
0 -> 13.dp
1 -> 24.dp
else -> 40.dp
}
@Composable
private fun AvailableRow(
leading: @Composable () -> Unit,
label: String,
pinned: Boolean,
onToggle: () -> Unit,
indent: Boolean = false,
indentLevel: Int = 0,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(start = if (indent) 24.dp else 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp),
.padding(start = indentPadding(indentLevel), end = 13.dp, top = 7.dp, bottom = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -773,12 +912,15 @@ private fun SectionHeader(title: String) {
}
@Composable
private fun EmptyChildHint(textRes: Int) {
private fun EmptyChildHint(
textRes: Int,
indentLevel: Int = 1,
) {
Text(
text = stringRes(textRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, end = 13.dp, top = 6.dp, bottom = 6.dp),
modifier = Modifier.padding(start = indentPadding(indentLevel), end = 13.dp, top = 6.dp, bottom = 6.dp),
)
}
@@ -835,7 +977,9 @@ private fun rememberPinnedVisual(
}
is BottomBarEntry.PublicChat,
is BottomBarEntry.RelayGroup,
is BottomBarEntry.RelayServer,
is BottomBarEntry.Concord,
is BottomBarEntry.ConcordChannel,
is BottomBarEntry.Geohash,
-> {
// Read-only: the settings list resolves from cache; the live bar owns the subscription.
@@ -40,7 +40,9 @@ class BottomBarEntrySerializationTest {
BottomBarEntry.Favorite("url:https://example.com"),
BottomBarEntry.PublicChat("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb"),
BottomBarEntry.RelayGroup("abcd1234", "wss://groups.example.com"),
BottomBarEntry.RelayServer("wss://groups.example.com"),
BottomBarEntry.Concord("f".repeat(64), listOf("wss://relay.ditto.pub", "wss://community.example.com")),
BottomBarEntry.ConcordChannel("f".repeat(64), "a".repeat(32), listOf("wss://relay.ditto.pub")),
)
@Test
@@ -50,7 +52,11 @@ class BottomBarEntrySerializationTest {
assertTrue("expected stable discriminators, got: $json", json.contains("\"builtIn\"") && json.contains("\"favorite\""))
assertTrue(
"expected group discriminators, got: $json",
json.contains("\"publicChat\"") && json.contains("\"relayGroup\"") && json.contains("\"concord\""),
json.contains("\"publicChat\"") &&
json.contains("\"relayGroup\"") &&
json.contains("\"relayServer\"") &&
json.contains("\"concord\"") &&
json.contains("\"concordChannel\""),
)
assertEquals(sample, JsonMapper.fromJson<List<BottomBarEntry>>(json))
}