diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 3609933d2c..eee854286f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt index 0d2e74ce07..67efd64822 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt @@ -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 = 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 = 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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt index d543f10d57..84dc39f278 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 73c94e020b..c8845a6468 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -685,9 +685,13 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { val pinnedRelays = settings.uiSettingsFlow.bottomBarItems.value - .filterIsInstance() - .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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index a6db1b8ade..de96865b9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -127,9 +127,14 @@ private fun bootstrapPinnedCommunities(accountViewModel: AccountViewModel) { remember(items, communities) { val known = communities.mapTo(HashSet()) { it.id } items - .filterIsInstance() - .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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index 512079e8fc..c2b52e3499 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -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, + pinnedKeys: Set, + 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, + 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. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt index 83fe3592e1..9423ecb2eb 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt @@ -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>(json)) }