mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+155
-2
@@ -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
|
||||
|
||||
+204
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
-5
@@ -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(
|
||||
|
||||
+29
@@ -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,
|
||||
)
|
||||
|
||||
+100
@@ -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
|
||||
+4
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+33
-14
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user