mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(relays): name the things each subscription is for, not just their ids
Entity rows resolved to a short hex whenever LocalCache could not place the id, which covered Marmot groups, Concord communities and geohash cells — the three that most needed naming. Each now resolves through the path its own subsystem uses: Marmot reads the per-account chatroom StateFlows and its encrypted Blossom avatar, Concord reads the folded Control Plane keyed on the session revision, and geohash cells go through the app's reverse-geocode cache so a cell reads as a place. All three route to their screen on tap. Relay groups pivot on the host relay, expandable to the groups it carries: a group is keyed by (id, host relay) and one community relay routinely hosts many, so the flat list repeated one hostname seven times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4d53bbea9e
commit
0ad4726d41
+515
-100
@@ -20,15 +20,27 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -39,147 +51,550 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurposeGroup
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Explains why the app is holding the number of subscriptions it currently holds.
|
||||
*
|
||||
* Pivoted on purpose rather than relay, because the relay-shaped view hides exactly the thing worth
|
||||
* finding: a probe holding hundreds of filters across hundreds of relays looks like one ordinary
|
||||
* entry repeated on every row, while here it is a single line that dwarfs everything under it.
|
||||
* Pivoted on purpose rather than relay: the relay-shaped view hides exactly the thing worth finding,
|
||||
* because a probe holding hundreds of filters across hundreds of relays looks like one ordinary row
|
||||
* repeated hundreds of times. Here it is one card whose share bar runs the full width while
|
||||
* everything under it is a stub.
|
||||
*
|
||||
* Account is the outer grouping — several are normally logged in, they do not share relay sets, and
|
||||
* a mixed total cannot be acted on.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveSubscriptionsScreen(viewModel: ActiveSubscriptionsViewModel = viewModel()) {
|
||||
fun ActiveSubscriptionsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
viewModel: ActiveSubscriptionsViewModel = viewModel(),
|
||||
) {
|
||||
LaunchedEffect(Unit) { viewModel.startPolling() }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(Modifier.fillMaxWidth()) {
|
||||
item {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.active_subs_title), nav) },
|
||||
) { pad ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(pad).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item { TotalsHeader(state) }
|
||||
|
||||
state.accounts.forEach { account ->
|
||||
item(key = "acct-${account.accountPubKey ?: "none"}") { AccountHeader(account) }
|
||||
|
||||
items(account.purposes, key = { "${account.accountPubKey}-${it.purpose.name}" }) { purpose ->
|
||||
PurposeCard(purpose, state.totalFilters, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TotalsHeader(state: ActiveSubscriptionsState) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.active_subs_filters, state.totalFilters, state.totalFilters),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.active_subs_relays, state.totalRelays, state.totalRelays),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (state.untaggedFilters > 0) {
|
||||
// Stated rather than hidden: an untagged filter is a subscription this screen cannot
|
||||
// explain, and a total that claims to be fully attributed would defeat the point.
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
countsLine(state.totalFilters, state.totalRelays),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
text = pluralStringResource(R.plurals.active_subs_untagged, state.untaggedFilters, state.untaggedFilters),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
if (state.untaggedFilters > 0) {
|
||||
// Stated rather than hidden: an untagged filter is a subscription this screen
|
||||
// cannot explain, and pretending the total is fully attributed would be a lie.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountHeader(account: SubscriptionAccountRow) {
|
||||
val name = account.accountPubKey?.let { displayNameOf(it) } ?: stringRes(R.string.active_subs_unattributed)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 10.dp, bottom = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.active_subs_relays, account.relays.size, account.relays.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PurposeCard(
|
||||
row: SubscriptionPurposeRow,
|
||||
totalFilters: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var expanded by rememberSaveable(row.purpose) { mutableStateOf(false) }
|
||||
val accent = colorOf(row.purpose.group)
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.animateContentSize(),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(10.dp).clip(CircleShape).background(accent))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringRes(SubPurposeLabels.labelOf(row.purpose)),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.active_subs_filters, row.filterCount, row.filterCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Share of ALL subscriptions, not of the biggest one: measuring against the biggest
|
||||
// makes one bar permanently full and says nothing about how much of the app's traffic a
|
||||
// purpose actually accounts for.
|
||||
val share = if (totalFilters > 0) row.filterCount / totalFilters.toFloat() else 0f
|
||||
ShareBar(fraction = share, color = accent)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.active_subs_relays, row.relays.size, row.relays.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.active_subs_share, (share * 100).roundToInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
SubPurposeLabels.explainerOf(row.purpose)?.let {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
pluralStringResource(R.plurals.active_subs_untagged, state.untaggedFilters, state.untaggedFilters),
|
||||
text = stringRes(it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
items(state.accounts, key = { it.accountPubKey ?: "unattributed" }) { account ->
|
||||
AccountSection(account)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Relay-hosted purposes pivot on the relay instead of listing every entity flat: a
|
||||
// NIP-29 group is keyed by (id, host relay), and one community relay routinely hosts
|
||||
// many of them — measured here, 13 groups across 5 relays, with one hostname repeated
|
||||
// seven times. Grouping folds that repetition away and answers the question the screen
|
||||
// exists for: *this* relay is connected because of *these* groups.
|
||||
if (row.purpose == SubPurpose.RELAY_GROUPS) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
RelayGroupedEntities(row.entities, accountViewModel, nav)
|
||||
} else {
|
||||
// Community Chats drops its unnamed row: Concord filters that name no community
|
||||
// are the plane-level machinery (control, guestbook, rekey), and rendering them as
|
||||
// a nameless "All" alongside real communities read as an unexplained extra rather
|
||||
// than as detail. The card's own filter count still includes them, so nothing is
|
||||
// lost from the totals — only from the per-entity list.
|
||||
val shown =
|
||||
if (row.purpose == SubPurpose.COMMUNITY_CHATS) {
|
||||
row.entities.filter { it.entityId != null }
|
||||
} else {
|
||||
row.entities
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountSection(account: SubscriptionAccountRow) {
|
||||
val name = account.accountPubKey?.let { displayNameOf(it) } ?: stringRes(R.string.active_subs_unattributed)
|
||||
|
||||
Column(Modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
account.purposes.forEach { PurposeSection(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PurposeSection(purposeRow: SubscriptionPurposeRow) {
|
||||
var expanded by rememberSaveable(purposeRow.purpose) { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringRes(SubPurposeLabels.labelOf(purposeRow.purpose)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = countsLine(purposeRow.filterCount, purposeRow.relays.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
SubPurposeLabels.explainerOf(purposeRow.purpose)?.let {
|
||||
Text(
|
||||
text = stringRes(it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
purposeRow.entities.forEach { entity ->
|
||||
val label =
|
||||
entity.entityId?.let { displayNameOf(it) }
|
||||
?: entity.detail
|
||||
?: stringRes(R.string.active_subs_no_entity)
|
||||
Text(
|
||||
text = stringRes(R.string.active_subs_pair, label, pluralStringResource(R.plurals.active_subs_relays, entity.relays.size, entity.relays.size)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 2.dp),
|
||||
)
|
||||
shown.forEach { entity ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
EntityBlock(entity, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "N filters · M relays", each noun pluralised on its own count. */
|
||||
/** The entity's name, coloured as a link only when tapping it actually goes somewhere. */
|
||||
@Composable
|
||||
private fun countsLine(
|
||||
filters: Int,
|
||||
relays: Int,
|
||||
): String =
|
||||
stringRes(
|
||||
R.string.active_subs_pair,
|
||||
pluralStringResource(R.plurals.active_subs_filters, filters, filters),
|
||||
pluralStringResource(R.plurals.active_subs_relays, relays, relays),
|
||||
private fun EntityLabelText(
|
||||
text: String,
|
||||
route: Route?,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (route != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an id to whatever name is loaded right now, falling back to a short id.
|
||||
* One row per host relay, expandable to the groups it carries.
|
||||
*
|
||||
* Deliberately at render time rather than baked into the filter: names arrive after the subscription
|
||||
* that needed them, so a name captured at filter-construction would usually be missing and would go
|
||||
* stale when the user renames.
|
||||
* Collapsed by default because the relay is the thing that costs a connection — the question "why am
|
||||
* I talking to this host" is answered by the header alone, and the group list is the follow-up only
|
||||
* some people want. The count sits on the header so a busy relay is obvious without opening it.
|
||||
*/
|
||||
@Composable
|
||||
private fun displayNameOf(id: HexKey): String {
|
||||
val cached =
|
||||
remember(id) {
|
||||
LocalCache.getUserIfExists(id)?.toBestDisplayName()
|
||||
?: LocalCache.getNoteIfExists(id)?.event?.let { LocalCache.getUserIfExists(it.pubKey)?.toBestDisplayName() }
|
||||
private fun RelayGroupedEntities(
|
||||
entities: List<SubscriptionEntityRow>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// An entity can name several relays in principle; each pairing is its own row under that relay.
|
||||
val byRelay =
|
||||
remember(entities) {
|
||||
entities
|
||||
.flatMap { entity -> entity.relays.map { relay -> relay to entity } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.toList()
|
||||
.sortedWith(compareByDescending<Pair<NormalizedRelayUrl, List<SubscriptionEntityRow>>> { it.second.size }.thenBy { it.first.url })
|
||||
}
|
||||
return cached ?: id.take(8)
|
||||
|
||||
byRelay.forEach { (relay, hosted) ->
|
||||
var open by rememberSaveable(relay.url) { mutableStateOf(false) }
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { open = !open },
|
||||
) {
|
||||
RelayLine(relay, accountViewModel, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.active_subs_groups, hosted.size, hosted.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
if (open) {
|
||||
hosted.forEach { entity ->
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Indented, and without the relay repeated — it is the header directly above.
|
||||
Column(Modifier.padding(start = 28.dp)) {
|
||||
EntityBlock(entity, accountViewModel, nav, showRelays = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareBar(
|
||||
fraction: Float,
|
||||
color: Color,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction.coerceIn(0.02f, 1f))
|
||||
.height(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One colour per family, so the eye groups Messages/Account/Feeds without reading labels. */
|
||||
@Composable
|
||||
private fun colorOf(group: SubPurposeGroup): Color =
|
||||
when (group) {
|
||||
SubPurposeGroup.MESSAGES -> MaterialTheme.colorScheme.primary
|
||||
SubPurposeGroup.ACCOUNT -> MaterialTheme.colorScheme.allGoodColor
|
||||
SubPurposeGroup.FEEDS -> MaterialTheme.colorScheme.tertiary
|
||||
SubPurposeGroup.CURRENT_SCREEN -> MaterialTheme.colorScheme.warningColor
|
||||
SubPurposeGroup.OTHER -> MaterialTheme.colorScheme.placeholderText
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntityBlock(
|
||||
entity: SubscriptionEntityRow,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
showRelays: Boolean = true,
|
||||
) {
|
||||
// Marmot groups are resolved first and separately: their name and avatar live in per-account
|
||||
// StateFlows (and the avatar is an encrypted Blossom blob needing a cipher registration), so
|
||||
// they cannot come from the plain LocalCache lookup that serves every other entity type.
|
||||
val marmot = entity.entityId?.let { id -> rememberMarmotEntity(id, accountViewModel) }
|
||||
val concord = entity.entityId?.let { id -> rememberConcordEntity(id, accountViewModel) }
|
||||
val resolved = marmot ?: concord ?: entity.entityId?.let { id -> remember(id, entity.relays) { resolveEntity(id, entity.relays) } }
|
||||
// Deliberately not falling back to `entity.detail`: that field is a developer breadcrumb set in
|
||||
// `commons`, where Android string resources do not exist, so it is hardcoded English and could
|
||||
// never be translated. A localized "no entity" line is better than an untranslatable one.
|
||||
val label = resolved?.name ?: stringRes(R.string.active_subs_no_entity)
|
||||
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
// Only tappable when we know where it goes; a dead tap target is worse than none.
|
||||
resolved?.route?.let { route -> Modifier.clickable { nav.nav(route) } } ?: Modifier,
|
||||
).padding(vertical = 2.dp),
|
||||
) {
|
||||
if (resolved?.picture != null) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = entity.entityId ?: label,
|
||||
model = resolved.picture,
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(24.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = false,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
// A geohash cell reads as a place everywhere else in the app, so it does here too. Same
|
||||
// helper the note headers and the geohash screen use, which means the same reverse-geocode
|
||||
// cache and the same graceful fallback to the raw cell on devices with no Geocoder backend.
|
||||
val cell = (resolved?.route as? Route.GeohashChat)?.geohash
|
||||
if (cell != null) {
|
||||
LoadCityName(
|
||||
geohashStr = cell,
|
||||
onLoading = { EntityLabelText(label, resolved.route) },
|
||||
) { city ->
|
||||
EntityLabelText("#" + city, resolved.route)
|
||||
}
|
||||
} else {
|
||||
EntityLabelText(label, resolved?.route)
|
||||
}
|
||||
}
|
||||
if (showRelays) {
|
||||
entity.relays.forEach { relay -> RelayLine(relay, accountViewModel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A relay with its real icon and the app's own relay-name colour, not a bare URL string. */
|
||||
@Composable
|
||||
private fun RelayLine(
|
||||
relay: NormalizedRelayUrl,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val info by loadRelayInfo(relay)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.padding(top = 6.dp, start = 2.dp),
|
||||
) {
|
||||
// Plain favicon, not RenderRelayIcon: that one overlays a ping-count badge which, at this
|
||||
// size, covers the icon entirely and reads as a random number next to every relay.
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = relay.displayUrl(),
|
||||
model = info.icon,
|
||||
contentDescription = relay.displayUrl(),
|
||||
colorFilter = RelayIconFilter,
|
||||
modifier = Modifier.size(20.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = false,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = relay.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an entity id resolves to right now: a name, a picture, and where tapping it goes.
|
||||
*
|
||||
* Resolved at render time rather than baked into the filter — names and pictures arrive *after* the
|
||||
* subscription that needed them, so anything captured at filter-construction would usually be empty
|
||||
* and would go stale on rename.
|
||||
*
|
||||
* The relays matter: a NIP-29 group is keyed by [GroupId] (id **and** host relay), so the id alone
|
||||
* cannot find it. Those are exactly the rows that used to render as bare hex.
|
||||
*/
|
||||
private class ResolvedEntity(
|
||||
val name: String,
|
||||
val picture: String?,
|
||||
val route: Route?,
|
||||
)
|
||||
|
||||
private fun resolveEntity(
|
||||
id: HexKey,
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
): ResolvedEntity {
|
||||
relays.forEach { relay ->
|
||||
LocalCache.getRelayGroupChannelIfExists(GroupId(id, relay))?.let {
|
||||
return ResolvedEntity(it.toBestDisplayName(), it.profilePicture(), Route.RelayGroup(id, relay.url))
|
||||
}
|
||||
}
|
||||
LocalCache.getPublicChatChannelIfExists(id)?.let {
|
||||
return ResolvedEntity(it.toBestDisplayName(), it.profilePicture(), Route.PublicChatChannel(id))
|
||||
}
|
||||
// Geohash cells: the id IS the cell, and it renders as "#cell" everywhere else in the app.
|
||||
LocalCache.getGeohashChannelIfExists(id)?.let {
|
||||
return ResolvedEntity(it.toBestDisplayName(), null, Route.GeohashChat(id))
|
||||
}
|
||||
LocalCache.getUserIfExists(id)?.let {
|
||||
return ResolvedEntity(it.toBestDisplayName(), it.profilePicture(), Route.Profile(id))
|
||||
}
|
||||
// Known-but-unresolvable (a Concord community, a mint) still shows something stable rather than
|
||||
// disappearing — a short id is honest about what we have.
|
||||
return ResolvedEntity(id.take(8), null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Marmot (MLS) group, or null when [id] names no group this account knows.
|
||||
*
|
||||
* Kept apart from [resolveEntity] because none of it is a plain cache read: the name and avatar are
|
||||
* `StateFlow`s that fill in after the subscription starts, and the avatar is an encrypted Blossom
|
||||
* blob whose decryption cipher `rememberMarmotGroupIconUrl` registers as a side effect. Looking the
|
||||
* room up with `rooms.get` rather than `getOrCreateGroup` keeps an unknown id from minting an empty
|
||||
* room just because a diagnostics screen asked about it.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberMarmotEntity(
|
||||
id: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
): ResolvedEntity? {
|
||||
val chatroom =
|
||||
remember(id) {
|
||||
accountViewModel.account.marmotGroupList.rooms
|
||||
.get(id)
|
||||
} ?: return null
|
||||
|
||||
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
|
||||
val image by chatroom.image.collectAsStateWithLifecycle()
|
||||
val relays by chatroom.relays.collectAsStateWithLifecycle()
|
||||
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
|
||||
|
||||
// Same name/icon precedence the chat-rooms list uses, so a group reads identically in both places.
|
||||
val name = displayName?.takeIf { it.isNotBlank() } ?: stringRes(R.string.marmot_group_fallback_name, id.take(8))
|
||||
val picture =
|
||||
if (image != null) {
|
||||
rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys)
|
||||
} else {
|
||||
loadMarmotRelayIcon(relays)
|
||||
}
|
||||
|
||||
return ResolvedEntity(name, picture, Route.MarmotGroupChat(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* A Concord community, or null when [id] names none this account has folded.
|
||||
*
|
||||
* Also not a cache read: the name and icon come from the folded Control Plane, and a fold is
|
||||
* announced by `concordSessions.revision` rather than by the session's own state, so the read has to
|
||||
* be keyed on that counter or the row keeps whatever it saw first. The icon is a CORD-02
|
||||
* [ImagePointer] — usually an encrypted blob — so it goes through `rememberConcordImageModel` rather
|
||||
* than being handed to Coil as a URL.
|
||||
*
|
||||
* Falls back to the joined-list entry's name, which is present before the first fold completes, and
|
||||
* returns null when neither knows the community so the caller can keep looking.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberConcordEntity(
|
||||
id: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
): ResolvedEntity? {
|
||||
val account = accountViewModel.account
|
||||
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
|
||||
val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle()
|
||||
|
||||
val metadata =
|
||||
remember(id, revision) {
|
||||
account.concordSessions
|
||||
.sessionFor(id)
|
||||
?.state
|
||||
?.value
|
||||
?.metadata
|
||||
}
|
||||
val fallbackName = remember(communities, id) { communities.firstOrNull { it.id == id }?.name?.ifBlank { null } }
|
||||
|
||||
// Resolved unconditionally: bailing out early would make the number of composable calls depend on
|
||||
// whether the community happens to be known yet, which changes as folds land.
|
||||
val model = rememberConcordImageModel(metadata?.icon, accountViewModel)
|
||||
val name = metadata?.name?.takeIf { it.isNotBlank() } ?: fallbackName
|
||||
|
||||
return name?.let { ResolvedEntity(it, model, Route.ConcordServer(id)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun displayNameOf(id: HexKey): String = remember(id) { resolveEntity(id, emptyList()).name }
|
||||
|
||||
+16
-7
@@ -85,7 +85,10 @@ data class ActiveSubscriptionsState(
|
||||
val totalRelays: Int = 0,
|
||||
/** Filters in flight that carry no purpose — assemblers not yet migrated. Honesty, not a bug. */
|
||||
val untaggedFilters: Int = 0,
|
||||
)
|
||||
) {
|
||||
/** The largest purpose, so every card can draw its share against a common scale. */
|
||||
val busiestPurposeFilters: Int = accounts.flatMap { it.purposes }.maxOfOrNull { it.filterCount } ?: 0
|
||||
}
|
||||
|
||||
class ActiveSubscriptionsViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(ActiveSubscriptionsState())
|
||||
@@ -121,12 +124,18 @@ class ActiveSubscriptionsViewModel : ViewModel() {
|
||||
return@forEach
|
||||
}
|
||||
allRelays.add(relay)
|
||||
byAccount
|
||||
.getOrPut(explained.accountPubKey) { mutableMapOf() }
|
||||
.getOrPut(explained.purpose) { mutableMapOf() }
|
||||
.getOrPut(explained.entityId) { mutableListOf() }
|
||||
.add(relay)
|
||||
detailOf[explained.purpose to explained.entityId] = explained.purposeDetail
|
||||
// A batched filter serves several entities at once — relay-group state is one #d
|
||||
// filter per host relay carrying every joined group on it — so it contributes a
|
||||
// row to each of them rather than collapsing to "All".
|
||||
val entities: List<HexKey?> = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null)
|
||||
entities.forEach { entityId ->
|
||||
byAccount
|
||||
.getOrPut(explained.accountPubKey) { mutableMapOf() }
|
||||
.getOrPut(explained.purpose) { mutableMapOf() }
|
||||
.getOrPut(entityId) { mutableListOf() }
|
||||
.add(relay)
|
||||
detailOf[explained.purpose to entityId] = explained.purposeDetail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user