From cc18043bb366a29d90eec4d60e5e167ae25b8573 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:45:30 +0000 Subject: [PATCH] feat: chat foundations - engagement details, delivery details, name colors, jumbo emoji Four features building on the redesign's foundations: - Who-reacted/who-zapped sheet: long-press a reaction chip (or tap the sats chip) to open a sheet listing every zapper with amount + comment and every reactor with the emoji they sent; rows open the profile. - Delivery detail: tapping the delivery tick opens a dialog showing per-recipient acceptance for DMs or per-relay acceptance for rooms, with a re-broadcast action for stuck messages. - Per-user name colors: group-chat author names get a stable pubkey-derived hue, tuned separately for light and dark themes. - Jumbo emoji: messages of 1-3 emoji render as large bare emoji with a transparent bubble (scroll-to-highlight still tints them); ZWJ sequences, skin tones, and flags count as single emoji. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX --- .../loggedIn/chats/feed/ChatDeliveryTicks.kt | 157 +++++++++++++- .../chats/feed/ChatEngagementDetailSheet.kt | 204 ++++++++++++++++++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 19 +- .../loggedIn/chats/feed/ChatReactionChips.kt | 43 +++- .../loggedIn/chats/feed/DrawAuthorInfo.kt | 29 +++ .../screen/loggedIn/chats/feed/JumboEmoji.kt | 100 +++++++++ .../chats/feed/layouts/ChatBubbleLayout.kt | 5 +- .../chats/feed/types/RenderRegularTextNote.kt | 47 ++-- amethyst/src/main/res/values/strings.xml | 3 + 9 files changed, 583 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt index 11faa75d41..16bd6fdb8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt @@ -20,13 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -38,11 +47,20 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDelivery +import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.RecipientDelivery +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl /** * Relay-acceptance ticks for the logged-in user's own chat messages, rendered next @@ -61,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText fun ChatDeliveryTicks( baseNote: Note, accountViewModel: AccountViewModel, + nav: INav, ) { val tracker = accountViewModel.account.chatDeliveryTracker @@ -72,9 +91,143 @@ fun ChatDeliveryTicks( remember(baseNote) { baseNote.flow().relays.stateFlow } .collectAsStateWithLifecycle() - val seenSomewhere = seenOnState.note.relays.isNotEmpty() + val seenOnRelays = seenOnState.note.relays + val seenSomewhere = seenOnRelays.isNotEmpty() - RenderDeliveryTicks(delivery, seenSomewhere) + var showDetails by remember { mutableStateOf(false) } + + ClickableBox(onClick = { showDetails = true }) { + RenderDeliveryTicks(delivery, seenSomewhere) + } + + if (showDetails) { + ChatDeliveryDetailDialog( + baseNote = baseNote, + delivery = delivery, + seenOnRelays = seenOnRelays, + onDismiss = { showDetails = false }, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +/** + * Per-recipient (DMs) or per-relay (rooms) acceptance detail behind the tick, + * with a re-broadcast escape hatch for messages stuck on pending relays. + */ +@Composable +private fun ChatDeliveryDetailDialog( + baseNote: Note, + delivery: ChatDelivery?, + seenOnRelays: List, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.chat_delivery_details_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val recipients = delivery?.recipients + when { + recipients != null -> + recipients.forEach { recipient -> + RecipientDeliveryRow(recipient, accountViewModel, nav) + } + + delivery != null -> + delivery.targetRelays.sortedBy { it.url }.forEach { relay -> + RelayDeliveryRow( + relay = relay, + accepted = relay in delivery.acceptedRelays || relay in seenOnRelays, + ) + } + + else -> + // Untracked (sent before a restart): only the seen-on set is known. + seenOnRelays.sortedBy { it.url }.forEach { relay -> + RelayDeliveryRow(relay = relay, accepted = true) + } + } + } + }, + confirmButton = { + if (accountViewModel.canBroadcast(baseNote)) { + TextButton( + onClick = { + accountViewModel.broadcast(baseNote) + onDismiss() + }, + ) { + Text(stringRes(R.string.broadcast)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.close)) + } + }, + ) +} + +@Composable +private fun RecipientDeliveryRow( + recipient: RecipientDelivery, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + LoadUser(baseUserHex = recipient.recipient, accountViewModel = accountViewModel) { user -> + if (user != null) { + UserPicture(user, Size20dp, Modifier, accountViewModel, nav) + Row(modifier = Modifier.weight(1f)) { + UsernameDisplay(baseUser = user, accountViewModel = accountViewModel) + } + } else { + Text(text = recipient.recipient.take(8), modifier = Modifier.weight(1f), maxLines = 1) + } + } + + if (recipient.isDelivered) { + TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, MaterialTheme.colorScheme.allGoodColor) + } else { + TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText) + } + } +} + +@Composable +private fun RelayDeliveryRow( + relay: NormalizedRelayUrl, + accepted: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = relay.displayUrl(), + modifier = Modifier.weight(1f), + maxLines = 1, + ) + + if (accepted) { + TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, MaterialTheme.colorScheme.allGoodColor) + } else { + TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText) + } + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt new file mode 100644 index 0000000000..f69d718fba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt @@ -0,0 +1,204 @@ +/* + * 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.feed + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Immutable +private data class ReactionEntry( + val user: User, + val reactionType: String, +) + +/** + * Who engaged with a chat message: every zapper with their amount and comment, + * then every reactor with the emoji they sent. Opened from the bubble's + * engagement chips (long-press a reaction chip, tap the sats chip). Rows navigate + * to the user's profile. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatEngagementDetailSheet( + baseNote: Note, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val reactionsState by observeNoteReactions(baseNote, accountViewModel) + + val reactionEntries = + remember(reactionsState) { + buildReactionEntries(reactionsState?.note ?: baseNote) + } + + val zaps by + produceState>(initialValue = persistentListOf(), baseNote) { + accountViewModel.decryptAmountMessageInGroup(baseNote) { value = it } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + zaps.forEach { zap -> + ZapperRow(zap, accountViewModel, nav, onDismiss) + } + + reactionEntries.forEach { entry -> + ReactorRow(entry, accountViewModel, nav, onDismiss) + } + + Spacer(Modifier.height(24.dp)) + } + } +} + +private fun buildReactionEntries(note: Note): ImmutableList = + note.reactions + .toList() + .sortedByDescending { it.second.size } + .flatMap { (type, reactionNotes) -> + reactionNotes.mapNotNull { reactionNote -> + reactionNote.author?.let { ReactionEntry(it, type) } + } + }.toImmutableList() + +@Composable +private fun ZapperRow( + zap: ZapAmountCommentNotification, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { + zap.user?.let { + nav.nav(routeFor(it)) + onDismiss() + } + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + zap.user?.let { user -> + UserPicture(user, Size25dp, Modifier, accountViewModel, nav) + Row(modifier = Modifier.weight(1f)) { + UsernameDisplay(baseUser = user, accountViewModel = accountViewModel) + } + } ?: Spacer(Modifier.weight(1f)) + + zap.amount?.let { amount -> + Text( + text = "⚡ $amount", + color = BitcoinOrange, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + } + + zap.comment?.takeIf { it.isNotBlank() }?.let { comment -> + Text( + text = comment, + fontSize = Font12SP, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(start = 33.dp, top = 2.dp), + ) + } + } +} + +@Composable +private fun ReactorRow( + entry: ReactionEntry, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { + nav.nav(routeFor(entry.user)) + onDismiss() + }, + ) { + UserPicture(entry.user, Size25dp, Modifier, accountViewModel, nav) + Row(modifier = Modifier.weight(1f)) { + UsernameDisplay(baseUser = entry.user, accountViewModel = accountViewModel) + } + ChipReactionGlyph(entry.reactionType) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index f7df0334d4..341d883d4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -213,6 +213,20 @@ fun NormalChatNote( } } + // Emoji-only messages render as bare jumbo emoji, no bubble fill. Encrypted + // content that hasn't hit the decrypt cache yet just isn't jumbo (ciphertext + // never passes the emoji-only check). + val isJumboEmoji = + remember(note.event) { + val noteEvent = note.event + if (noteEvent is DraftWrapEvent) { + false + } else { + val content = accountViewModel.cachedDecrypt(note) ?: noteEvent?.content + content != null && jumboEmojiCount(content) > 0 + } + } + ChatBubbleLayout( isLoggedInUser = isLoggedInUser, isDraft = note.event is DraftWrapEvent, @@ -223,6 +237,7 @@ fun NormalChatNote( hasDetailsToShow = false, drawAuthorInfo = drawAuthorInfo && groupPosition.isFirstOfGroup, groupPosition = groupPosition, + transparentBubble = isJumboEmoji, parentBackgroundColor = parentBackgroundColor, shouldHighlight = shouldHighlight, onHighlightFinished = onHighlightFinished, @@ -258,7 +273,7 @@ fun NormalChatNote( }, reactionsRow = if (!innerQuote) { - { ChatReactionChips(note, accountViewModel) } + { ChatReactionChips(note, accountViewModel, nav) } } else { null }, @@ -271,7 +286,7 @@ fun NormalChatNote( ) { ChatTimeAgo(note) if (isLoggedInUser && !note.isDraft()) { - ChatDeliveryTicks(note, accountViewModel) + ChatDeliveryTicks(note, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index 974c70c102..b543c576a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -33,9 +35,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.compositeOver import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight @@ -46,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCorner import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LikedIcon import com.vitorpamplona.amethyst.ui.note.ObserveZapAmountText import com.vitorpamplona.amethyst.ui.note.ZappedIcon @@ -77,6 +83,7 @@ private data class ReactionChip( fun ChatReactionChips( baseNote: Note, accountViewModel: AccountViewModel, + nav: INav, ) { val reactionsState by observeNoteReactions(baseNote, accountViewModel) @@ -93,11 +100,23 @@ fun ChatReactionChips( } } + var showDetails by remember { mutableStateOf(false) } + + if (showDetails) { + ChatEngagementDetailSheet( + baseNote = baseNote, + onDismiss = { showDetails = false }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + ObserveZapAmountText(baseNote, accountViewModel) { zapAmount -> RenderChatReactionChips( chips = chips, zapAmount = zapAmount, onToggleReaction = { accountViewModel.reactToOrDelete(baseNote, it) }, + onOpenDetails = { showDetails = true }, ) } } @@ -108,6 +127,7 @@ private fun RenderChatReactionChips( chips: ImmutableList, zapAmount: String, onToggleReaction: (String) -> Unit, + onOpenDetails: () -> Unit, ) { if (chips.isEmpty() && zapAmount.isBlank()) return @@ -119,19 +139,25 @@ private fun RenderChatReactionChips( verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (zapAmount.isNotBlank()) { - ZapChip(zapAmount) + ZapChip(zapAmount, onClick = onOpenDetails) } chips.forEach { chip -> - ReactionChipView(chip, onClick = { onToggleReaction(chip.type) }) + ReactionChipView( + chip = chip, + onClick = { onToggleReaction(chip.type) }, + onLongClick = onOpenDetails, + ) } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ReactionChipView( chip: ReactionChip, onClick: () -> Unit, + onLongClick: () -> Unit, ) { val background = if (chip.includesMe) { @@ -150,10 +176,13 @@ private fun ReactionChipView( } Surface( - onClick = onClick, shape = ButtonBorder, color = background, border = border, + modifier = + Modifier + .clip(ButtonBorder) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), ) { ChipContentRow { ChipReactionGlyph(chip.type) @@ -171,8 +200,12 @@ private fun ReactionChipView( } @Composable -private fun ZapChip(amount: String) { +private fun ZapChip( + amount: String, + onClick: () -> Unit, +) { Surface( + onClick = onClick, shape = ButtonBorder, color = MaterialTheme.colorScheme.surfaceVariant, border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder), @@ -202,7 +235,7 @@ private fun ChipContentRow(content: @Composable () -> Unit) { } @Composable -private fun ChipReactionGlyph(reactionType: String) { +internal fun ChipReactionGlyph(reactionType: String) { if (reactionType.startsWith(":")) { val url = reactionType.removePrefix(":").substringAfter(":") InLineIconRenderer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 2488506058..dc79f3f888 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -20,10 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.model.Note @@ -39,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.UserDisplayNameLayout import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size5Modifier +import com.vitorpamplona.amethyst.ui.theme.isLight @Composable fun DrawAuthorInfo( @@ -51,6 +55,23 @@ fun DrawAuthorInfo( } } +/** + * A stable, pubkey-derived name color so authors are scannable in fast-moving + * group rooms. The hue comes from the pubkey; saturation/lightness are tuned per + * theme so every hue stays readable on the "them" bubble fill. + */ +fun authorNameColorFor( + pubkeyHex: String, + isLightTheme: Boolean, +): Color { + val hue = (pubkeyHex.take(6).toIntOrNull(16) ?: pubkeyHex.hashCode()).mod(360).toFloat() + return if (isLightTheme) { + Color.hsl(hue, saturation = 0.70f, lightness = 0.35f) + } else { + Color.hsl(hue, saturation = 0.55f, lightness = 0.70f) + } +} + @Composable private fun WatchAndDisplayUser( author: User, @@ -59,6 +80,12 @@ private fun WatchAndDisplayUser( ) { val userState by observeUserInfo(author, accountViewModel) + val isLightTheme = MaterialTheme.colorScheme.isLight + val nameColor = + remember(author.pubkeyHex, isLightTheme) { + authorNameColorFor(author.pubkeyHex, isLightTheme) + } + UserDisplayNameLayout( picture = { InnerUserPicture( @@ -83,6 +110,7 @@ private fun WatchAndDisplayUser( CreateTextWithEmoji( text = userState?.info?.bestName() ?: author.pubkeyDisplayHex(), tags = userState?.tags ?: EmptyTagList, + color = nameColor, maxLines = 1, fontWeight = FontWeight.Bold, ) @@ -90,6 +118,7 @@ private fun WatchAndDisplayUser( CreateTextWithEmoji( text = author.pubkeyDisplayHex(), tags = EmptyTagList, + color = nameColor, maxLines = 1, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt new file mode 100644 index 0000000000..0852f51270 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt @@ -0,0 +1,100 @@ +/* + * 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.feed + +/** A message of up to this many emoji renders as jumbo emoji without a bubble. */ +const val MAX_JUMBO_EMOJI = 3 + +/** + * Number of emoji in [content] when the message is emoji-only (ignoring + * whitespace) and short enough to render jumbo, or 0 when it contains any + * non-emoji text or more than [MAX_JUMBO_EMOJI] emoji. + * + * Modifier code points (variation selectors, ZWJ, skin tones, keycaps) don't + * count as separate emoji, so a ZWJ family or a flag counts what it draws as. + */ +fun jumboEmojiCount(content: String): Int { + var count = 0 + var previousWasZwj = false + var pendingRegionalIndicator = false + + var i = 0 + while (i < content.length) { + val cp = content.codePointAt(i) + i += Character.charCount(cp) + + when { + Character.isWhitespace(cp) -> { + previousWasZwj = false + } + + isEmojiModifier(cp) -> { + previousWasZwj = cp == ZWJ + } + + isRegionalIndicator(cp) -> { + // Two regional indicators pair into one flag. + if (pendingRegionalIndicator) { + pendingRegionalIndicator = false + } else { + pendingRegionalIndicator = true + count++ + } + previousWasZwj = false + } + + isEmojiBase(cp) -> { + if (!previousWasZwj) count++ + previousWasZwj = false + } + + else -> return 0 + } + + if (count > MAX_JUMBO_EMOJI) return 0 + } + + return count +} + +private const val ZWJ = 0x200D + +private fun isEmojiModifier(cp: Int): Boolean = + cp == ZWJ || + cp == 0xFE0E || // variation selector 15 (text style) + cp == 0xFE0F || // variation selector 16 (emoji style) + cp == 0x20E3 || // combining enclosing keycap + cp in 0x1F3FB..0x1F3FF // skin tones + +private fun isRegionalIndicator(cp: Int): Boolean = cp in 0x1F1E6..0x1F1FF + +private fun isEmojiBase(cp: Int): Boolean = + cp in 0x1F000..0x1FAFF || // pictographs, emoticons, transport, supplemental, extended-A + cp in 0x2600..0x27BF || // misc symbols + dingbats (hearts, stars, hands) + cp in 0x2B00..0x2BFF || // arrows-C block (star, heavy circles) + cp == 0x203C || // double exclamation + cp == 0x2049 || // exclamation question + cp == 0x00A9 || // copyright (with VS16) + cp == 0x00AE || // registered (with VS16) + cp in 0x2934..0x2935 || // arrow emoji + cp in 0x3297..0x3299 || // circled ideographs + cp == 0x3030 || // wavy dash + cp == 0x303D // part alternation mark diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 7a7b47accf..81097d04c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -80,6 +80,8 @@ fun ChatBubbleLayout( hasDetailsToShow: Boolean, drawAuthorInfo: Boolean, groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE, + // Jumbo-emoji messages render without a bubble fill. + transparentBubble: Boolean = false, parentBackgroundColor: MutableState? = null, shouldHighlight: Boolean = false, onHighlightFinished: (() -> Unit)? = null, @@ -178,7 +180,8 @@ fun ChatBubbleLayout( modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, ) { Surface( - color = animatedColor, + // Jumbo emoji show bare; the scroll-to highlight still tints them. + color = if (transparentBubble && !highlightActive.value) Color.Transparent else animatedColor, shape = chatBubbleShapeFor(isLoggedInUser, if (innerQuote) ChatGroupPosition.SINGLE else groupPosition), modifier = clickableModifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index ba32f860f2..6b3b681378 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists @@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount import com.vitorpamplona.amethyst.ui.stringRes @Composable @@ -51,21 +54,37 @@ fun RenderRegularTextNote( note = note, accountViewModel = accountViewModel, ) { - val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + val jumboCount = remember(eventContent) { jumboEmojiCount(eventContent) } - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview, - quotesLeft = if (innerQuote) 0 else 1, - modifier = Modifier, - tags = tags, - backgroundColor = bgColor, - id = note.idHex, - callbackUri = note.toNostrUri(), - authorPubKey = note.author?.pubkeyHex, - accountViewModel = accountViewModel, - nav = nav, - ) + if (jumboCount > 0) { + // Emoji-only messages render as jumbo emoji (the bubble behind + // them is transparent — see NormalChatNote). + Text( + text = eventContent.trim(), + fontSize = + when (jumboCount) { + 1 -> 50.sp + 2 -> 40.sp + else -> 32.sp + }, + ) + } else { + val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview, + quotesLeft = if (innerQuote) 0 else 1, + modifier = Modifier, + tags = tags, + backgroundColor = bgColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + authorPubKey = note.author?.pubkeyHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } else { TranslatableRichTextViewer( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c10d2fc861..e44b8b858b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2822,6 +2822,9 @@ %1$s created the channel %1$s updated the channel profile + Message Delivery + Close + Waiting for a relay to accept this message Accepted by at least one relay Delivered to all recipients\' relays