feat(chat): quiet the chat screen down

The chat screen put its color in the wrong places: my own bubbles were a
45% purple slab, every bubble carried a green "✓✓ 6/6" and a long relative
time, and each author line was a picture with trust scores plus a bold
hashed name inside the bubble.

- My bubbles: chatBubbleMe drops to 24% (dark) / 20% (light), a muted
  tint that carries white text.
- Footer: clock time ("9:08", "Sep 28, 9:08") instead of "32 min. ago";
  delivered ticks in the caption gray; group DMs show the k/n count only
  while someone is still missing the message. Red stays for failures.
- Authors: the face moves outside the bubble, beside the last bubble of
  each run (ChatBubbleLayout.authorAvatar, ChatAuthorAvatar), without
  score or follow badges; the name inside is a 13sp semibold label in a
  softer hash color. Applies to NIP-17/NIP-28/NIP-29 chats and cordn.
- Rhythm: 2dp between bubbles of a run, ~10dp between runs; bubbles cap
  at 78% of the row.
- Header: two overlapping faces (ChatRoomFaces), semibold title and an
  "N members" subtitle for group DMs.
- Rename: a subject change is a centered caption ("You renamed the chat
  to “X”"), shown only when the subject actually changes; day dividers
  are caption type instead of bold.
- Reaction chips: a background-colored ring instead of an outline.
- Composer: "Message" placeholder in chat composers; the send button is
  a filled accent disc once there is something to send.
- ChatScreenRenderTest renders the screen offscreen (dark | light) to
  commonsUI/build/chat-screen/chat.png; SubjectChangeTest covers the
  rename detection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113Y3NP1fRnbQJyrH6D6YQj
This commit is contained in:
Claude
2026-10-02 02:08:14 +00:00
parent 02a5ec4820
commit b5b0fed04c
22 changed files with 641 additions and 145 deletions
@@ -51,7 +51,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -59,7 +58,6 @@ import com.vitorpamplona.amethyst.commons.chats.ui.CHAT_GROUP_WINDOW_SECONDS
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiFontSize import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiFontSize
import com.vitorpamplona.amethyst.commons.cordn.CordnMentions import com.vitorpamplona.amethyst.commons.cordn.CordnMentions
@@ -100,6 +98,9 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ActionTile import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ActionTile
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatAuthorAvatar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatAuthorNameSize
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatAuthorNameWeight
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatChipFlowRow import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatChipFlowRow
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.MoreActionsToggle import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.MoreActionsToggle
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ReactionChip import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ReactionChip
@@ -256,6 +257,13 @@ internal fun CordnMessageRow(
null null
}, },
drawAuthorLine = { CordnAuthorLine(message.envelope.pubKey, accountViewModel, nav) }, drawAuthorLine = { CordnAuthorLine(message.envelope.pubKey, accountViewModel, nav) },
// Everyone else's face sits beside their run, as in every other group chat.
authorAvatar =
if (isMine) {
null
} else {
{ ChatAuthorAvatar(message.envelope.pubKey, accountViewModel) { nav.nav(Route.Profile(message.envelope.pubKey)) } }
},
) { bgColor -> ) { bgColor ->
CordnBubbleContents( CordnBubbleContents(
message = message, message = message,
@@ -356,10 +364,9 @@ private fun CordnBubbleContents(
} }
/** /**
* Name and face on the first bubble of a burst, in the shared chat author layout — so a * Name on the first bubble of a burst, styled like a DM sender's, colour included — the
* cordn sender is drawn exactly like a DM sender, colour included. [authorNameColorFor] * face sits beside the burst ([ChatAuthorAvatar]). [authorNameColorFor] derives a stable
* derives a stable hue from the pubkey, which is what makes authors scannable in a * hue from the pubkey, which is what makes authors scannable in a fast-moving room.
* fast-moving room.
* *
* [observeUserNameByHex] falls back to a hex prefix until the profile arrives. Looking a * [observeUserNameByHex] falls back to a hex prefix until the profile arrives. Looking a
* sender up for a display name is safe: a profile is public relay data the cache already * sender up for a display name is safe: a profile is public relay data the cache already
@@ -375,24 +382,13 @@ private fun CordnAuthorLine(
val isLightTheme = MaterialTheme.colorScheme.isLight val isLightTheme = MaterialTheme.colorScheme.isLight
val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) } val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) }
UserDisplayNameLayout( Text(
picture = { text = name,
UserPicture( color = nameColor,
userHex = pubKey, fontWeight = ChatAuthorNameWeight,
size = Size20dp, fontSize = ChatAuthorNameSize,
accountViewModel = accountViewModel, maxLines = 1,
nav = nav, overflow = TextOverflow.Ellipsis,
)
},
name = {
Text(
text = name,
color = nameColor,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
) )
} }
@@ -407,6 +407,9 @@
<string name="could_not_decrypt_the_message">Could not decrypt the message</string> <string name="could_not_decrypt_the_message">Could not decrypt the message</string>
<string name="chat_preview_decrypting">Decrypting…</string> <string name="chat_preview_decrypting">Decrypting…</string>
<string name="chat_preview_you_prefix">You: %1$s</string> <string name="chat_preview_you_prefix">You: %1$s</string>
<string name="chat_subject_changed_by">%1$s renamed the chat to “%2$s”</string>
<string name="chat_subject_changed_by_you">You renamed the chat to “%1$s”</string>
<string name="chat_composer_hint">Message</string>
<string name="chat_preview_photo">Photo</string> <string name="chat_preview_photo">Photo</string>
<string name="chat_preview_video">Video</string> <string name="chat_preview_video">Video</string>
<string name="chat_preview_note">Shared a note</string> <string name="chat_preview_note">Shared a note</string>
@@ -4316,6 +4319,10 @@
<string name="marmot_group_disbanded_toast">Group disbanded</string> <string name="marmot_group_disbanded_toast">Group disbanded</string>
<string name="marmot_group_disbanding_toast">Ending the group. It finishes once the group agrees.</string> <string name="marmot_group_disbanding_toast">Ending the group. It finishes once the group agrees.</string>
<string name="marmot_group_info_updated">Group info updated</string> <string name="marmot_group_info_updated">Group info updated</string>
<plurals name="chat_room_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="marmot_member_count"> <plurals name="marmot_member_count">
<item quantity="one">%1$d member</item> <item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item> <item quantity="other">%1$d members</item>
@@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -103,6 +104,10 @@ private val ChatChipOverlapArrangement = Arrangement.spacedBy((-12).dp)
// already has 6dp bottom padding; the chips poke ~12dp in, so ~8dp more clears them. // already has 6dp bottom padding; the chips poke ~12dp in, so ~8dp more clears them.
private val ChatChipOverlapReserve = 8.dp private val ChatChipOverlapReserve = 8.dp
// The column the author face sits in, beside a group chat's bubbles, and its gap to them.
private val ChatAuthorGutterModifier = Modifier.width(28.dp)
private val ChatAuthorGutterGap = Modifier.width(6.dp)
// Swipe-to-reply: releasing past the threshold fires the reply; the bubble never // Swipe-to-reply: releasing past the threshold fires the reply; the bubble never
// drags further than the max. // drags further than the max.
private val SwipeReplyThreshold = 56.dp private val SwipeReplyThreshold = 56.dp
@@ -133,6 +138,10 @@ fun ChatBubbleLayout(
// status glyphs). Null skips it entirely. // status glyphs). Null skips it entirely.
footerRow: (@Composable () -> Unit)? = null, footerRow: (@Composable () -> Unit)? = null,
drawAuthorLine: @Composable () -> Unit, drawAuthorLine: @Composable () -> Unit,
// The author's face in a group chat, drawn OUTSIDE the bubble, beside the last bubble of
// the author's run (the other bubbles of the run keep its width as an indent so the run
// lines up). Null for one-on-one chats, own messages and quotes: no gutter at all.
authorAvatar: (@Composable () -> Unit)? = null,
inner: @Composable (MutableState<Color>) -> Unit, inner: @Composable (MutableState<Color>) -> Unit,
) { ) {
val loggedInColors = MaterialTheme.colorScheme.chatBubbleMe val loggedInColors = MaterialTheme.colorScheme.chatBubbleMe
@@ -306,7 +315,14 @@ fun ChatBubbleLayout(
Row( Row(
modifier = (if (innerQuote) Modifier else Modifier.fillMaxWidth()).then(swipeModifier), modifier = (if (innerQuote) Modifier else Modifier.fillMaxWidth()).then(swipeModifier),
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) { ) {
if (authorAvatar != null && !isLoggedInUser && !innerQuote) {
Box(ChatAuthorGutterModifier) {
if (groupPosition.isLastOfGroup) authorAvatar()
}
Spacer(ChatAuthorGutterGap)
}
InnerChatBubble( InnerChatBubble(
isLoggedInUser = isLoggedInUser, isLoggedInUser = isLoggedInUser,
innerQuote = innerQuote, innerQuote = innerQuote,
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.commons.chats.ui package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DividerDefaults import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -30,11 +33,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
/**
* A day (or status) divider between chat messages: hairlines either side of a caption-sized
* label. Caption type, not bold: it marks where the conversation crossed a day, it is not a
* heading the eye should land on before the messages.
*/
@Composable @Composable
fun ChatDivisor( fun ChatDivisor(
info: String, info: String,
@@ -53,9 +64,9 @@ fun ChatDivisor(
) )
Text( Text(
text = info, text = info,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
fontSize = Font14SP, fontSize = Font12SP,
color = color, color = if (color.isSpecified) color else MaterialTheme.colorScheme.placeholderText,
modifier = HalfPadding, modifier = HalfPadding,
) )
HorizontalDivider( HorizontalDivider(
@@ -65,3 +76,21 @@ fun ChatDivisor(
) )
} }
} }
/**
* Something that happened to the conversation rather than a message in it (a rename): one
* centered line in caption type, no bubble and no rules, so it never competes with what
* people said.
*/
@Composable
fun ChatSystemCaption(text: String) {
Text(
text = text,
fontSize = Font12SP,
color = MaterialTheme.colorScheme.placeholderText,
textAlign = TextAlign.Center,
modifier = ChatSystemCaptionModifier,
)
}
private val ChatSystemCaptionModifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 10.dp)
@@ -21,19 +21,33 @@
package com.vitorpamplona.amethyst.commons.chats.ui package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chat_subject_changed_by
import com.vitorpamplona.amethyst.commons.resources.chat_subject_changed_by_you
import com.vitorpamplona.amethyst.commons.resources.never import com.vitorpamplona.amethyst.commons.resources.never
import com.vitorpamplona.amethyst.commons.resources.today import com.vitorpamplona.amethyst.commons.resources.today
import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter
import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip14Subject.subject
/**
* The quiet rows between messages: a day divider when the date changes, and a one-line caption
* when a message renames the conversation ("Alice renamed the chat to “Product”").
*
* The rename used to be a bold divider holding just the new name, which read like a section
* heading and said nothing about what happened. Now it says who did what, in caption type.
*/
@Composable @Composable
fun NewDateOrSubjectDivisor( fun NewDateOrSubjectDivisor(
previous: Note?, previous: Note?,
note: Note, note: Note,
accountViewModel: AccountViewModel,
) { ) {
if (previous == null) return if (previous == null) return
@@ -43,17 +57,35 @@ fun NewDateOrSubjectDivisor(
val prevDate = remember(previous) { dateFormatter(previous.event?.createdAt, never, today) } val prevDate = remember(previous) { dateFormatter(previous.event?.createdAt, never, today) }
val date = remember(note) { dateFormatter(note.event?.createdAt, never, today) } val date = remember(note) { dateFormatter(note.event?.createdAt, never, today) }
val subject = remember(note) { note.event?.subject() }
if (prevDate != date) { if (prevDate != date) {
if (subject != null) { ChatDivisor(date)
ChatDivisor("$date - $subject") }
} else {
ChatDivisor(date) val newSubject = remember(previous, note) { subjectChangeOf(previous.event, note.event) }
} if (newSubject != null) {
} else { val author = note.author
if (subject != null) { val text =
ChatDivisor(subject) when {
} author == null -> newSubject
accountViewModel.isLoggedUser(author) -> stringRes(Res.string.chat_subject_changed_by_you, newSubject)
else -> {
val name by observeUserName(author, accountViewModel)
stringRes(Res.string.chat_subject_changed_by, name, newSubject)
}
}
ChatSystemCaption(text)
} }
} }
/**
* The subject [newer] sets, or null when it sets none or repeats the one [older] already carried.
* Some clients tag every message of a named group with its subject; without the repeat check each
* of those would announce a rename.
*/
fun subjectChangeOf(
older: Event?,
newer: Event?,
): String? {
val subject = newer?.subject()?.trim()?.ifBlank { null } ?: return null
return if (older?.subject()?.trim() == subject) null else subject
}
@@ -20,16 +20,29 @@
*/ */
package com.vitorpamplona.amethyst.commons.chats.ui package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.accessibility_send import com.vitorpamplona.amethyst.commons.resources.accessibility_send
import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
/**
* The composer's send button. Idle it is a quiet glyph; once there is something to send it
* becomes a filled accent disc, so the one action the screen is waiting for is the thing
* that stands out, and an outline-gray arrow no longer looks disabled when it is not.
*/
@Composable @Composable
fun ThinSendButton( fun ThinSendButton(
isActive: Boolean, isActive: Boolean,
@@ -41,10 +54,28 @@ fun ThinSendButton(
// modifier = modifier, // modifier = modifier,
onClick = onClick, onClick = onClick,
) { ) {
Icon( if (isActive) {
symbol = MaterialSymbols.AutoMirrored.Send, Box(
contentDescription = stringRes(id = Res.string.accessibility_send), contentAlignment = Alignment.Center,
modifier = Size25Modifier, modifier = SendDiscModifier.background(MaterialTheme.colorScheme.primary, CircleShape),
) ) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(id = Res.string.accessibility_send),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = SendGlyphModifier,
)
}
} else {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(id = Res.string.accessibility_send),
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Size25Modifier,
)
}
} }
} }
private val SendDiscModifier = Modifier.size(36.dp)
private val SendGlyphModifier = Modifier.size(19.dp)
@@ -358,7 +358,6 @@ private fun RenderDeliveryTicks(
onRetry: () -> Unit, onRetry: () -> Unit,
) { ) {
val pendingColor = MaterialTheme.colorScheme.placeholderText val pendingColor = MaterialTheme.colorScheme.placeholderText
val deliveredColor = MaterialTheme.colorScheme.allGoodColor
// The send's own lifecycle outranks relay acceptance: until the event has // The send's own lifecycle outranks relay acceptance: until the event has
// been handed to the relay pool there is nothing for a relay to have // been handed to the relay pool there is nothing for a relay to have
@@ -398,19 +397,23 @@ private fun RenderDeliveryTicks(
// the sender's own self-copy wrap only shows in the detail dialog. // the sender's own self-copy wrap only shows in the detail dialog.
val others = delivery.otherRecipients val others = delivery.otherRecipients
if (others != null && others.size > 1) { if (others != null && others.size > 1) {
// Group DM: double check once everyone got it, plus a delivered count. // Group DM: a double check once everyone got it. The k/n count only shows while
// someone is still missing it: "6/6" on every bubble restated the double check in a
// bright color, while "4/6" is the one case worth reading.
val deliveredCount = others.count { it.isDelivered } val deliveredCount = others.count { it.isDelivered }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
DeliveryLadderTick( DeliveryLadderTick(
pending = deliveredCount == 0 && !seenSomewhere, pending = deliveredCount == 0 && !seenSomewhere,
fullyAccepted = delivery.isFullyAccepted, fullyAccepted = delivery.isFullyAccepted,
) )
Text( if (!delivery.isFullyAccepted) {
text = "$deliveredCount/${others.size}", Text(
fontSize = Font12SP, text = "$deliveredCount/${others.size}",
color = if (delivery.isFullyAccepted) deliveredColor else pendingColor, fontSize = Font12SP,
maxLines = 1, color = pendingColor,
) maxLines = 1,
)
}
} }
return return
} }
@@ -433,8 +436,11 @@ private fun DeliveryLadderTick(
pending -> pending ->
TickIcon(MaterialSymbols.Schedule, Res.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText) TickIcon(MaterialSymbols.Schedule, Res.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText)
// Same caption gray as the time beside it: the single vs double check already says
// how far it got, and a green glyph on every bubble was a third accent on the screen.
// Color is kept for the states that need the reader: rejected and failed.
fullyAccepted -> fullyAccepted ->
TickIcon(MaterialSymbols.DoneAll, Res.string.chat_delivery_delivered_all, MaterialTheme.colorScheme.allGoodColor) TickIcon(MaterialSymbols.DoneAll, Res.string.chat_delivery_delivered_all, MaterialTheme.colorScheme.placeholderText)
else -> else ->
TickIcon(MaterialSymbols.Done, Res.string.chat_delivery_accepted, MaterialTheme.colorScheme.placeholderText) TickIcon(MaterialSymbols.Done, Res.string.chat_delivery_accepted, MaterialTheme.colorScheme.placeholderText)
@@ -269,7 +269,7 @@ fun ChatFeedLoaded(
// before the bubble. Composing it after put the header below its own message — // before the bubble. Composing it after put the header below its own message —
// i.e. visually heading the NEXT (newer) message while showing this one's date, // i.e. visually heading the NEXT (newer) message while showing this one's date,
// which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble. // which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble.
NewDateOrSubjectDivisor(older, item) NewDateOrSubjectDivisor(older, item, accountViewModel)
// Per-relay paging markers for the gap toward the next-older message. Older items sit // Per-relay paging markers for the gap toward the next-older message. Older items sit
// ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble — // ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble —
@@ -386,6 +386,18 @@ fun NormalChatNote(
nav, nav,
) )
}, },
// Group chats put each author's face beside their run; one-on-one chats and my own
// messages have none (drawAuthorInfo is already false for both).
authorAvatar =
if (drawAuthorInfo && !isLoggedInUser && !innerQuote) {
{
note.author?.let { author ->
ChatAuthorAvatar(author.pubkeyHex, accountViewModel) { nav.nav(routeFor(author)) }
}
}
} else {
null
},
) { bgColor -> ) { bgColor ->
MessageBubbleLines( MessageBubbleLines(
note, note,
@@ -75,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -212,6 +211,15 @@ fun ChatReactionChips(
} }
} }
/**
* The edge of an engagement chip: a ring in the screen's background color rather than a
* hairline. Riding the bubble's border, the ring cuts the chip out of the bubble cleanly;
* the outlined pill it replaces drew one more line on every bubble that had a reaction.
* A chip I'm part of says so with its accent fill alone.
*/
@Composable
fun chatChipRing(): BorderStroke = BorderStroke(2.dp, MaterialTheme.colorScheme.background)
/** /**
* The strip of engagement chips that rides a chat bubble's bottom border. Shared so * The strip of engagement chips that rides a chat bubble's bottom border. Shared so
* every chat surface lays its chips out identically — [ChatReactionChips] fills it from * every chat surface lays its chips out identically — [ChatReactionChips] fills it from
@@ -276,7 +284,7 @@ private fun MinichatChip(
Surface( Surface(
shape = ButtonBorder, shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder), border = chatChipRing(),
// Plain clickable (not the Surface onClick overload) so the chip keeps its // Plain clickable (not the Surface onClick overload) so the chip keeps its
// content height instead of being padded to the 48dp minimum touch target, // content height instead of being padded to the 48dp minimum touch target,
// which would drop it below the bubble border the reaction chips ride. // which would drop it below the bubble border the reaction chips ride.
@@ -316,17 +324,10 @@ fun ReactionChipView(
MaterialTheme.colorScheme.surfaceVariant MaterialTheme.colorScheme.surfaceVariant
} }
val border =
if (chip.includesMe) {
BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.60f))
} else {
BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder)
}
Surface( Surface(
shape = ButtonBorder, shape = ButtonBorder,
color = background, color = background,
border = border, border = chatChipRing(),
modifier = modifier =
Modifier Modifier
.clip(ButtonBorder) .clip(ButtonBorder)
@@ -355,7 +356,7 @@ private fun ZapChip(
Surface( Surface(
shape = ButtonBorder, shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder), border = chatChipRing(),
// Plain clickable (not the Surface onClick overload) so the chip keeps its // Plain clickable (not the Surface onClick overload) so the chip keeps its
// content height instead of being padded to the 48dp minimum touch target, // content height instead of being padded to the 48dp minimum touch target,
// which would drop it below the bubble border the reaction chips ride. // which would drop it below the bubble border the reaction chips ride.
@@ -385,7 +386,7 @@ private fun PendingZapChip() {
Surface( Surface(
shape = ButtonBorder, shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder), border = chatChipRing(),
) { ) {
ChipContentRow { ChipContentRow {
ZappedIcon(Size14Modifier) ZappedIcon(Size14Modifier)
@@ -41,8 +41,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_pinned_content_d
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayLocation import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayLocation
import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.commons.ui.note.elements.DisplayPoW
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.commons.ui.note.timeAbsoluteNoDot
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
@@ -54,17 +53,21 @@ import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.expiration
/**
* The time on a chat bubble, as a clock time ("9:08"; "Sep 28, 9:08" once it is not today).
*
* Not relative: "32 min. ago" on every bubble was long, kept changing under the reader, and
* made a column of near-identical captions. A conversation is read by when things were said.
* The chat time is wrapped in a tap target that opens the relay/delivery dialog, so it is
* plain text with no toggle of its own.
*/
@Composable @Composable
fun ChatTimeAgo(baseNote: Note) { fun ChatTimeAgo(baseNote: Note) {
ToggleableTimeAgoText( Text(
timestamp = baseNote.createdAt() ?: 0L, text = timeAbsoluteNoDot(baseNote.createdAt() ?: 0L),
style = TimeAgoStyle.Short,
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP, fontSize = Font12SP,
// The chat time is wrapped in a tap target that opens the relay/delivery dialog, maxLines = 1,
// so it must not steal the tap to toggle relative/absolute. The absolute time is
// shown in that dialog instead.
toggleable = false,
) )
} }
@@ -20,20 +20,23 @@
*/ */
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel
import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.User
@@ -42,17 +45,19 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.FollowingIcon
import com.vitorpamplona.amethyst.commons.ui.note.InnerUserPicture import com.vitorpamplona.amethyst.commons.ui.note.InnerUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.ObserveAndRenderUserCards import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.WatchUserFollows
import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.isLight import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* The author's name at the top of the first bubble of their run. Name only: the face sits
* outside the bubble, beside the run's last bubble ([ChatAuthorAvatar]), so the bubble keeps
* to what was said instead of opening with a picture and its badges.
*/
@Composable @Composable
fun DrawAuthorInfo( fun DrawAuthorInfo(
baseNote: Note, baseNote: Note,
@@ -77,7 +82,9 @@ fun DrawAuthorInfo(
/** /**
* A stable, pubkey-derived name color so authors are scannable in fast-moving * 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 * group rooms. The hue comes from the pubkey; saturation/lightness are tuned per
* theme so every hue stays readable on the "them" bubble fill. * theme so every hue stays readable on the "them" bubble fill. Saturation is kept
* low: the hue only has to tell two names apart, and at full strength every author
* line was another bright color competing with the message under it.
*/ */
fun authorNameColorFor( fun authorNameColorFor(
pubkeyHex: String, pubkeyHex: String,
@@ -85,9 +92,39 @@ fun authorNameColorFor(
): Color { ): Color {
val hue = (pubkeyHex.take(6).toIntOrNull(16) ?: pubkeyHex.hashCode()).mod(360).toFloat() val hue = (pubkeyHex.take(6).toIntOrNull(16) ?: pubkeyHex.hashCode()).mod(360).toFloat()
return if (isLightTheme) { return if (isLightTheme) {
Color.hsl(hue, saturation = 0.70f, lightness = 0.35f) Color.hsl(hue, saturation = 0.45f, lightness = 0.38f)
} else { } else {
Color.hsl(hue, saturation = 0.55f, lightness = 0.70f) Color.hsl(hue, saturation = 0.38f, lightness = 0.74f)
}
}
/** Size of the author face beside a chat bubble. */
val ChatAuthorAvatarSize = 28.dp
/** Weight and size of the author name inside a bubble: a label over the message, not a heading. */
val ChatAuthorNameWeight = FontWeight.SemiBold
val ChatAuthorNameSize = 13.sp
/**
* The author's face beside the last bubble of their run in a group chat. Plain picture: the
* trust score and follow mark it used to carry inside the bubble were unreadable at this size
* and live on the profile one tap away.
*/
@Composable
fun ChatAuthorAvatar(
userHex: HexKey,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
LoadUser(baseUserHex = userHex) { user ->
val userState = user?.let { observeUserInfo(it, accountViewModel).value }
InnerUserPicture(
userHex = userHex,
userPicture = userState?.info?.picture,
userName = userState?.info?.bestName(),
size = ChatAuthorAvatarSize,
modifier = Modifier.clip(CircleShape).clickable(onClick = onClick),
)
} }
} }
@@ -116,41 +153,21 @@ private fun WatchAndDisplayUser(
authorNameColorFor(author.pubkeyHex, isLightTheme) authorNameColorFor(author.pubkeyHex, isLightTheme)
} }
UserDisplayNameLayout( Row(
picture = { verticalAlignment = Alignment.CenterVertically,
InnerUserPicture( horizontalArrangement = Arrangement.spacedBy(6.dp),
userHex = author.pubkeyHex, ) {
userPicture = userState?.info?.picture, CreateTextWithEmoji(
userName = displayName, text = displayName ?: author.pubkeyDisplayHex(),
size = Size20dp, tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
modifier = Modifier, color = nameColor,
) maxLines = 1,
fontWeight = ChatAuthorNameWeight,
WatchUserFollows(author.pubkeyHex, accountViewModel) { newFollowingState -> fontSize = ChatAuthorNameSize,
if (newFollowingState) { modifier = Modifier.weight(1f, fill = false),
FollowingIcon(Size5Modifier) )
} // An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0),
} // unless its contextual name already does.
BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified)
ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel) }
},
name = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
CreateTextWithEmoji(
text = displayName ?: author.pubkeyDisplayHex(),
tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
color = nameColor,
maxLines = 1,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f, fill = false),
)
// An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0),
// unless its contextual name already does.
BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified)
}
},
)
} }
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_body
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
@@ -286,7 +286,7 @@ fun MarmotGroupMessageComposer(
shape = EditFieldBorder, shape = EditFieldBorder,
placeholder = { placeholder = {
Text( Text(
text = stringRes(Res.string.reply_here), text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -51,15 +52,17 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.call_video import com.vitorpamplona.amethyst.commons.resources.call_video
import com.vitorpamplona.amethyst.commons.resources.call_voice import com.vitorpamplona.amethyst.commons.resources.call_voice
import com.vitorpamplona.amethyst.commons.resources.chat_room_member_count
import com.vitorpamplona.amethyst.commons.resources.edits_the_channel_metadata import com.vitorpamplona.amethyst.commons.resources.edits_the_channel_metadata
import com.vitorpamplona.amethyst.commons.resources.messages_group_descriptor import com.vitorpamplona.amethyst.commons.resources.messages_group_descriptor
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.ChatRoomFaces
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.commons.ui.note.UserCompose import com.vitorpamplona.amethyst.commons.ui.note.UserCompose
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay
import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -67,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.Size34dp import com.vitorpamplona.amethyst.commons.ui.theme.Size34dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@@ -92,7 +96,7 @@ fun RenderRoomTopBar(
Spacer(modifier = DoubleHorzSpacer) Spacer(modifier = DoubleHorzSpacer)
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.SemiBold, accountViewModel = accountViewModel)
if (onVideoCallClick != null) { if (onVideoCallClick != null) {
IconButton( IconButton(
@@ -141,13 +145,26 @@ fun RenderRoomTopBar(
TopBarExtensibleWithBackButton( TopBarExtensibleWithBackButton(
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures( // Two overlapping faces without badges, as on the Messages list: a 2x2
room = room, // mosaic with a trust score on each face is unreadable at header size.
accountViewModel = accountViewModel, ChatRoomFaces(
userHexList = remember(room) { room.users.toList() },
size = Size34dp, size = Size34dp,
accountViewModel = accountViewModel,
) )
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel) // The name, then who is in it: a group header with only a name gave no
// hint of how many people would read what you type.
Column(Modifier.padding(start = 10.dp).weight(1f)) {
RoomNameOnlyDisplay(room, Modifier, FontWeight.SemiBold, accountViewModel)
Text(
// room.users is everyone but me.
text = pluralStringRes(Res.plurals.chat_room_member_count, room.users.size + 1, room.users.size + 1),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
if (onVideoCallClick != null) { if (onVideoCallClick != null) {
IconButton( IconButton(
@@ -54,8 +54,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
import com.vitorpamplona.amethyst.commons.resources.recipient_missing_dm_relays import com.vitorpamplona.amethyst.commons.resources.recipient_missing_dm_relays
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.resources.retry_without_encryption import com.vitorpamplona.amethyst.commons.resources.retry_without_encryption
import com.vitorpamplona.amethyst.commons.resources.this_message_will_disappear_in import com.vitorpamplona.amethyst.commons.resources.this_message_will_disappear_in
import com.vitorpamplona.amethyst.commons.resources.upload_without_encryption_warning import com.vitorpamplona.amethyst.commons.resources.upload_without_encryption_warning
@@ -226,7 +226,7 @@ fun EditField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text( Text(
text = stringRes(Res.string.reply_here), text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggesti
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
import com.vitorpamplona.amethyst.commons.resources.concord_banned_notice import com.vitorpamplona.amethyst.commons.resources.concord_banned_notice
import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only
import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_cut import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_cut
@@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.commons.resources.concord_timer_active
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
import com.vitorpamplona.amethyst.commons.resources.concord_typing_two import com.vitorpamplona.amethyst.commons.resources.concord_typing_two
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
@@ -589,7 +589,7 @@ private fun ConcordMessageComposer(
shape = EditFieldBorder, shape = EditFieldBorder,
placeholder = { placeholder = {
Text( Text(
text = stringRes(Res.string.reply_here), text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
@@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggesti
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.buzz_editing_banner import com.vitorpamplona.amethyst.commons.resources.buzz_editing_banner
import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
@@ -199,7 +199,7 @@ fun EditFieldRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text( Text(
text = stringRes(Res.string.reply_here), text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.resources.chat_composer_hint
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
@@ -140,7 +140,7 @@ fun NestEditFieldRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text( Text(
text = stringRes(Res.string.reply_here), text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
@@ -420,11 +420,13 @@ val ColorScheme.subtleBorder: Color
val ColorScheme.chatBackground: Color val ColorScheme.chatBackground: Color
get() = if (isLight) LightChatBackground else DarkChatBackground get() = if (isLight) LightChatBackground else DarkChatBackground
// Accent-following bubble fill for the logged-in user's own chat messages. Stronger // Accent-following bubble fill for the logged-in user's own chat messages. A tint, not a
// than mediumImportanceLink so "mine" vs "theirs" vs background read at a glance // fill: enough accent that "mine" vs "theirs" reads at a glance, low enough that a screen
// while the default onBackground text stays readable on top of it. // of my own messages is a deep muted tone carrying white text, not a wall of purple (at
// 45% the bubbles were the loudest thing on the screen and the gray captions on them
// were hard to read).
val ColorScheme.chatBubbleMe: Color val ColorScheme.chatBubbleMe: Color
get() = primary.copy(alpha = if (isLight) 0.36f else 0.45f) get() = primary.copy(alpha = if (isLight) 0.20f else 0.24f)
val ColorScheme.chatBubbleThem: Color val ColorScheme.chatBubbleThem: Color
get() = if (isLight) LightChatBubbleThem else DarkChatBubbleThem get() = if (isLight) LightChatBubbleThem else DarkChatBubbleThem
@@ -245,7 +245,9 @@ val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(15.dp)
val ShowMoreRelaysButtonIconModifier = Modifier.size(20.dp) val ShowMoreRelaysButtonIconModifier = Modifier.size(20.dp)
val ShowMoreRelaysButtonBoxModifer = Modifier.width(55.dp).height(17.dp) val ShowMoreRelaysButtonBoxModifer = Modifier.width(55.dp).height(17.dp)
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f) // Caps a bubble at ~3/4 of the row: past that, a long message turns into a wide slab
// that reads like a document instead of a line of conversation.
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.78f)
val ModifierWidth3dp = Modifier.width(3.dp) val ModifierWidth3dp = Modifier.width(3.dp)
@@ -272,14 +274,17 @@ val FeedPadding = PaddingValues(top = 10.dp, bottom = 10.dp)
val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
val ChatPaddingInnerQuoteModifier = Modifier val ChatPaddingInnerQuoteModifier = Modifier
// Two gaps only, so the column has a rhythm: ~2dp between bubbles of one author run and
// ~10dp where a new run starts. The gap lives on top of the bubble that opens a run.
val ChatPaddingModifier = val ChatPaddingModifier =
Modifier Modifier
.fillMaxWidth(1f) .fillMaxWidth(1f)
.padding( .padding(
start = 12.dp, start = 12.dp,
end = 12.dp, end = 12.dp,
top = 3.dp, top = 9.dp,
bottom = 3.dp, bottom = 1.dp,
) )
// Tighter top gap for a message visually connected to the previous message of the // Tighter top gap for a message visually connected to the previous message of the
@@ -291,7 +296,7 @@ val ChatPaddingGroupedModifier =
start = 12.dp, start = 12.dp,
end = 12.dp, end = 12.dp,
top = 1.dp, top = 1.dp,
bottom = 3.dp, bottom = 1.dp,
) )
val profileContentHeaderModifier = val profileContentHeaderModifier =
@@ -0,0 +1,64 @@
/*
* 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.commons.chats.ui
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class SubjectChangeTest {
private val key = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
private fun message(subject: String?) =
Event(
id = key,
pubKey = key,
createdAt = 1L,
kind = 14,
tags = if (subject != null) arrayOf(arrayOf("subject", subject)) else emptyArray(),
content = "hi",
sig = key,
)
@Test
fun noSubjectIsNoRename() {
assertNull(subjectChangeOf(message(null), message(null)))
}
@Test
fun aNewSubjectIsARename() {
assertEquals("Product", subjectChangeOf(message(null), message("Product")))
assertEquals("Product", subjectChangeOf(message("Old"), message("Product")))
}
@Test
fun aRepeatedSubjectIsNotARename() {
// Some clients tag every message of a named group with its subject.
assertNull(subjectChangeOf(message("Product"), message("Product")))
assertNull(subjectChangeOf(message("Product"), message(" Product ")))
}
@Test
fun aBlankSubjectIsNoRename() {
assertNull(subjectChangeOf(message("Product"), message(" ")))
}
}
@@ -0,0 +1,258 @@
/*
* 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.commons.ui.screen.loggedIn.chats.feed
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
import com.vitorpamplona.amethyst.commons.chats.ui.ChatSystemCaption
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import org.jetbrains.skia.EncodedImageFormat
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Renders a group chat offscreen (no device, no display) through `ImageComposeScene`, in the dark and
* light themes side by side, and writes it to `commonsUI/build/chat-screen/chat.png`.
*
* Bubbles, dividers, the rename caption, reaction chips and the send button are the real shared
* composables. The header row, the bubble footer and the faces are stand-ins with the same geometry
* and styles, since the real ones need a Note, an account and the network. It exists so the chat's
* visual weight can be looked at while it is tuned, and it fails if the screen draws nothing.
*/
class ChatScreenRenderTest {
private val outDir = File("build/chat-screen").apply { mkdirs() }
@Test
fun chat() {
val density = 2f
val widthDp = 840
val heightDp = 1040
val scene =
ImageComposeScene(
width = (widthDp * density).toInt(),
height = (heightDp * density).toInt(),
density = Density(density),
) { ThemeComparisonRow { SampleGroupChat() } }
try {
var image = scene.render(0)
repeat(SETTLE_FRAMES) { frame ->
Thread.sleep(FRAME_MILLIS)
image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L)
}
val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode")
File(outDir, "chat.png").writeBytes(png.bytes)
val pixels = image.peekPixels() ?: error("no pixels")
val distinct = HashSet<Int>()
for (y in 0 until image.height step 7) for (x in 0 until image.width step 7) distinct += pixels.getColor(x, y)
assertTrue(distinct.size > 20, "the chat rendered almost nothing (${distinct.size} colours)")
} finally {
scene.close()
}
}
private companion object {
const val SETTLE_FRAMES = 12
const val FRAME_MILLIS = 60L
}
}
private const val OUTPOST = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
private const val DAVID = "3f9e2d1c0b0a99887766554433221100ffeeddccbbaa99887766554433221100"
@Composable
private fun Face(
color: Color,
size: Int = 28,
) {
Box(Modifier.size(size.dp).background(color, CircleShape))
}
/** Same geometry as `ChatRoomFaces` (two overlapping faces with a background ring). */
@Composable
private fun HeaderFaces() {
val face = 34.dp * 0.68f
Box(Modifier.size(34.dp)) {
Box(Modifier.align(Alignment.TopStart).size(face).background(Color(0xFF8C6E5A), CircleShape))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.align(Alignment.BottomEnd).size(face + 4.dp).background(MaterialTheme.colorScheme.background, CircleShape),
) {
Box(Modifier.size(face).background(Color(0xFF4F86C6), CircleShape))
}
}
}
@Composable
private fun Header() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText)
Spacer(Modifier.width(12.dp))
HeaderFaces()
Column(Modifier.padding(start = 10.dp)) {
Text("NosFabrica Product", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium)
Text("5 members", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
/** Same type and color as `ChatTimeAgo` + the delivered tick of `ChatTimeWithDelivery`. */
@Composable
private fun Footer(
time: String,
mine: Boolean,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(time, color = MaterialTheme.colorScheme.placeholderText, fontSize = Font12SP)
if (mine) {
Spacer(Modifier.width(4.dp))
Icon(MaterialSymbols.DoneAll, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(14.dp))
}
}
}
@Composable
private fun Bubble(
text: String,
mine: Boolean,
position: ChatGroupPosition = ChatGroupPosition.SINGLE,
time: String? = null,
author: Pair<String, String>? = null,
faceColor: Color = Color.Gray,
fire: Boolean = false,
) {
val isLight = MaterialTheme.colorScheme.isLight
ChatBubbleLayout(
isLoggedInUser = mine,
isDraft = false,
innerQuote = false,
drawAuthorInfo = author != null && position.isFirstOfGroup,
groupPosition = position,
onClick = { false },
onAuthorClick = {},
actionMenu = {},
reactionsRow =
if (fire) {
{ ChatChipFlowRow { ReactionChipView(ReactionChip("🔥", 1, false), {}, {}) } }
} else {
null
},
footerRow = time?.let { { Footer(it, mine) } },
drawAuthorLine = {
author?.let { (hex, name) ->
Text(name, color = authorNameColorFor(hex, isLight), fontWeight = ChatAuthorNameWeight, fontSize = ChatAuthorNameSize)
}
},
authorAvatar = if (author != null) ({ Face(faceColor) }) else null,
) { Text(text) }
}
@Composable
private fun Composer(typed: String?) {
Surface(
shape = EditFieldBorder,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 14.dp, end = 4.dp)) {
Icon(MaterialSymbols.AddPhotoAlternate, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText)
Spacer(Modifier.width(12.dp))
Text(
typed ?: "Message",
color = if (typed != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.weight(1f),
)
ThinSendButton(isActive = typed != null, modifier = Modifier) {}
}
}
}
@Composable
private fun SampleGroupChat() {
Column {
Header()
ChatDivisor("Today")
ChatSystemCaption("You renamed the chat to “NosFabrica Product”")
Bubble("Renamed the chat to “NosFabrica Product”", mine = true, position = ChatGroupPosition.TOP)
Bubble("Ok, this is working way better than I expected", mine = true, position = ChatGroupPosition.BOTTOM, time = "8:38", fire = true)
Bubble("A cool goal of this is to delete messages from unknown users automatically", mine = true, time = "8:49")
Bubble(
"to prevent random users or bots out of network from sliding in DMs?",
mine = false,
time = "8:55",
author = OUTPOST to "Relay Outpost",
faceColor = Color(0xFF5A5A5A),
)
Bubble("Delete the past scammers", mine = true, time = "8:59", fire = true)
Bubble(
"Big fat red alert next to the accounts that need it.",
mine = false,
position = ChatGroupPosition.TOP,
author = DAVID to "david",
faceColor = Color(0xFFD9825B),
)
Bubble(
"Unlike every other nostr app that give you no useful warnings at all.",
mine = false,
position = ChatGroupPosition.BOTTOM,
time = "9:08",
author = DAVID to "david",
faceColor = Color(0xFFD9825B),
)
Bubble("But also, send a message to the relays that store DMs to delete those Giftwraps.", mine = true, time = "9:09")
Spacer(Modifier.size(8.dp))
Composer(typed = null)
Composer(typed = "Sounds good")
}
}