feat(cordn): fold "Review invitations" into the Cordn card on the new-conversation screen

The invitations entry was a flat row appended after the Relay-aware section,
styled unlike every other card and sharing the relay group's icon, so it read
as part of the wrong group. It now sits inside the Cordn card as an outlined
secondary button under "Create Cordn group" (a new optional SecondaryAction on
ConversationType). Still a button, never a count: fetching invitations is a
call to every coordinator (spec/00.md §8).

Also:
- Add an invitations button to the Cordn groups list top bar, which was the
  other natural place to look and had no way in.
- Tighten the Cordn card copy to match the other types: title case, short
  pros/cons, and drop the "Best for" prefix that duplicated the BEST FOR header.
- Remove the now-unused cordn_invitations_entry string from all locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUVZKGPbRB9gWBUrBMU5x5
This commit is contained in:
Claude
2026-10-01 01:55:26 +00:00
parent a8c251e64a
commit 0fb780a615
10 changed files with 52 additions and 57 deletions
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none_detail
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_start
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
@@ -110,6 +111,16 @@ fun CordnGroupListScreen(
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(Res.string.cordn_groups_title), nav) {
// Invitations are fetched only when someone opens that screen
// (every call to a coordinator is metadata, spec/00.md §8), so
// this is a plain way in, never a badge.
IconButton(onClick = { nav.nav(Route.CordnInvitations) }) {
Icon(
symbol = MaterialSymbols.MoveToInbox,
contentDescription = stringRes(Res.string.cordn_invitations_title),
modifier = Modifier.size(22.dp),
)
}
IconButton(onClick = { nav.nav(Route.CordnCoordinators) }) {
Icon(
symbol = MaterialSymbols.Dns,
@@ -5591,7 +5591,6 @@
<string name="new_conversation_cordn_pro_1">End-to-end šifrování pomocí MLS — koordinátor nikdy nepřečte žádnou zprávu.</string>
<string name="new_conversation_cordn_pro_2">Jedno dohodnuté pořadí pro všechny, takže historii nemohou přeházet špatně nastavené hodiny.</string>
<string name="new_conversation_cordn_con_1">Koordinátor zjistí, kdo je ve skupině a kdy spolu mluvíte, i když nemůže číst, co píšete.</string>
<string name="cordn_invitations_entry">Máte pozvánku do skupiny cordn?</string>
<string name="cordn_invitations_entry_action">Zobrazit pozvánky</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
one-shot handoff rather than continuous sync. The exposure strings say
@@ -5361,7 +5361,6 @@
<string name="new_conversation_cordn_pro_1">Ende-zu-Ende-verschlüsselt mit MLS — der Koordinator kann nie eine Nachricht lesen.</string>
<string name="new_conversation_cordn_pro_2">Eine gemeinsame Reihenfolge für alle, sodass der Verlauf nicht durch eine falsch gehende Uhr durcheinandergeraten kann.</string>
<string name="new_conversation_cordn_con_1">Der Koordinator erfährt, wer in der Gruppe ist und wann ihr schreibt, auch wenn er nicht lesen kann, was ihr sagt.</string>
<string name="cordn_invitations_entry">In eine cordn-Gruppe eingeladen?</string>
<string name="cordn_invitations_entry_action">Einladungen ansehen</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
one-shot handoff rather than continuous sync. The exposure strings say
@@ -5455,7 +5455,6 @@
<string name="new_conversation_cordn_pro_1">आरम्भ से अन्त तक रहस्यीकृत एमएलएस॰ के साथ। समायोजक सन्देश कभी पढ नहीं सकता।</string>
<string name="new_conversation_cordn_pro_2">एक स्वीकृत अनुक्रमण सभी के लिए। जिससे इतिहास को अस्तव्यस्त नहीं किया जा सकता एक भ्रष्ट घडी से।</string>
<string name="new_conversation_cordn_con_1">समायोजक जान लेता है समूह में कौन हैं तथा आप कब बात करते है। यद्यपि वह पढ नहीं सकता कि आप ने क्या कहा।</string>
<string name="cordn_invitations_entry">क्या आप एक कोर्डन समूह में आमन्त्रित हैं।</string>
<string name="cordn_invitations_entry_action">आमन्त्रण पुनरीक्षण</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
one-shot handoff rather than continuous sync. The exposure strings say
@@ -5455,7 +5455,6 @@
<string name="new_conversation_cordn_pro_1">Végponttól végpontig titkosított MLS-sel – a koordinátor soha nem olvashat el egyetlen üzenetet sem.</string>
<string name="new_conversation_cordn_pro_2">Egy mindenki által elfogadott sorrend, így az előzményeket egy hibás óra sem keverheti össze.</string>
<string name="new_conversation_cordn_con_1">A koordinátor tudja, ki van a csoportban és mikor beszélnek, még ha nem is tudja elolvasni, mit mondanak.</string>
<string name="cordn_invitations_entry">Meghívták egy cordn csoportba?</string>
<string name="cordn_invitations_entry_action">Meghívók áttekintése</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
one-shot handoff rather than continuous sync. The exposure strings say
@@ -5660,7 +5660,6 @@
<string name="new_conversation_cordn_pro_1">Szyfrowanie typu „end-to-end” z wykorzystaniem MLS — koordynator nigdy nie ma możliwości odczytania wiadomości.</string>
<string name="new_conversation_cordn_pro_2">Jedno uzgodnione postanowienie dla wszystkich, aby historia nie mogła zostać zmieniona przez błędny zegar.</string>
<string name="new_conversation_cordn_con_1">Koordynator wie, kto należy do grupy i kiedy ktoś zabiera głos, mimo że nie potrafi odczytać treści wypowiedzi.</string>
<string name="cordn_invitations_entry">Zostałeś zaproszony do grupy „cordn”?</string>
<string name="cordn_invitations_entry_action">Przejrzyj zaproszenia</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
one-shot handoff rather than continuous sync. The exposure strings say
@@ -5359,7 +5359,6 @@
<string name="new_conversation_cordn_pro_1">Criptografia de ponta a ponta com MLS — o coordenador nunca consegue ler uma mensagem.</string>
<string name="new_conversation_cordn_pro_2">Uma única ordem acordada para todos, então o histórico não pode ser embaralhado por um relógio errado.</string>
<string name="new_conversation_cordn_con_1">O coordenador fica sabendo quem está no grupo e quando vocês conversam, mesmo sem conseguir ler o que é dito.</string>
<string name="cordn_invitations_entry">Convidado para um grupo cordn?</string>
<string name="cordn_invitations_entry_action">Ver convites</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
one-shot handoff rather than continuous sync. The exposure strings say
@@ -5359,7 +5359,6 @@
<string name="new_conversation_cordn_pro_1">End-to-end-krypterad med MLS — koordinatorn kan aldrig läsa ett meddelande.</string>
<string name="new_conversation_cordn_pro_2">En gemensam ordning för alla, så att historiken inte kan kastas om av en felaktig klocka.</string>
<string name="new_conversation_cordn_con_1">Koordinatorn får veta vilka som är med i gruppen och när ni pratar, även om den inte kan läsa vad ni säger.</string>
<string name="cordn_invitations_entry">Inbjuden till en cordn-grupp?</string>
<string name="cordn_invitations_entry_action">Granska inbjudningar</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
one-shot handoff rather than continuous sync. The exposure strings say
@@ -5543,15 +5543,14 @@
<string name="cordn_preview_photo">Photo</string>
<string name="cordn_preview_video">Video</string>
<string name="cordn_preview_file">File</string>
<string name="new_conversation_cordn_title">Cordn group</string>
<string name="new_conversation_cordn_tagline">Encrypted group chat ordered by a coordinator you pick.</string>
<string name="new_conversation_cordn_title">Cordn Group</string>
<string name="new_conversation_cordn_tagline">Encrypted groups on a coordinator you pick</string>
<string name="new_conversation_cordn_chip">Coordinated</string>
<string name="new_conversation_cordn_best">Best for teams that want one reliable ordering of the conversation.</string>
<string name="new_conversation_cordn_best">Teams that want one reliable ordering of the conversation.</string>
<string name="new_conversation_cordn_cta">Create Cordn group</string>
<string name="new_conversation_cordn_pro_1">End-to-end encrypted with MLS — the coordinator can never read a message.</string>
<string name="new_conversation_cordn_pro_2">One agreed order for everyone, so history cannot be reshuffled by a bad clock.</string>
<string name="new_conversation_cordn_con_1">The coordinator learns who is in the group and when you talk, even though it cannot read what you say.</string>
<string name="cordn_invitations_entry">Invited to a cordn group?</string>
<string name="new_conversation_cordn_pro_1">Coordinator can't read messages</string>
<string name="new_conversation_cordn_pro_2">One agreed message order</string>
<string name="new_conversation_cordn_con_1">Coordinator sees who talks, and when</string>
<string name="cordn_invitations_entry_action">Review invitations</string>
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -42,6 +42,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -64,7 +65,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry_action
import com.vitorpamplona.amethyst.commons.resources.new_conversation_best_for
import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_best
@@ -164,7 +164,9 @@ private val ColorLocation = Color(0xFF0891B2)
* One selectable conversation type. Collapsed, a row shows only the icon, name, a short tagline, and
* a one-word [chip] naming its deciding axis (scale / device-bound / moderation / live). Tapping the
* row expands it to reveal [bestFor] and the [pros]/[cons] before the [cta] button routes to that
* type's existing creation (or browse) flow.
* type's existing creation (or browse) flow. A type whose joining is its own flow (rather than
* something that just shows up in the inbox) adds it as [secondary], an outlined button under
* the main one, so "join" sits on the same card as "create" instead of floating elsewhere.
*/
@Immutable
private class ConversationType(
@@ -178,6 +180,13 @@ private class ConversationType(
val pros: List<StringResource>,
val cons: List<StringResource>,
val route: Route,
val secondary: SecondaryAction? = null,
)
@Immutable
private class SecondaryAction(
val label: StringResource,
val route: Route,
)
@Immutable
@@ -253,6 +262,13 @@ private val conversationSections =
// to it, and this is the first place it can be said.
cons = listOf(Res.string.new_conversation_cordn_con_1),
route = Route.CordnCreateGroup,
// Being invited is the other half of "start a
// conversation", so it sits on the same card as
// creating one. It is a button you press, never a
// count: a count would take a call to every
// coordinator, and every call to a coordinator is
// metadata (spec/00.md §8).
secondary = SecondaryAction(Res.string.cordn_invitations_entry_action, Route.CordnInvitations),
),
),
),
@@ -351,50 +367,11 @@ fun NewConversationScreen(nav: INav) {
expanded = expandedId == type.title.key,
onToggle = { expandedId = if (expandedId == type.title.key) "" else type.title.key },
onCreate = { nav.nav(type.route) },
onSecondary = { type.secondary?.let { nav.nav(it.route) } },
)
}
}
}
// Being invited is the other half of "start a conversation", so it
// belongs on the screen people reach for when they want one --
// not buried in settings. The count is deliberately absent: it
// would take a call to every coordinator, and every call to a
// coordinator is metadata (spec/00.md §8).
item(key = "cordn-invitations") {
CordnInvitationsEntry(onClick = { nav.nav(Route.CordnInvitations) })
}
}
}
}
@Composable
private fun CordnInvitationsEntry(onClick: () -> Unit) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = null,
tint = ColorCordn,
modifier = Modifier.size(20.dp),
)
Column(Modifier.weight(1f)) {
Text(
text = stringRes(Res.string.cordn_invitations_entry),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringRes(Res.string.cordn_invitations_entry_action),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
@@ -405,6 +382,7 @@ private fun ConversationRow(
expanded: Boolean,
onToggle: () -> Unit,
onCreate: () -> Unit,
onSecondary: () -> Unit,
) {
// Lighten the accent in dark mode so chip/label/checkmark text stays legible on the dark ground;
// the solid icon tile keeps the saturated base color in both themes.
@@ -493,6 +471,20 @@ private fun ConversationRow(
) {
Text(stringRes(type.cta), fontWeight = FontWeight.Bold)
}
// Stacked rather than side by side: at phone width each half
// would be ~150dp and the bold primary label would wrap.
type.secondary?.let { secondary ->
OutlinedButton(
onClick = onSecondary,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, accent),
colors = ButtonDefaults.outlinedButtonColors(contentColor = accent),
) {
Text(stringRes(secondary.label), fontWeight = FontWeight.Bold)
}
}
}
}
}