diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b9b8c34ce6..e4be8075af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewConversationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen @@ -744,6 +745,7 @@ fun BuildNavigation( composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } + composableFromBottom { NewConversationScreen(nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } composableFromBottomArgs { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b16cc4dfd9..ca6def1815 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -748,6 +748,11 @@ sealed class Route { val attachment: String? = null, ) : Route() + // Full-screen chooser opened from the Messages FAB: explains each conversation type + // (DM, Marmot group, Concord, public chat, relay group, disappearing chat) with its + // pros/cons and routes to that type's creation flow. + @Serializable object NewConversation : Route() + @Serializable data class ShareToDM( val message: String? = null, val attachment: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index d9eacb8d29..34f60218c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -20,148 +20,41 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewGroupDM import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +/** + * The Messages "+" button. Instead of a speed-dial that fanned out several cryptic one-word FABs + * (Private / Public / Group / Find groups), a single button opens the full-screen + * [NewConversationScreen] chooser, which explains every conversation type and its trade-offs before + * the user commits to one. + */ @Composable fun ChannelFabColumn(nav: INav) { - var isOpen by remember { mutableStateOf(false) } - - Column { - AnimatedVisibility( - visible = isOpen, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), - ) { - Column { - FloatingActionButton( - onClick = { - nav.nav(NewGroupDM()) - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Text( - text = stringRes(R.string.messages_new_message), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - FloatingActionButton( - onClick = { - nav.nav(Route.ChannelMetadataEdit()) - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Text( - text = stringRes(R.string.messages_create_public_chat), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - FloatingActionButton( - onClick = { - nav.nav(Route.CreateMarmotGroup) - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Text( - text = stringRes(R.string.messages_create_group), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - FloatingActionButton( - onClick = { - nav.nav(Route.RelayGroupBrowse) - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Text( - text = stringRes(R.string.relay_group_browse_title), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - } - } - - val rotationDegree by animateFloatAsState( - targetValue = if (isOpen) 45f else 0f, + FloatingActionButton( + onClick = { nav.nav(Route.NewConversation) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.messages_create_public_private_chat_description), + modifier = Modifier.size(26.dp), + tint = Color.White, ) - - FloatingActionButton( - onClick = { isOpen = !isOpen }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = stringRes(R.string.messages_create_public_private_chat_description), - modifier = - Modifier.size(26.dp).graphicsLayer { - rotationZ = rotationDegree - }, - tint = Color.White, - ) - } } } 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 new file mode 100644 index 0000000000..828e20a0c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/NewConversationScreen.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms + +import androidx.annotation.StringRes +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +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 + +/** + * 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 + * type's existing creation (or browse) flow. + */ +private class ConversationType( + val icon: MaterialSymbol, + @StringRes val title: Int, + @StringRes val tagline: Int, + @StringRes val bestFor: Int, + val pros: List, + val cons: List, + val route: Route, +) + +private class ConversationSection( + @StringRes val header: Int, + 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. +private val conversationSections = + listOf( + ConversationSection( + header = R.string.new_conversation_section_direct, + types = + listOf( + ConversationType( + icon = MaterialSymbols.Mail, + title = R.string.new_conversation_dm_title, + tagline = R.string.new_conversation_dm_tagline, + 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, + ), + route = Route.NewGroupDM(), + ), + ), + ), + ConversationSection( + header = R.string.new_conversation_section_encrypted, + types = + listOf( + ConversationType( + icon = MaterialSymbols.Lock, + title = R.string.new_conversation_marmot_title, + tagline = R.string.new_conversation_marmot_tagline, + 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, + ), + route = Route.CreateMarmotGroup, + ), + ConversationType( + icon = MaterialSymbols.Groups, + title = R.string.new_conversation_concord_title, + tagline = R.string.new_conversation_concord_tagline, + 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, + ), + route = Route.ConcordCreate, + ), + ), + ), + ConversationSection( + header = R.string.new_conversation_section_public, + types = + listOf( + ConversationType( + icon = MaterialSymbols.Public, + title = R.string.new_conversation_public_chat_title, + tagline = R.string.new_conversation_public_chat_tagline, + 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, + ), + route = Route.ChannelMetadataEdit(), + ), + ConversationType( + icon = MaterialSymbols.Dns, + title = R.string.new_conversation_relay_group_title, + tagline = R.string.new_conversation_relay_group_tagline, + 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, + ), + route = Route.RelayGroupBrowse, + ), + ConversationType( + icon = MaterialSymbols.Timer, + title = R.string.new_conversation_ephemeral_title, + tagline = R.string.new_conversation_ephemeral_tagline, + 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, + ), + route = Route.NewEphemeralChat, + ), + ), + ), + ) + +@Composable +fun NewConversationScreen(nav: INav) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(R.string.new_conversation_title), nav) + }, + ) { pad -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 12.dp, + end = 12.dp, + top = pad.calculateTopPadding() + 4.dp, + bottom = pad.calculateBottomPadding() + 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.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 { + Text( + text = stringRes(section.header), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp, top = 6.dp), + ) + } + + section.types.forEach { type -> + item(key = type.title) { + ConversationTypeCard(type) { nav.nav(type.route) } + } + } + } + } + } +} + +@Composable +private fun ConversationTypeCard( + type: ConversationType, + onClick: () -> Unit, +) { + ElevatedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + symbol = type.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + } + + Spacer(Modifier.size(14.dp)) + + Column(Modifier.weight(1f)) { + Text( + text = stringRes(type.title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringRes(type.tagline), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(22.dp), + ) + } + + Spacer(modifier = DoubleVertSpacer) + + 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, + ) + } + + Spacer(modifier = StdVertSpacer) + + type.pros.forEach { ProConRow(it, isPro = true) } + type.cons.forEach { ProConRow(it, isPro = false) } + } + } +} + +@Composable +private fun ProConRow( + @StringRes text: Int, + isPro: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 1.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), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = stringRes(text), + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Preview +@Composable +private fun NewConversationScreenPreview() { + ThemeComparisonColumn { + NewConversationScreen(nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f916216345..2cc4426b6b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2049,6 +2049,65 @@ Public 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 + Encrypted groups + Public & open + + 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 + + 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 + + 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 + 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. + Anyone can join + Discoverable and simple + Not private — messages are public + Limited 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 + + 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 Relay Groups Inline By relay