From 5e7fffe08ee6ee6751762d129e48237ccc54a26d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:15:38 +0000 Subject: [PATCH] feat: lean, color-coded redesign of the new-conversation chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the "Start a conversation" chooser to be scannable instead of a wall of text. Each conversation type is now a one-line row — a color-coded icon tile, the name, a short tagline, and a single chip naming its deciding axis (1:1–5 rooms / Device-bound / Workspaces / Unmoderated / Moderated / Live now). Tapping a row expands it in place to reveal "Best for" and a compact Good / Trade-offs split, then a Create button that routes to that type's existing flow (accordion: one open at a time). Copy now leads with each protocol's real differentiator — Marmot's device-bound gotcha (chats don't follow an nsec to another app), Concord as channel-split workspaces, and the relay-aware section grouping Public Chat + Relay Group by the fact the relay can see and moderate who's there. Per-type accent colors are lightened in dark theme for legibility; the solid icon tile keeps the saturated hue in both themes. --- .../chats/rooms/NewConversationScreen.kt | 297 ++++++++++-------- amethyst/src/main/res/values/strings.xml | 89 +++--- 2 files changed, 217 insertions(+), 169 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/NewConversationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/NewConversationScreen.kt index 828e20a0c6..6b5594c4ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/NewConversationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/NewConversationScreen.kt @@ -21,27 +21,39 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.annotation.StringRes +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.isSystemInDarkTheme 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.fillMaxSize 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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -54,21 +66,34 @@ 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.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.grayText +// Per-type accent colors. Each protocol gets its own hue so private-vs-public reads at a glance +// instead of via text. Used at full strength for the icon tile (white glyph on top) and, tint-mixed, +// for the type's chip / expanded accents. In dark theme the accent is lightened for legibility on the +// dark ground (see [ConversationRow]); the solid tile keeps the saturated base either way. +private val ColorPrivate = Color(0xFF7C3AED) +private val ColorMarmot = Color(0xFF4F46E5) +private val ColorConcord = Color(0xFF0D9488) +private val ColorPublic = Color(0xFFD97706) +private val ColorRelay = Color(0xFF2563EB) +private val ColorEphemeral = Color(0xFFEA580C) + /** - * One selectable conversation type on the [NewConversationScreen] chooser: an icon, a name, a - * one-line tagline, a "best for" hint, and short pros/cons lists. Tapping the card routes to that + * 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. */ private class ConversationType( val icon: MaterialSymbol, + val color: Color, @StringRes val title: Int, @StringRes val tagline: Int, + @StringRes val chip: Int, @StringRes val bestFor: Int, + @StringRes val cta: Int, val pros: List, val cons: List, val route: Route, @@ -79,8 +104,8 @@ private class ConversationSection( val types: List, ) -// Grouped by intent so the six protocols read as three simple buckets instead of a flat wall of -// options: talk privately, run an encrypted group, or open something to the public. +// Grouped by intent so the six protocols read as three simple buckets: talk privately, run an +// encrypted group, or use a relay-aware room (where the relay can see/moderate who's there). private val conversationSections = listOf( ConversationSection( @@ -89,20 +114,14 @@ private val conversationSections = listOf( ConversationType( icon = MaterialSymbols.Mail, + color = ColorPrivate, title = R.string.new_conversation_dm_title, tagline = R.string.new_conversation_dm_tagline, + chip = R.string.new_conversation_dm_chip, bestFor = R.string.new_conversation_dm_best, - pros = - listOf( - R.string.new_conversation_dm_pro_1, - R.string.new_conversation_dm_pro_2, - R.string.new_conversation_dm_pro_3, - ), - cons = - listOf( - R.string.new_conversation_dm_con_1, - R.string.new_conversation_dm_con_2, - ), + cta = R.string.new_conversation_dm_cta, + pros = listOf(R.string.new_conversation_dm_pro_1, R.string.new_conversation_dm_pro_2), + cons = listOf(R.string.new_conversation_dm_con_1), route = Route.NewGroupDM(), ), ), @@ -113,95 +132,68 @@ private val conversationSections = listOf( ConversationType( icon = MaterialSymbols.Lock, + color = ColorMarmot, title = R.string.new_conversation_marmot_title, tagline = R.string.new_conversation_marmot_tagline, + chip = R.string.new_conversation_marmot_chip, bestFor = R.string.new_conversation_marmot_best, - pros = - listOf( - R.string.new_conversation_marmot_pro_1, - R.string.new_conversation_marmot_pro_2, - R.string.new_conversation_marmot_pro_3, - ), - cons = - listOf( - R.string.new_conversation_marmot_con_1, - R.string.new_conversation_marmot_con_2, - ), + cta = R.string.new_conversation_marmot_cta, + pros = listOf(R.string.new_conversation_marmot_pro_1, R.string.new_conversation_marmot_pro_2), + cons = listOf(R.string.new_conversation_marmot_con_1), route = Route.CreateMarmotGroup, ), ConversationType( icon = MaterialSymbols.Groups, + color = ColorConcord, title = R.string.new_conversation_concord_title, tagline = R.string.new_conversation_concord_tagline, + chip = R.string.new_conversation_concord_chip, bestFor = R.string.new_conversation_concord_best, - pros = - listOf( - R.string.new_conversation_concord_pro_1, - R.string.new_conversation_concord_pro_2, - R.string.new_conversation_concord_pro_3, - ), - cons = - listOf( - R.string.new_conversation_concord_con_1, - R.string.new_conversation_concord_con_2, - ), + cta = R.string.new_conversation_concord_cta, + pros = listOf(R.string.new_conversation_concord_pro_1, R.string.new_conversation_concord_pro_2), + cons = listOf(R.string.new_conversation_concord_con_1), route = Route.ConcordCreate, ), ), ), ConversationSection( - header = R.string.new_conversation_section_public, + header = R.string.new_conversation_section_relay, types = listOf( ConversationType( icon = MaterialSymbols.Public, + color = ColorPublic, title = R.string.new_conversation_public_chat_title, tagline = R.string.new_conversation_public_chat_tagline, + chip = R.string.new_conversation_public_chat_chip, bestFor = R.string.new_conversation_public_chat_best, - pros = - listOf( - R.string.new_conversation_public_chat_pro_1, - R.string.new_conversation_public_chat_pro_2, - ), - cons = - listOf( - R.string.new_conversation_public_chat_con_1, - R.string.new_conversation_public_chat_con_2, - ), + cta = R.string.new_conversation_public_chat_cta, + pros = listOf(R.string.new_conversation_public_chat_pro_1, R.string.new_conversation_public_chat_pro_2), + cons = listOf(R.string.new_conversation_public_chat_con_1, R.string.new_conversation_public_chat_con_2), route = Route.ChannelMetadataEdit(), ), ConversationType( icon = MaterialSymbols.Dns, + color = ColorRelay, title = R.string.new_conversation_relay_group_title, tagline = R.string.new_conversation_relay_group_tagline, + chip = R.string.new_conversation_relay_group_chip, bestFor = R.string.new_conversation_relay_group_best, - pros = - listOf( - R.string.new_conversation_relay_group_pro_1, - R.string.new_conversation_relay_group_pro_2, - ), - cons = - listOf( - R.string.new_conversation_relay_group_con_1, - R.string.new_conversation_relay_group_con_2, - ), + cta = R.string.new_conversation_relay_group_cta, + pros = listOf(R.string.new_conversation_relay_group_pro_1, R.string.new_conversation_relay_group_pro_2), + cons = listOf(R.string.new_conversation_relay_group_con_1), route = Route.RelayGroupBrowse, ), ConversationType( icon = MaterialSymbols.Timer, + color = ColorEphemeral, title = R.string.new_conversation_ephemeral_title, tagline = R.string.new_conversation_ephemeral_tagline, + chip = R.string.new_conversation_ephemeral_chip, bestFor = R.string.new_conversation_ephemeral_best, - pros = - listOf( - R.string.new_conversation_ephemeral_pro_1, - R.string.new_conversation_ephemeral_pro_2, - ), - cons = - listOf( - R.string.new_conversation_ephemeral_con_1, - R.string.new_conversation_ephemeral_con_2, - ), + cta = R.string.new_conversation_ephemeral_cta, + pros = listOf(R.string.new_conversation_ephemeral_pro_1, R.string.new_conversation_ephemeral_pro_2), + cons = listOf(R.string.new_conversation_ephemeral_con_1, R.string.new_conversation_ephemeral_con_2), route = Route.NewEphemeralChat, ), ), @@ -210,13 +202,15 @@ private val conversationSections = @Composable fun NewConversationScreen(nav: INav) { + // Accordion: at most one row expanded, keyed by its (unique) title resource id. Survives config + // changes so an opened card stays open on rotation. + var expandedId by rememberSaveable { mutableStateOf(0) } + Scaffold( - topBar = { - TopBarWithBackButton(stringRes(R.string.new_conversation_title), nav) - }, + topBar = { TopBarWithBackButton(stringRes(R.string.new_conversation_title), nav) }, ) { pad -> LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues( start = 12.dp, @@ -224,31 +218,27 @@ fun NewConversationScreen(nav: INav) { top = pad.calculateTopPadding() + 4.dp, bottom = pad.calculateBottomPadding() + 16.dp, ), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(9.dp), ) { - item { - Text( - text = stringRes(R.string.new_conversation_intro), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), - ) - } - conversationSections.forEach { section -> - item { + item(key = section.header) { Text( text = stringRes(section.header), - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 4.dp, top = 6.dp), + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(start = 6.dp, top = 8.dp), ) } section.types.forEach { type -> item(key = type.title) { - ConversationTypeCard(type) { nav.nav(type.route) } + ConversationRow( + type = type, + expanded = expandedId == type.title, + onToggle = { expandedId = if (expandedId == type.title) 0 else type.title }, + onCreate = { nav.nav(type.route) }, + ) } } } @@ -257,33 +247,39 @@ fun NewConversationScreen(nav: INav) { } @Composable -private fun ConversationTypeCard( +private fun ConversationRow( type: ConversationType, - onClick: () -> Unit, + expanded: Boolean, + onToggle: () -> Unit, + onCreate: () -> 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. + val accent = if (isSystemInDarkTheme()) lerp(type.color, Color.White, 0.42f) else type.color + ElevatedCard( - onClick = onClick, + onClick = onToggle, modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), ) { - Column(Modifier.padding(16.dp)) { + Column(Modifier.animateContentSize().padding(13.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(13.dp), + color = type.color, modifier = Modifier.size(44.dp), ) { Box(contentAlignment = Alignment.Center) { Icon( symbol = type.icon, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, + tint = Color.White, modifier = Modifier.size(24.dp), ) } } - Spacer(Modifier.size(14.dp)) + Spacer(Modifier.width(13.dp)) Column(Modifier.weight(1f)) { Text( @@ -298,54 +294,99 @@ private fun ConversationTypeCard( ) } - Icon( - symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.grayText, - modifier = Modifier.size(22.dp), - ) + Spacer(Modifier.width(8.dp)) + + AxisChip(stringRes(type.chip), accent) } - Spacer(modifier = DoubleVertSpacer) + if (expanded) { + HorizontalDivider( + modifier = Modifier.padding(top = 13.dp, bottom = 12.dp), + color = accent.copy(alpha = 0.22f), + ) - Row { - Text( - text = "${stringRes(R.string.new_conversation_best_for)}: ", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(type.bestFor), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.grayText, - ) + Column { + ColumnHeader(stringRes(R.string.new_conversation_best_for)) + Text( + text = stringRes(type.bestFor), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = 3.dp, bottom = 12.dp), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Column(Modifier.weight(1f)) { + ColumnHeader(stringRes(R.string.new_conversation_pros)) + Spacer(Modifier.height(4.dp)) + type.pros.forEach { ProConRow(it, accent, isPro = true) } + } + Column(Modifier.weight(1f)) { + ColumnHeader(stringRes(R.string.new_conversation_cons)) + Spacer(Modifier.height(4.dp)) + type.cons.forEach { ProConRow(it, accent, isPro = false) } + } + } + + Button( + onClick = onCreate, + modifier = Modifier.fillMaxWidth().padding(top = 13.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = type.color, contentColor = Color.White), + ) { + Text(stringRes(type.cta), fontWeight = FontWeight.Bold) + } } - - Spacer(modifier = StdVertSpacer) - - type.pros.forEach { ProConRow(it, isPro = true) } - type.cons.forEach { ProConRow(it, isPro = false) } } } } +@Composable +private fun AxisChip( + label: String, + accent: Color, +) { + Surface( + shape = RoundedCornerShape(999.dp), + color = accent.copy(alpha = 0.14f), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = accent, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun ColumnHeader(text: String) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.grayText, + ) +} + @Composable private fun ProConRow( @StringRes text: Int, + accent: Color, isPro: Boolean, ) { Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(vertical = 2.dp), ) { Icon( symbol = if (isPro) MaterialSymbols.Check else MaterialSymbols.Close, contentDescription = null, - tint = if (isPro) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText, - modifier = Modifier.size(16.dp), + tint = if (isPro) accent else MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(15.dp).padding(top = 1.dp), ) - Spacer(Modifier.size(8.dp)) + Spacer(Modifier.width(6.dp)) Text( text = stringRes(text), style = MaterialTheme.typography.bodySmall, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2cc4426b6b..0b8d8cdec2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2050,64 +2050,71 @@ Group New Public or Private Group - + Start a conversation - Pick the kind of conversation that fits what you need. They differ in who can join, how private they are, and where the messages live. Best for - Direct & private + Good + Trade-offs + Private Encrypted groups - Public & open + Relay-aware Private Message - Encrypted direct messages with one person or a small group. - Talking privately with people you already know. - End-to-end encrypted - Hides who you\'re talking to - Works across all your relays - Best for small groups - No shared admins or moderation + Direct messages, end-to-end encrypted + 1:1–5 rooms + One-on-one and small group chats with people you know. + New message + 1-on-1 & small groups + End-to-end encrypted + Not for big groups Marmot Group - Strongly encrypted group chat built on the MLS standard. - A private team or friend group that needs the strongest encryption. - Forward-secret group encryption - Relays can\'t read the messages - Controlled membership - Newer, still maturing - Everyone needs a Marmot-capable app + Bigger private groups + Device-bound + Larger private groups that outgrow small-group DMs. + Create group + Scales past small DMs + Encrypted group chat + Device-bound — won\'t appear if you sign in elsewhere Concord Community - An encrypted community with multiple channels, like a private server. - Running a community or team with topic channels and roles. - Many channels and roles - Encrypted, with invite links - Scales to larger groups + Communities split by channel + Workspaces + Massive communities, organized by channels or workstreams. + Create community + Scales to huge communities + Split by channel / workstream More to set up - Members need a Concord-capable app Public Chat - An open channel anyone can find, read, and join. - A public topic room open to everyone. + Public messages on a relay + Unmoderated + Open public rooms where anyone can post. + Create public chat Anyone can join - Discoverable and simple - Not private — messages are public - Limited moderation + Simple & discoverable + Public only + No moderation Relay Group - A group hosted and moderated by a specific relay. - A managed group with admins, roles, and moderation. - Real moderation and roles - Can be open or invite-only - Lives on a single relay - That relay can see the group + Relay-run, public or private + Moderated + Groups moderated by the relay that hosts them. + Browse relay groups + Moderated by the relay + Public or private + Tied to that one relay Disappearing Chat - A temporary room tied to a relay — messages aren\'t stored. - Quick, in-the-moment chats you don\'t want kept. - Nothing is saved - Lightweight and instant - History disappears - Only people on that relay see it + Whoever\'s online, right now + Live now + In-the-moment chat with whoever is online right now. + Start chat + Talk to who\'s online now + Nothing is saved + No history + Relay-scoped Relay Groups Inline By relay