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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
This commit is contained in:
Claude
2026-07-12 15:45:30 +00:00
parent 070e6242cd
commit cc18043bb3
9 changed files with 583 additions and 24 deletions
@@ -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<NormalizedRelayUrl>,
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
@@ -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<ImmutableList<ZapAmountCommentNotification>>(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<ReactionEntry> =
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)
}
}
@@ -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)
}
}
}
@@ -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<ReactionChip>,
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(
@@ -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,
)
@@ -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
@@ -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<Color>? = 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,
) {
@@ -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(
+3
View File
@@ -2822,6 +2822,9 @@
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
<string name="chat_delivery_pending">Waiting for a relay to accept this message</string>
<string name="chat_delivery_accepted">Accepted by at least one relay</string>
<string name="chat_delivery_delivered_all">Delivered to all recipients\' relays</string>