mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
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:
+2
@@ -235,7 +235,9 @@ internal fun rememberBottomBarSlot(
|
|||||||
}
|
}
|
||||||
is BottomBarEntry.PublicChat,
|
is BottomBarEntry.PublicChat,
|
||||||
is BottomBarEntry.RelayGroup,
|
is BottomBarEntry.RelayGroup,
|
||||||
|
is BottomBarEntry.RelayServer,
|
||||||
is BottomBarEntry.Concord,
|
is BottomBarEntry.Concord,
|
||||||
|
is BottomBarEntry.ConcordChannel,
|
||||||
is BottomBarEntry.Geohash,
|
is BottomBarEntry.Geohash,
|
||||||
-> {
|
-> {
|
||||||
val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return null
|
val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return null
|
||||||
|
|||||||
+32
@@ -38,6 +38,11 @@ import kotlinx.serialization.Serializable
|
|||||||
* - [PublicChat], [RelayGroup] and [Concord] each pin one specific joined chat the user picked from
|
* - [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
|
* 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.
|
* 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
|
@Serializable
|
||||||
sealed interface BottomBarEntry {
|
sealed interface BottomBarEntry {
|
||||||
@@ -70,6 +75,17 @@ sealed interface BottomBarEntry {
|
|||||||
val relayUrl: String,
|
val relayUrl: String,
|
||||||
) : BottomBarEntry
|
) : 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.
|
* 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(),
|
val relays: List<String> = emptyList(),
|
||||||
) : BottomBarEntry
|
) : 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. */
|
/** A pinned Bitchat geohash location channel, keyed by its geohash cell; opens the location chat. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("geohash")
|
@SerialName("geohash")
|
||||||
@@ -107,7 +137,9 @@ val BottomBarEntry.stableKey: String
|
|||||||
is BottomBarEntry.Favorite -> "favorite:$favoriteId"
|
is BottomBarEntry.Favorite -> "favorite:$favoriteId"
|
||||||
is BottomBarEntry.PublicChat -> "publicChat:$channelId"
|
is BottomBarEntry.PublicChat -> "publicChat:$channelId"
|
||||||
is BottomBarEntry.RelayGroup -> "relayGroup:$relayUrl|$groupId"
|
is BottomBarEntry.RelayGroup -> "relayGroup:$relayUrl|$groupId"
|
||||||
|
is BottomBarEntry.RelayServer -> "relayServer:$relayUrl"
|
||||||
is BottomBarEntry.Concord -> "concord:$communityId"
|
is BottomBarEntry.Concord -> "concord:$communityId"
|
||||||
|
is BottomBarEntry.ConcordChannel -> "concordChannel:$communityId|$channelId"
|
||||||
is BottomBarEntry.Geohash -> "geohash:$geohash"
|
is BottomBarEntry.Geohash -> "geohash:$geohash"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
@@ -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.nip28PublicChats.PublicChatChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
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.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
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.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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. */
|
/** [observeChannelMetadata] tolerant of a null channel (unresolvable relay), so callers avoid an early return. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun observeChannelMetadataOrNull(
|
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
|
* 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.
|
* pin robohash keyed by the cell, labelled with the geohash, opening the location chat.
|
||||||
@@ -198,7 +257,9 @@ fun rememberGroupEntryDisplay(
|
|||||||
when (entry) {
|
when (entry) {
|
||||||
is BottomBarEntry.PublicChat -> rememberPublicChatEntryDisplay(entry, accountViewModel, subscribe)
|
is BottomBarEntry.PublicChat -> rememberPublicChatEntryDisplay(entry, accountViewModel, subscribe)
|
||||||
is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel, subscribe)
|
is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel, subscribe)
|
||||||
|
is BottomBarEntry.RelayServer -> rememberRelayServerEntryDisplay(entry, accountViewModel)
|
||||||
is BottomBarEntry.Concord -> rememberConcordEntryDisplay(entry, accountViewModel)
|
is BottomBarEntry.Concord -> rememberConcordEntryDisplay(entry, accountViewModel)
|
||||||
|
is BottomBarEntry.ConcordChannel -> rememberConcordChannelEntryDisplay(entry, accountViewModel)
|
||||||
is BottomBarEntry.Geohash -> rememberGeohashEntryDisplay(entry)
|
is BottomBarEntry.Geohash -> rememberGeohashEntryDisplay(entry)
|
||||||
is BottomBarEntry.BuiltIn -> null
|
is BottomBarEntry.BuiltIn -> null
|
||||||
is BottomBarEntry.Favorite -> null
|
is BottomBarEntry.Favorite -> null
|
||||||
|
|||||||
+7
-3
@@ -685,9 +685,13 @@ class AccountViewModel(
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val pinnedRelays =
|
val pinnedRelays =
|
||||||
settings.uiSettingsFlow.bottomBarItems.value
|
settings.uiSettingsFlow.bottomBarItems.value
|
||||||
.filterIsInstance<BottomBarEntry.Concord>()
|
.flatMap {
|
||||||
.flatMap { it.relays }
|
when (it) {
|
||||||
.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
is BottomBarEntry.Concord -> it.relays
|
||||||
|
is BottomBarEntry.ConcordChannel -> it.relays
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||||
account.importConcordCommunities(pinnedRelays)
|
account.importConcordCommunities(pinnedRelays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -127,9 +127,14 @@ private fun bootstrapPinnedCommunities(accountViewModel: AccountViewModel) {
|
|||||||
remember(items, communities) {
|
remember(items, communities) {
|
||||||
val known = communities.mapTo(HashSet()) { it.id }
|
val known = communities.mapTo(HashSet()) { it.id }
|
||||||
items
|
items
|
||||||
.filterIsInstance<BottomBarEntry.Concord>()
|
.mapNotNull {
|
||||||
.map { it.communityId }
|
// Both a pinned community and a pinned channel need their community's list fetched.
|
||||||
.filterTo(sortedSetOf()) { it !in known }
|
when (it) {
|
||||||
|
is BottomBarEntry.Concord -> it.communityId
|
||||||
|
is BottomBarEntry.ConcordChannel -> it.communityId
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.filterTo(sortedSetOf()) { it !in known }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(missingPinned) {
|
LaunchedEffect(missingPinned) {
|
||||||
|
|||||||
+158
-14
@@ -99,6 +99,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
|||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
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). */
|
/** The chat catalog items whose picker row expands to a per-item picker (favorites / joined groups). */
|
||||||
private val ExpandableItems =
|
private val ExpandableItems =
|
||||||
@@ -557,7 +560,7 @@ private fun PickerChildren(
|
|||||||
label = fav.label,
|
label = fav.label,
|
||||||
pinned = entry.stableKey in pinnedKeys,
|
pinned = entry.stableKey in pinnedKeys,
|
||||||
onToggle = { onTogglePin(entry) },
|
onToggle = { onTogglePin(entry) },
|
||||||
indent = true,
|
indentLevel = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -573,20 +576,41 @@ private fun PickerChildren(
|
|||||||
NavBarItem.RELAY_GROUPS -> {
|
NavBarItem.RELAY_GROUPS -> {
|
||||||
val groups by accountViewModel.account.relayGroupList.liveRelayGroupList
|
val groups by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
val entries =
|
if (groups.isEmpty()) {
|
||||||
remember(groups) {
|
EmptyChildHint(R.string.bottom_bar_settings_no_groups)
|
||||||
groups
|
} else {
|
||||||
.sortedBy { (it.name ?: it.groupId).lowercase() }
|
// Group joined groups by their host relay: the relay itself is addable (opens its home
|
||||||
.map { BottomBarEntry.RelayGroup(it.groupId, it.relayUrl) }
|
// 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 -> {
|
NavBarItem.CONCORD -> {
|
||||||
val communities by accountViewModel.account.concordChannelList.liveCommunities
|
val communities by accountViewModel.account.concordChannelList.liveCommunities
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id, it.relays) } }
|
if (communities.isEmpty()) {
|
||||||
GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin)
|
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 -> {
|
NavBarItem.GEOHASH_CHATS -> {
|
||||||
@@ -619,7 +643,110 @@ private fun GroupChildList(
|
|||||||
label = display.label,
|
label = display.label,
|
||||||
pinned = entry.stableKey in pinnedKeys,
|
pinned = entry.stableKey in pinnedKeys,
|
||||||
onToggle = { onTogglePin(entry) },
|
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
|
// 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
|
@Composable
|
||||||
private fun AvailableRow(
|
private fun AvailableRow(
|
||||||
leading: @Composable () -> Unit,
|
leading: @Composable () -> Unit,
|
||||||
label: String,
|
label: String,
|
||||||
pinned: Boolean,
|
pinned: Boolean,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
indent: Boolean = false,
|
indentLevel: Int = 0,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onToggle)
|
.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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -773,12 +912,15 @@ private fun SectionHeader(title: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyChildHint(textRes: Int) {
|
private fun EmptyChildHint(
|
||||||
|
textRes: Int,
|
||||||
|
indentLevel: Int = 1,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(textRes),
|
text = stringRes(textRes),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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.PublicChat,
|
||||||
is BottomBarEntry.RelayGroup,
|
is BottomBarEntry.RelayGroup,
|
||||||
|
is BottomBarEntry.RelayServer,
|
||||||
is BottomBarEntry.Concord,
|
is BottomBarEntry.Concord,
|
||||||
|
is BottomBarEntry.ConcordChannel,
|
||||||
is BottomBarEntry.Geohash,
|
is BottomBarEntry.Geohash,
|
||||||
-> {
|
-> {
|
||||||
// Read-only: the settings list resolves from cache; the live bar owns the subscription.
|
// Read-only: the settings list resolves from cache; the live bar owns the subscription.
|
||||||
|
|||||||
+7
-1
@@ -40,7 +40,9 @@ class BottomBarEntrySerializationTest {
|
|||||||
BottomBarEntry.Favorite("url:https://example.com"),
|
BottomBarEntry.Favorite("url:https://example.com"),
|
||||||
BottomBarEntry.PublicChat("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb"),
|
BottomBarEntry.PublicChat("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb"),
|
||||||
BottomBarEntry.RelayGroup("abcd1234", "wss://groups.example.com"),
|
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.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
|
@Test
|
||||||
@@ -50,7 +52,11 @@ class BottomBarEntrySerializationTest {
|
|||||||
assertTrue("expected stable discriminators, got: $json", json.contains("\"builtIn\"") && json.contains("\"favorite\""))
|
assertTrue("expected stable discriminators, got: $json", json.contains("\"builtIn\"") && json.contains("\"favorite\""))
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"expected group discriminators, got: $json",
|
"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))
|
assertEquals(sample, JsonMapper.fromJson<List<BottomBarEntry>>(json))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user