diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt
index f492bdb7bf..5372631d2e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
@@ -42,7 +41,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
-import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -69,10 +67,9 @@ fun ChannelNamePreview() {
},
secondRow = {
Text(PREVIEW_MESSAGE, Modifier.weight(1f))
- Spacer(modifier = Height4dpModifier)
- NewItemsBubble()
},
onClick = {},
+ hasNewMessages = true,
)
HorizontalDivider(thickness = DividerThickness)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt
index 740aa35c9d..d54137712c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt
@@ -51,7 +51,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.ChatDivisor
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.jumboEmojiFontSize
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.ToggleableTimeAgoText
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.MoreActionsToggle
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ReactionChip
@@ -256,6 +257,13 @@ internal fun CordnMessageRow(
null
},
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 ->
CordnBubbleContents(
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
- * cordn sender is drawn exactly like a DM sender, colour included. [authorNameColorFor]
- * derives a stable hue from the pubkey, which is what makes authors scannable in a
- * fast-moving room.
+ * Name on the first bubble of a burst, styled like a DM sender's, colour included — the
+ * face sits beside the burst ([ChatAuthorAvatar]). [authorNameColorFor] derives a stable
+ * hue from the pubkey, which is what makes authors scannable in a fast-moving room.
*
* [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
@@ -375,24 +382,13 @@ private fun CordnAuthorLine(
val isLightTheme = MaterialTheme.colorScheme.isLight
val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) }
- UserDisplayNameLayout(
- picture = {
- UserPicture(
- userHex = pubKey,
- size = Size20dp,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- },
- name = {
- Text(
- text = name,
- color = nameColor,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
+ Text(
+ text = name,
+ color = nameColor,
+ fontWeight = ChatAuthorNameWeight,
+ fontSize = ChatAuthorNameSize,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt
index aa9c4ebe6d..fc3d319ebb 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt
@@ -27,7 +27,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.SecondaryTabRow
+import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -81,7 +81,10 @@ fun MessagesTabHeader(
val coroutineScope = rememberCoroutineScope()
Box(Modifier.fillMaxWidth()) {
- SecondaryTabRow(
+ // A primary row draws its indicator under the label only, not across the whole half of the
+ // screen, and the unselected tab steps back to the placeholder gray so the selection reads
+ // from the text itself instead of from a wide accent bar.
+ PrimaryTabRow(
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
@@ -91,6 +94,8 @@ fun MessagesTabHeader(
Tab(
selected = pagerState.currentPage == index,
text = { Text(text = stringRes(tab.resource)) },
+ selectedContentColor = MaterialTheme.colorScheme.onBackground,
+ unselectedContentColor = MaterialTheme.colorScheme.placeholderText,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
)
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewText.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewText.kt
new file mode 100644
index 0000000000..7d96b48b61
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewText.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.model.privateChats
+
+import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
+import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
+import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
+import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
+
+/**
+ * The words a one-line preview puts in place of things that only make sense rendered: a photo, a
+ * video, a quoted note. Resolved from string resources by the UI and handed in, so the cleanup itself
+ * stays a pure function.
+ */
+data class ChatPreviewLabels(
+ val photo: String,
+ val video: String,
+ val note: String,
+)
+
+/**
+ * Turns a raw message body into what a Messages-list preview line should say.
+ *
+ * The bubble renders a message; the preview line only has one line of plain text, so anything that
+ * relies on rendering comes out as machine text there — `nostr:nevent1qqs…`, a 90-char blob URL,
+ * markdown backticks. Those strings are the harshest thing on a list screen, so the preview swaps
+ * each for what a reader would call it:
+ *
+ * - `nostr:npub…` / `nprofile…` → `@Name` (via [nameOf]; a short npub when the name is unknown)
+ * - `nostr:nevent…` / `note…` / `naddr…` / `nembed…` → [ChatPreviewLabels.note]
+ * - an image URL → [ChatPreviewLabels.photo], a video URL → [ChatPreviewLabels.video],
+ * any other URL → its host
+ * - markdown `[label](url)` → `label`; emphasis markers, backticks, heading and quote markers dropped
+ * - line breaks and runs of spaces → one space, so the line shows as much of the message as it can
+ */
+object ChatPreviewText {
+ private val whitespace = Regex("\\s+")
+ private val markdownLink = Regex("\\[([^\\]\\n]+)]\\((https?://[^)\\s]+)\\)")
+ private val url = Regex("https?://[^\\s<>\"]+", RegexOption.IGNORE_CASE)
+ private val lineMarkers = Regex("(?m)^[ \\t]*(#{1,6}[ \\t]+|>[ \\t]?)")
+ private val emphasis = Regex("\\*\\*|__|~~|`")
+ private val trailingPunctuation = charArrayOf('.', ',', ')', '!', '?', ';', ':', '"', '\'')
+ private val nip19Prefixes = arrayOf("npub1", "nprofile1", "nevent1", "note1", "naddr1", "nembed1")
+
+ fun tidy(
+ text: String,
+ labels: ChatPreviewLabels,
+ nameOf: (HexKey) -> String?,
+ ): String {
+ if (text.isEmpty()) return text
+
+ var out = text
+ if (out.contains("](")) out = markdownLink.replace(out) { it.groupValues[1] }
+ if (out.contains('#') || out.contains('>')) out = lineMarkers.replace(out, "")
+ if (nip19Prefixes.any { out.contains(it, ignoreCase = true) }) out = replaceNip19(out, labels, nameOf)
+ if (out.contains("://")) out = replaceUrls(out, labels)
+ out = emphasis.replace(out, "")
+
+ return whitespace.replace(out, " ").trim()
+ }
+
+ private fun replaceNip19(
+ text: String,
+ labels: ChatPreviewLabels,
+ nameOf: (HexKey) -> String?,
+ ): String =
+ Nip19Parser.nip19regex.replace(text) { match ->
+ val type = match.groups[3]?.value ?: match.groups[5]?.value
+ val key = match.groups[4]?.value ?: match.groups[6]?.value
+ val trailing = match.groups[7]?.value ?: ""
+
+ when (val entity = Nip19Parser.parseComponents(type ?: "", key, null)?.entity) {
+ is NPub -> "@${nameOf(entity.hex) ?: shortBech32(type + key)}$trailing"
+ is NProfile -> "@${nameOf(entity.hex) ?: shortBech32(type + key)}$trailing"
+ is NEvent, is NNote, is NAddress, is NEmbed -> "${labels.note}$trailing"
+ else -> match.value
+ }
+ }
+
+ private fun replaceUrls(
+ text: String,
+ labels: ChatPreviewLabels,
+ ): String =
+ url.replace(text) { match ->
+ val raw = match.value
+ val link = raw.trimEnd(*trailingPunctuation)
+ val trailing = raw.substring(link.length)
+ val replacement =
+ when {
+ RichTextParser.isImageUrl(link) -> labels.photo
+ RichTextParser.isVideoUrl(link) -> labels.video
+ else -> hostOf(link) ?: link
+ }
+ replacement + trailing
+ }
+
+ private fun hostOf(link: String): String? {
+ val afterScheme = link.substringAfter("://", "")
+ val host =
+ afterScheme
+ .substringBefore('/')
+ .substringBefore('?')
+ .substringBefore('#')
+ .substringAfter('@')
+ return host.removePrefix("www.").ifBlank { null }
+ }
+
+ private fun shortBech32(bech32: String): String = if (bech32.length > 16) bech32.take(10) + "…" + bech32.takeLast(4) else bech32
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTextTest.kt
new file mode 100644
index 0000000000..6e20357af2
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTextTest.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.model.privateChats
+
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
+import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ChatPreviewTextTest {
+ private val labels = ChatPreviewLabels(photo = "Photo", video = "Video", note = "Note")
+ private val hex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
+ private val names = mapOf(hex to "Vitor")
+
+ private fun tidy(text: String) = ChatPreviewText.tidy(text, labels) { names[it] }
+
+ @Test
+ fun plainTextIsUntouched() {
+ assertEquals("hello there", tidy("hello there"))
+ }
+
+ @Test
+ fun collapsesLineBreaksIntoOneLine() {
+ assertEquals("first line second line", tidy("first line\n\n second line"))
+ }
+
+ @Test
+ fun eventReferenceBecomesTheNoteLabel() {
+ val nevent = NEvent.create(hex, hex, 1, relay = null)
+ assertEquals("Note", tidy("nostr:$nevent"))
+ assertEquals("look: Note", tidy("look: nostr:${NNote.create(hex)}"))
+ }
+
+ @Test
+ fun userMentionBecomesTheName() {
+ assertEquals("hi @Vitor!", tidy("hi nostr:${NPub.create(hex)}!"))
+ }
+
+ @Test
+ fun unknownUserMentionIsShortened() {
+ val other = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
+ val npub = NPub.create(other)
+ assertEquals("@${npub.take(10)}…${npub.takeLast(4)}", tidy("nostr:$npub"))
+ }
+
+ @Test
+ fun imageAndVideoLinksBecomeLabels() {
+ assertEquals("Photo", tidy("https://nostr.download/ee078c63eab4f77eb1234.jpg"))
+ assertEquals("watch Video.", tidy("watch https://example.com/clip.mp4."))
+ }
+
+ @Test
+ fun otherLinksBecomeTheirHost() {
+ assertEquals("see github.com", tidy("see https://www.github.com/vitorpamplona/amethyst/pull/1"))
+ assertEquals("nostr.download", tidy("https://nostr.download/ee078c63eab4f77eb1234"))
+ }
+
+ @Test
+ fun markdownIsFlattened() {
+ assertEquals("[github block/buzz@main] a3870 fix the build", tidy("[github block/buzz@main] `a3870` **fix** the build"))
+ assertEquals("the docs here", tidy("the [docs](https://example.com/docs) here"))
+ assertEquals("Title quoted", tidy("## Title\n> quoted"))
+ }
+}
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index d13da0213e..fb8465f84e 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -407,6 +407,12 @@
Could not decrypt the message
Decrypting…
You: %1$s
+ %1$s renamed the chat to “%2$s”
+ You renamed the chat to “%1$s”
+ Message
+ Photo
+ Video
+ Shared a note
Group Picture
Explicit Content
Relay Notice
@@ -4318,6 +4324,10 @@
Group disbanded
Ending the group. It finishes once the group agrees.
Group info updated
+
+ - %1$d member
+ - %1$d members
+
- %1$d member
- %1$d members
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt
index c04a634407..a9f7dc13c3 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt
@@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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
@@ -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.
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
// drags further than the max.
private val SwipeReplyThreshold = 56.dp
@@ -133,6 +138,10 @@ fun ChatBubbleLayout(
// status glyphs). Null skips it entirely.
footerRow: (@Composable () -> Unit)? = null,
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) -> Unit,
) {
val loggedInColors = MaterialTheme.colorScheme.chatBubbleMe
@@ -306,7 +315,14 @@ fun ChatBubbleLayout(
Row(
modifier = (if (innerQuote) Modifier else Modifier.fillMaxWidth()).then(swipeModifier),
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(
isLoggedInUser = isLoggedInUser,
innerQuote = innerQuote,
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt
index a9988fb4cd..e765f20332 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.commons.chats.ui
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.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.isSpecified
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.Font14SP
+import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding
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
fun ChatDivisor(
info: String,
@@ -53,9 +64,9 @@ fun ChatDivisor(
)
Text(
text = info,
- fontWeight = FontWeight.Bold,
- fontSize = Font14SP,
- color = color,
+ fontWeight = FontWeight.Medium,
+ fontSize = Font12SP,
+ color = if (color.isSpecified) color else MaterialTheme.colorScheme.placeholderText,
modifier = HalfPadding,
)
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)
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewDateOrSubjectDivisor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewDateOrSubjectDivisor.kt
index 779bb759ee..84a08cfb01 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewDateOrSubjectDivisor.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewDateOrSubjectDivisor.kt
@@ -21,19 +21,33 @@
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.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.today
import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter
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
+/**
+ * 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
fun NewDateOrSubjectDivisor(
previous: Note?,
note: Note,
+ accountViewModel: AccountViewModel,
) {
if (previous == null) return
@@ -43,17 +57,35 @@ fun NewDateOrSubjectDivisor(
val prevDate = remember(previous) { dateFormatter(previous.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 (subject != null) {
- ChatDivisor("$date - $subject")
- } else {
- ChatDivisor(date)
- }
- } else {
- if (subject != null) {
- ChatDivisor(subject)
- }
+ ChatDivisor(date)
+ }
+
+ val newSubject = remember(previous, note) { subjectChangeOf(previous.event, note.event) }
+ if (newSubject != null) {
+ val author = note.author
+ val text =
+ 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
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ThinSendButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ThinSendButton.kt
index f75cefa3ba..a3259be2c5 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ThinSendButton.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ThinSendButton.kt
@@ -20,16 +20,29 @@
*/
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.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
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.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.accessibility_send
import com.vitorpamplona.amethyst.commons.ui.stringRes
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
fun ThinSendButton(
isActive: Boolean,
@@ -41,10 +54,28 @@ fun ThinSendButton(
// modifier = modifier,
onClick = onClick,
) {
- Icon(
- symbol = MaterialSymbols.AutoMirrored.Send,
- contentDescription = stringRes(id = Res.string.accessibility_send),
- modifier = Size25Modifier,
- )
+ if (isActive) {
+ Box(
+ contentAlignment = Alignment.Center,
+ 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)
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/concord/ui/ConcordCommunityPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/concord/ui/ConcordCommunityPill.kt
index 2925594198..0909455b08 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/concord/ui/ConcordCommunityPill.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/concord/ui/ConcordCommunityPill.kt
@@ -42,8 +42,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
/**
* A tappable chip naming the Concord community a message belongs to. Wears the same highlighted wash as
* the NIP-29 relay-host chip ([secondaryContainer] — a gray on the dark theme) so every "which server /
- * community does this room belong to" chip reads the same across the Messages screen. Shared by the
- * Messages row and the Notifications feed so a Concord message reads the same wherever it surfaces; the
+ * community does this room belong to" chip reads the same in the Notifications feed. (The Messages
+ * list names the community with the quieter `ChatRowLabel` instead, since it repeats on every row.) The
* width is capped at [ChatLabelMaxWidth] with a middle ellipsis so a long community name is truncated
* instead of crowding the room name out.
*/
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt
index 3769beeb3a..2f07f6b7c6 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -43,6 +44,8 @@ fun FavoriteAppIcon(
tint: Color,
modifier: Modifier = Modifier,
iconModel: Any? = null,
+ // Applied to the drawn icon (image or its placeholder glyph), e.g. to desaturate it.
+ colorFilter: ColorFilter? = null,
) {
val symbol = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public
val model = iconModel ?: app.iconUrl?.takeIf { it.isNotBlank() }
@@ -58,6 +61,7 @@ fun FavoriteAppIcon(
placeholder = glyph,
error = glyph,
fallback = glyph,
+ colorFilter = colorFilter,
)
}
}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayNameChip.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayNameChip.kt
index a8e5277a23..623d44a33e 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayNameChip.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayNameChip.kt
@@ -48,8 +48,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
* so every "which server / community does this room belong to" chip reads the same way. Unlike the
* muted note-header [com.vitorpamplona.amethyst.commons.ui.note.HeaderPill] (PoW/OTS/location
* markers), this one is a first-class navigation entry point, so it keeps the stronger
- * `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Buzz
- * message reads the same wherever it surfaces; the width is capped at [ChatLabelMaxWidth] with a
+ * `secondaryContainer` highlight. Used by the Notifications feed; the Messages list names the relay
+ * with the quieter `ChatRowLabel` instead, since there it repeats on every row. The width is capped at [ChatLabelMaxWidth] with a
* middle ellipsis so a long host is truncated instead of crowding the channel name out.
*/
@Composable
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt
index 76fc5b6425..51fc1f205e 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt
@@ -29,14 +29,39 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
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.commons.ui.theme.DoubleHorzSpacer
-import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
-import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
+import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowAvatarModifier
+import com.vitorpamplona.amethyst.commons.ui.theme.grayText
+private val ChatRowPadding = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
+private val ChatRowAvatarGap = Modifier.width(12.dp)
+private val ChatRowLineGap = Modifier.padding(top = 3.dp)
+private val ChatRowDotGap = Modifier.width(8.dp)
+
+/**
+ * Title weight of a Messages-list row. Read rows sit at Medium so the list is calm; only a row with
+ * something new goes Bold, which makes bold *mean* unread instead of being on every line.
+ */
+fun chatRowTitleWeight(hasNewMessages: Boolean): FontWeight = if (hasNewMessages) FontWeight.Bold else FontWeight.Medium
+
+/**
+ * One Messages-list row: picture, then a title line over a preview line.
+ *
+ * The row owns its unread emphasis so every room kind agrees on it: [hasNewMessages] turns the title
+ * Bold (titles inherit it from [LocalTextStyle] — don't hardcode a weight in [firstRow]), lifts the
+ * preview from gray to full-contrast (preview text inherits [LocalContentColor] — don't hardcode a
+ * color in [secondRow]), and draws the unread dot at the end of the preview line.
+ */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatHeaderLayout(
@@ -45,33 +70,46 @@ fun ChatHeaderLayout(
secondRow: @Composable RowScope.() -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
+ hasNewMessages: Boolean = false,
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
- .padding(10.dp),
+ .then(ChatRowPadding),
verticalAlignment = Alignment.CenterVertically,
) {
- Box(Size55Modifier) { channelPicture() }
+ Box(ChatRowAvatarModifier) { channelPicture() }
- Spacer(modifier = DoubleHorzSpacer)
+ Spacer(modifier = ChatRowAvatarGap)
Column(
modifier = Modifier.fillMaxWidth(),
) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
+ CompositionLocalProvider(
+ LocalTextStyle provides LocalTextStyle.current.copy(fontWeight = chatRowTitleWeight(hasNewMessages)),
) {
- firstRow()
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ firstRow()
+ }
}
- Spacer(modifier = Height4dpModifier)
-
- Row(
- verticalAlignment = Alignment.CenterVertically,
+ CompositionLocalProvider(
+ LocalContentColor provides if (hasNewMessages) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText,
) {
- secondRow()
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = ChatRowLineGap,
+ ) {
+ secondRow()
+
+ if (hasNewMessages) {
+ Spacer(modifier = ChatRowDotGap)
+ NewItemsBubble()
+ }
+ }
}
}
}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/AppBottomBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/AppBottomBar.kt
index e60a9b8150..e7fd0558aa 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/AppBottomBar.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/AppBottomBar.kt
@@ -34,6 +34,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -41,6 +42,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
@@ -145,6 +148,8 @@ internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? =
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
}
+private val GrayscaleFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
+
/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */
@Composable
internal fun FavoriteEntryIcon(
@@ -158,6 +163,9 @@ internal fun FavoriteEntryIcon(
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
modifier = Size25Modifier,
iconModel = iconModel,
+ // A favicon is a full-color brand mark sitting among monochrome glyphs; unselected it
+ // goes grayscale like its neighbours and gets its color back once it is the open tab.
+ colorFilter = if (selected) null else GrayscaleFilter,
)
}
}
@@ -192,6 +200,10 @@ private fun RenderBottomMenu(
) {
// Render in the user's saved order — built-ins, favorites and pinned groups interleaved.
// Each entry resolves to a shared BottomBarSlot (route + icon), the same one the rail uses.
+ // The selected pill is a neutral wash rather than the accent container: the selected
+ // icon is already accent-tinted, and an accent pill behind it made the bar one of the
+ // loudest things on every screen.
+ val itemColors = NavigationBarItemDefaults.colors(indicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
items.forEach { entry ->
val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach
val selected = slot.route == selectedRoute
@@ -200,6 +212,7 @@ private fun RenderBottomMenu(
icon = { slot.icon(selected) },
selected = selected,
onClick = { nav(slot.route) },
+ colors = itemColors,
)
}
}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserProfilePicture.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserProfilePicture.kt
index ce4b21c4af..b46073c08d 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserProfilePicture.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserProfilePicture.kt
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -221,6 +223,50 @@ fun ClickableUserPicture(
BaseUserPicture(baseUserHex, size, accountViewModel, modifier, myModifier)
}
+/**
+ * The faces of a chat room (Messages-list rows, chat headers). A 1:1 room shows its counterpart;
+ * a group shows at most two faces, overlapped, instead of a 2x2 mosaic. Every face keeps the
+ * usual follow mark and NIP-85 trust score, as [BaseUserPicture] draws them everywhere else.
+ */
+@Composable
+fun ChatRoomFaces(
+ userHexList: List,
+ size: Dp,
+ accountViewModel: AccountViewModel,
+) {
+ when (userHexList.size) {
+ 0 -> {}
+
+ 1 -> {
+ LoadUser(baseUserHex = userHexList.first()) {
+ it?.let { BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier.size(size)) }
+ }
+ }
+
+ else -> {
+ val face = size * 0.68f
+ val ring = 2.dp
+ Box(Modifier.size(size)) {
+ Box(Modifier.align(Alignment.TopStart)) {
+ BaseUserPicture(userHexList[0], face, accountViewModel)
+ }
+ // The front face sits in a disc of the background color, which cuts a clean ring
+ // out of the face behind it instead of letting the two pictures smear together.
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier =
+ Modifier
+ .align(Alignment.BottomEnd)
+ .size(face + ring * 2)
+ .background(MaterialTheme.colorScheme.background, CircleShape),
+ ) {
+ BaseUserPicture(userHexList[1], face, accountViewModel)
+ }
+ }
+ }
+ }
+}
+
@Composable
fun NonClickableUserPictures(
room: ChatroomKey,
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt
index 17fc78210c..1d92680b1b 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt
@@ -358,7 +358,6 @@ private fun RenderDeliveryTicks(
onRetry: () -> Unit,
) {
val pendingColor = MaterialTheme.colorScheme.placeholderText
- val deliveredColor = MaterialTheme.colorScheme.allGoodColor
// 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
@@ -398,19 +397,23 @@ private fun RenderDeliveryTicks(
// the sender's own self-copy wrap only shows in the detail dialog.
val others = delivery.otherRecipients
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 }
Row(verticalAlignment = Alignment.CenterVertically) {
DeliveryLadderTick(
pending = deliveredCount == 0 && !seenSomewhere,
fullyAccepted = delivery.isFullyAccepted,
)
- Text(
- text = "$deliveredCount/${others.size}",
- fontSize = Font12SP,
- color = if (delivery.isFullyAccepted) deliveredColor else pendingColor,
- maxLines = 1,
- )
+ if (!delivery.isFullyAccepted) {
+ Text(
+ text = "$deliveredCount/${others.size}",
+ fontSize = Font12SP,
+ color = pendingColor,
+ maxLines = 1,
+ )
+ }
}
return
}
@@ -433,8 +436,11 @@ private fun DeliveryLadderTick(
pending ->
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 ->
- 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 ->
TickIcon(MaterialSymbols.Done, Res.string.chat_delivery_accepted, MaterialTheme.colorScheme.placeholderText)
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt
index eb0b18e505..0f6a1eff31 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt
@@ -283,7 +283,7 @@ fun ChatFeedLoaded(
// 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,
// 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
// ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble —
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
index fedecb173d..a8c5e7489d 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
@@ -391,6 +391,18 @@ fun NormalChatNote(
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 ->
MessageBubbleLines(
note,
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt
index fc965758f5..d75d683130 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt
@@ -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.bitcoinColor
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.quartz.nip30CustomEmoji.CustomEmoji
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
* every chat surface lays its chips out identically — [ChatReactionChips] fills it from
@@ -276,7 +284,7 @@ private fun MinichatChip(
Surface(
shape = ButtonBorder,
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
// content height instead of being padded to the 48dp minimum touch target,
// which would drop it below the bubble border the reaction chips ride.
@@ -316,17 +324,10 @@ fun ReactionChipView(
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(
shape = ButtonBorder,
color = background,
- border = border,
+ border = chatChipRing(),
modifier =
Modifier
.clip(ButtonBorder)
@@ -355,7 +356,7 @@ private fun ZapChip(
Surface(
shape = ButtonBorder,
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
// content height instead of being padded to the 48dp minimum touch target,
// which would drop it below the bubble border the reaction chips ride.
@@ -385,7 +386,7 @@ private fun PendingZapChip() {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder),
+ border = chatChipRing(),
) {
ChipContentRow {
ZappedIcon(Size14Modifier)
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt
index 63d5606454..d0d1b322b7 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt
@@ -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.note.elements.DisplayLocation
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.elements.ToggleableTimeAgoText
+import com.vitorpamplona.amethyst.commons.ui.note.timeAbsoluteNoDot
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
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.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
fun ChatTimeAgo(baseNote: Note) {
- ToggleableTimeAgoText(
- timestamp = baseNote.createdAt() ?: 0L,
- style = TimeAgoStyle.Short,
+ Text(
+ text = timeAbsoluteNoDot(baseNote.createdAt() ?: 0L),
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP,
- // The chat time is wrapped in a tap target that opens the relay/delivery dialog,
- // so it must not steal the tap to toggle relative/absolute. The absolute time is
- // shown in that dialog instead.
- toggleable = false,
+ maxLines = 1,
)
}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt
index 3f006671d0..4770750fa2 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt
@@ -20,8 +20,10 @@
*/
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.Row
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -31,9 +33,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.rememberBuzzContextualName
-import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
@@ -42,17 +44,18 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
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.ObserveAndRenderUserCards
-import com.vitorpamplona.amethyst.commons.ui.note.WatchUserFollows
+import com.vitorpamplona.amethyst.commons.ui.note.BaseUserPicture
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.viewmodels.AccountViewModel
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
fun DrawAuthorInfo(
baseNote: Note,
@@ -77,7 +80,9 @@ 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.
+ * 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(
pubkeyHex: String,
@@ -85,12 +90,38 @@ fun authorNameColorFor(
): 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)
+ Color.hsl(hue, saturation = 0.45f, lightness = 0.38f)
} 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, with the follow mark
+ * and NIP-85 trust score [BaseUserPicture] draws on every profile picture. Tapping it opens the
+ * profile.
+ */
+@Composable
+fun ChatAuthorAvatar(
+ userHex: HexKey,
+ accountViewModel: AccountViewModel,
+ onClick: () -> Unit,
+) {
+ BaseUserPicture(
+ baseUserHex = userHex,
+ size = ChatAuthorAvatarSize,
+ accountViewModel = accountViewModel,
+ outerModifier = Modifier.size(ChatAuthorAvatarSize).clickable(onClick = onClick),
+ )
+}
+
@Composable
private fun WatchAndDisplayUser(
author: User,
@@ -116,41 +147,21 @@ private fun WatchAndDisplayUser(
authorNameColorFor(author.pubkeyHex, isLightTheme)
}
- UserDisplayNameLayout(
- picture = {
- InnerUserPicture(
- userHex = author.pubkeyHex,
- userPicture = userState?.info?.picture,
- userName = displayName,
- size = Size20dp,
- modifier = Modifier,
- )
-
- WatchUserFollows(author.pubkeyHex, accountViewModel) { newFollowingState ->
- if (newFollowingState) {
- FollowingIcon(Size5Modifier)
- }
- }
-
- 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)
- }
- },
- )
+ 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 = ChatAuthorNameWeight,
+ fontSize = ChatAuthorNameSize,
+ 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)
+ }
}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt
index c7793cca4c..fa8e1f9ed6 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.commons.resources.Res
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_group_composer_disbanding
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_title
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.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
@@ -286,7 +286,7 @@ fun MarmotGroupMessageComposer(
shape = EditFieldBorder,
placeholder = {
Text(
- text = stringRes(Res.string.reply_here),
+ text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt
index 75c9e9d497..5ee7b825da 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header
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
@@ -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.call_video
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.messages_group_descriptor
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
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.LoadUser
-import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.commons.ui.note.UserCompose
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.stringRes
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.StdPadding
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.quartz.nip17Dm.base.ChatroomKey
import kotlinx.collections.immutable.toPersistentList
@@ -92,7 +96,7 @@ fun RenderRoomTopBar(
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) {
IconButton(
@@ -141,13 +145,26 @@ fun RenderRoomTopBar(
TopBarExtensibleWithBackButton(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
- NonClickableUserPictures(
- room = room,
- accountViewModel = accountViewModel,
+ // Two overlapping faces without badges, as on the Messages list: a 2x2
+ // mosaic with a trust score on each face is unreadable at header size.
+ ChatRoomFaces(
+ userHexList = remember(room) { room.users.toList() },
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) {
IconButton(
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt
index bd270d8320..c3b4be58e1 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt
@@ -114,6 +114,7 @@ fun RoomNameDisplay(
// this function no longer self-resolves, so a caller must decide explicitly rather than
// silently rendering a blank 1:1 name.
preloadedUser: User?,
+ fontWeight: FontWeight = FontWeight.Bold,
) {
val roomSubject by accountViewModel.account.chatroomList
.getOrCreatePrivateChatroom(room)
@@ -123,16 +124,16 @@ fun RoomNameDisplay(
CrossfadeIfEnabled(targetState = roomSubject, modifier = modifier) {
if (!it.isNullOrBlank()) {
if (room.users.size > 1) {
- DisplayRoomSubject(it)
+ DisplayRoomSubject(it, fontWeight)
} else {
- DisplayUserAndSubject(it, accountViewModel, preloadedUser)
+ DisplayUserAndSubject(it, accountViewModel, preloadedUser, fontWeight)
}
} else if (room.users.size == 1) {
Row {
- preloadedUser?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) }
+ preloadedUser?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight, accountViewModel = accountViewModel) }
}
} else {
- DisplayUserSetAsSubject(room, accountViewModel)
+ DisplayUserSetAsSubject(room, accountViewModel, fontWeight)
}
}
}
@@ -156,20 +157,21 @@ private fun DisplayUserAndSubject(
subject: String,
accountViewModel: AccountViewModel,
preloadedUser: User?,
+ fontWeight: FontWeight,
) {
Row {
Text(
text = subject,
- fontWeight = FontWeight.Bold,
+ fontWeight = fontWeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = " - ",
- fontWeight = FontWeight.Bold,
+ fontWeight = fontWeight,
maxLines = 1,
)
- preloadedUser?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) }
+ preloadedUser?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight, accountViewModel = accountViewModel) }
}
}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
index dff74285d6..9e5b29ac37 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
@@ -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.resources.Res
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.reply_here
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.upload_without_encryption_warning
@@ -226,7 +226,7 @@ fun EditField(
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
- text = stringRes(Res.string.reply_here),
+ text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt
index b16e5411ce..6189a39d36 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt
@@ -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.resources.Res
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_dissolved_read_only
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_one
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.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
@@ -589,7 +589,7 @@ private fun ConcordMessageComposer(
shape = EditFieldBorder,
placeholder = {
Text(
- text = stringRes(Res.string.reply_here),
+ text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt
index fb82bae890..0af4ad0dba 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt
@@ -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.buzz_editing_banner
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.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
@@ -199,7 +199,7 @@ fun EditFieldRow(
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
- text = stringRes(Res.string.reply_here),
+ text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatRowParts.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatRowParts.kt
new file mode 100644
index 0000000000..527e33984a
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatRowParts.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.rooms
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowPictureModifier
+import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
+
+private val ChatRowTypeIconModifier = Modifier.size(15.dp)
+private val ChatRowLabelIconModifier = Modifier.size(13.dp)
+
+/**
+ * The room-kind glyph after a Messages-row title (globe for a public chat, lock for a Marmot group,
+ * pin for a geohash cell, ...). Icon only: the glyph is enough to tell kinds apart at a glance, and a
+ * word in a chip on every row competed with the room name for the eye. [contentDescription] carries
+ * the word for screen readers.
+ */
+@Composable
+fun ChatRowTypeIcon(
+ symbol: MaterialSymbol,
+ contentDescription: String?,
+) {
+ Icon(
+ symbol = symbol,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.placeholderText,
+ modifier = ChatRowTypeIconModifier,
+ )
+}
+
+/**
+ * Muted metadata after a Messages-row title when the room needs a *name* to be told apart, not just a
+ * kind: the relay a NIP-29 group lives on, the Concord community a channel belongs to, the cordn
+ * coordinator. No fill — it reads as a caption of the title, not as a second title. Capped at
+ * [ChatLabelMaxWidth] with a middle ellipsis so it never crowds the room name out.
+ */
+@Composable
+fun ChatRowLabel(
+ symbol: MaterialSymbol,
+ text: String,
+ contentDescription: String? = null,
+ onClick: (() -> Unit)? = null,
+) {
+ val color = MaterialTheme.colorScheme.placeholderText
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(3.dp),
+ modifier =
+ Modifier
+ .widthIn(max = ChatLabelMaxWidth)
+ .then(if (onClick != null) Modifier.clip(MaterialTheme.shapes.small).clickable(onClick = onClick) else Modifier),
+ ) {
+ Icon(
+ symbol = symbol,
+ contentDescription = contentDescription,
+ tint = color,
+ modifier = ChatRowLabelIconModifier,
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ color = color,
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis,
+ )
+ }
+}
+
+/**
+ * The picture of a room that has none: a quiet tonal disc with the room-kind glyph. Replaces the
+ * robohash for rooms (not people) — a robot face says nothing about a geohash cell or a relay group,
+ * and its saturated colors made picture-less rooms the loudest rows in the list.
+ */
+@Composable
+fun ChatRowTonalAvatar(symbol: MaterialSymbol) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = ChatRowPictureModifier.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f), CircleShape),
+ ) {
+ Icon(
+ symbol = symbol,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.placeholderText,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt
index 80ba1ce955..37e7aa1293 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt
@@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalTextStyle
@@ -39,7 +39,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -47,7 +46,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.publicChannels.concord.concordCommunityHasUnreadFlow
import com.vitorpamplona.amethyst.commons.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
import com.vitorpamplona.amethyst.commons.chats.rooms.rowHasUnread
-import com.vitorpamplona.amethyst.commons.concord.ui.ConcordCommunityPill
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
@@ -74,9 +72,10 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreview
+import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreviewLabels
+import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreviewText
import com.vitorpamplona.amethyst.commons.model.privateChats.chatPreviewOf
import com.vitorpamplona.amethyst.commons.model.relayGroupChannelLastReadRoute
-import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayNameChip
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderByParentFilterAssemblerSubscription
@@ -91,6 +90,9 @@ import com.vitorpamplona.amethyst.commons.resources.channel_invite_leave
import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you
import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you_by
import com.vitorpamplona.amethyst.commons.resources.chat_preview_decrypting
+import com.vitorpamplona.amethyst.commons.resources.chat_preview_note
+import com.vitorpamplona.amethyst.commons.resources.chat_preview_photo
+import com.vitorpamplona.amethyst.commons.resources.chat_preview_video
import com.vitorpamplona.amethyst.commons.resources.chat_preview_you_prefix
import com.vitorpamplona.amethyst.commons.resources.chat_system_renamed_conversation_to
import com.vitorpamplona.amethyst.commons.resources.chat_system_renamed_conversation_to_you
@@ -123,17 +125,16 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_server_label
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
import com.vitorpamplona.amethyst.commons.resources.unpin_conversation
-import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
+import com.vitorpamplona.amethyst.commons.ui.layouts.chatRowTitleWeight
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.note.BlankNote
-import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
+import com.vitorpamplona.amethyst.commons.ui.note.ChatRoomFaces
import com.vitorpamplona.amethyst.commons.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.commons.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
-import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.commons.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
@@ -153,11 +154,9 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
import com.vitorpamplona.amethyst.commons.ui.stringRes
-import com.vitorpamplona.amethyst.commons.ui.theme.AccountPictureModifier
-import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
-import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowAvatarSize
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowPictureModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size15Modifier
-import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
@@ -419,6 +418,7 @@ private fun ChannelRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Public,
onClick = { nav.nav(routeFor(channel)) },
onLongClick = { menuOpen = true },
)
@@ -479,6 +479,7 @@ private fun ChannelRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Timer,
onClick = { nav.nav(routeFor(channel)) },
)
}
@@ -514,6 +515,7 @@ private fun GeohashRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.LocationOn,
onClick = { nav.nav(Route.GeohashChat(geohashChannel.geohash)) },
)
}
@@ -545,7 +547,7 @@ private fun MarmotGroupRoomCompose(
// An unnamed group without an avatar (White Noise's 1:1 chats) is about its people:
// show them, as a NIP-17 room does, instead of a relay icon.
ChannelName(
- channelPicture = { NonClickableUserPictures(userHexList = otherMembers, size = Size55dp, accountViewModel = accountViewModel) },
+ channelPicture = { ChatRoomFaces(userHexList = otherMembers, size = ChatRowAvatarSize, accountViewModel = accountViewModel) },
channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, MaterialSymbols.Lock, Res.string.marmot_group, modifier) },
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
@@ -579,6 +581,7 @@ private fun MarmotGroupRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Lock,
onClick = { nav.nav(Route.MarmotGroupChat(chatroom.nostrGroupId)) },
)
}
@@ -683,6 +686,7 @@ internal fun CordnGroupRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Groups,
onClick = { nav.nav(Route.CordnGroupChat(chatroom.coordinatorPubKey, chatroom.gid)) },
)
}
@@ -791,20 +795,15 @@ fun RelayGroupRow(
channelIdHex = channel.groupId.id,
channelPicture = channelPicture,
channelTitle = { modifier ->
- Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
- Text(
- text = channel.toBestDisplayName(),
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f, fill = false),
- )
- Spacer(Modifier.width(6.dp))
- RelayNameChip(
- label = channel.groupId.relayUrl.displayUrl(),
- onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
- )
- }
+ // A group id is only unique within its host relay, so the relay is part of the
+ // room's identity and is spelled out; tapping it opens that relay's groups.
+ ChannelTitleWithLabelInfo(
+ channelName = channel.toBestDisplayName(),
+ labelIcon = MaterialSymbols.Dns,
+ labelText = channel.groupId.relayUrl.displayUrl(),
+ modifier = modifier,
+ onLabelClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
+ )
},
channelLastTime = lastTime,
channelLastContent = lastContent,
@@ -823,6 +822,7 @@ fun RelayGroupRow(
nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url))
}
},
+ fallbackSymbol = MaterialSymbols.Groups,
onLongClick = { menuOpen = true },
)
@@ -959,22 +959,20 @@ private fun ConcordRoomCompose(
channelIdHex = channel.channelId.channelId,
channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel),
channelTitle = { modifier ->
- Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
- Text(
- text = channel.toBestDisplayName(),
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f, fill = false),
+ val communityName = channel.communityName
+ if (communityName != null) {
+ // The label names the parent community and, when tapped, opens that community's
+ // channel list — the "chip that opens the Concord Channel" entry point.
+ ChannelTitleWithLabelInfo(
+ channelName = channel.toBestDisplayName(),
+ labelIcon = MaterialSymbols.Group,
+ labelText = communityName,
+ modifier = modifier,
+ onLabelClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
)
- channel.communityName?.let { communityName ->
- Spacer(Modifier.width(6.dp))
- // The chip names the parent community and, when tapped, opens that community's
- // channel list — the "chip that opens the Concord Channel" entry point.
- ConcordCommunityPill(
- communityName = communityName,
- onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
- )
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
+ ChannelTitleText(channel.toBestDisplayName())
}
}
},
@@ -987,6 +985,7 @@ private fun ConcordRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Group,
onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) },
onLongClick = { menuOpen = true },
)
@@ -1044,6 +1043,7 @@ private fun RelayGroupServerRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Dns,
onClick = { nav.nav(Route.RelayGroupServer(relay.url)) },
)
}
@@ -1081,6 +1081,7 @@ private fun ConcordDirectInviteRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Group,
onClick = { nav.nav(Route.Concords) },
)
}
@@ -1134,56 +1135,71 @@ private fun ConcordServerRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
+ fallbackSymbol = MaterialSymbols.Group,
onClick = { nav.nav(Route.ConcordServer(row.communityId)) },
)
}
/**
- * Renders a Messages row title as the channel name followed by a muted [HeaderPill] naming the room
- * type (Public Chat, Marmot Group, ...). The pill mirrors the Concord community chip so every group
- * kind reads the same way across the screen: bold name, then a faint rounded chip with a type icon
- * and short label. The name yields space to the chip so a long title can't crowd it out.
+ * Renders a Messages row title as the channel name followed by the room-kind glyph (globe for a
+ * Public Chat, lock for a Marmot Group, ...). The kind's word is the glyph's content description:
+ * spelled out in a chip on every row it competed with the room name for attention. The title takes
+ * its weight from [ChatHeaderLayout] (bold only when unread), so none is set here.
*/
@Composable
-private fun ChannelTitleWithLabelInfo(
+internal fun ChannelTitleWithLabelInfo(
channelName: String,
labelIcon: MaterialSymbol,
label: StringResource,
modifier: Modifier,
labelContentDescription: String? = null,
-) = ChannelTitleWithLabelInfo(channelName, labelIcon, stringRes(id = label), modifier, labelContentDescription)
+) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
+ ChannelTitleText(channelName)
+ Spacer(TitleLabelGap)
+ ChatRowTypeIcon(labelIcon, labelContentDescription ?: stringRes(id = label))
+ }
+}
+
+private val TitleLabelGap = Modifier.width(6.dp)
+
+@Composable
+private fun RowScope.ChannelTitleText(channelName: String) {
+ Text(
+ text = channelName,
+ style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false),
+ )
+}
/**
- * As above, for a pill whose text is a name rather than a fixed word.
+ * As above, for a label that is a name rather than a fixed word, so it is spelled out as a muted
+ * [ChatRowLabel]: the relay a NIP-29 group lives on, the Concord community a channel belongs to.
*
- * A cordn room's pill carries its coordinator, which is a value and not a
+ * A cordn room's label carries its coordinator, which is a value and not a
* string resource: the coordinator is the one server that carries every message
* in that group, so which one it is tells a reader more than being told twice
* that this is a cordn chat.
*/
@Composable
-private fun ChannelTitleWithLabelInfo(
+internal fun ChannelTitleWithLabelInfo(
channelName: String,
labelIcon: MaterialSymbol,
labelText: String,
modifier: Modifier,
labelContentDescription: String? = null,
+ onLabelClick: (() -> Unit)? = null,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
- Text(
- text = channelName,
- fontWeight = FontWeight.Bold,
- style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f, fill = false),
- )
- Spacer(Modifier.width(6.dp))
- HeaderPill(
+ ChannelTitleText(channelName)
+ Spacer(TitleLabelGap)
+ ChatRowLabel(
symbol = labelIcon,
text = labelText,
- modifier = Modifier.widthIn(max = ChatLabelMaxWidth),
contentDescription = labelContentDescription,
+ onClick = onLabelClick,
)
}
}
@@ -1226,25 +1242,36 @@ private fun UserRoomCompose(
// menu-text slots, confining pin-toggle invalidations to those scopes.
val pinnedRooms = accountViewModel.pinnedChatroomsFlow().collectAsStateWithLifecycle()
+ // A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
+ val newestEvent = lastMessage.event
+ val countsAsRead =
+ !isDraft &&
+ newestEvent != null &&
+ chatMessageMarksRoomAsRead(newestEvent, room, accountViewModel.account.signer.pubKey)
+
+ val lastReadTime by accountViewModel.account.loadLastReadFlow(privateChatLastReadRoute(room)).collectAsStateWithLifecycle()
+ val hasNewMessages = !countsAsRead && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime
+
ChatHeaderLayout(
channelPicture = {
- NonClickableUserPictures(
- room = room,
+ ChatRoomFaces(
+ userHexList = remember(room) { room.users.toList() },
+ size = ChatRowAvatarSize,
accountViewModel = accountViewModel,
- size = Size55dp,
)
},
firstRow = {
+ val titleWeight = chatRowTitleWeight(hasNewMessages)
val counterpartHex = room.users.singleOrNull()
if (counterpartHex != null) {
// 1:1 room: resolve the counterpart once and share it between the name and the
// report-warning icon below, instead of each doing its own LoadUser.
LoadUser(baseUserHex = counterpartHex) { counterpart ->
- RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = counterpart)
+ RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = counterpart, fontWeight = titleWeight)
RoomReportWarningIcon(counterpart, accountViewModel)
}
} else {
- RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = null)
+ RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = null, fontWeight = titleWeight)
}
if (room in pinnedRooms.value) {
Icon(
@@ -1253,28 +1280,15 @@ private fun UserRoomCompose(
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
- Spacer(modifier = StdHorzSpacer)
}
- TimeAgo(lastMessage.createdAt())
+ TimeAgo(lastMessage.createdAt(), hasNewMessages)
},
secondRow = {
LastMessagePreview(lastMessage, accountViewModel)
-
- // A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
- val newestEvent = lastMessage.event
- val countsAsRead =
- !isDraft &&
- newestEvent != null &&
- chatMessageMarksRoomAsRead(newestEvent, room, accountViewModel.account.signer.pubKey)
-
- val lastReadTime by accountViewModel.account.loadLastReadFlow(privateChatLastReadRoute(room)).collectAsStateWithLifecycle()
- if (!countsAsRead && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) {
- Spacer(modifier = Height4dpModifier)
- NewItemsBubble()
- }
},
onClick = { nav.nav(Route.Room(room)) },
onLongClick = { popupExpanded = true },
+ hasNewMessages = hasNewMessages,
)
DropdownMenu(
@@ -1341,14 +1355,7 @@ private fun RowScope.LastMessagePreview(
ChatPreview.Missing -> stringRes(Res.string.referenced_event_not_found)
}
- Text(
- text,
- color = MaterialTheme.colorScheme.grayText,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
- modifier = Modifier.weight(1f),
- )
+ PreviewLine(rememberTidyPreview(text))
}
}
@@ -1365,18 +1372,25 @@ fun ChannelName(
autoPlayGif: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
+ // A room with no picture of its own shows this glyph on a tonal disc instead of a robohash.
+ // Null keeps the robohash (search results, where a robot face is the established look).
+ fallbackSymbol: MaterialSymbol? = null,
) {
ChannelName(
channelPicture = {
- RobohashFallbackAsyncImage(
- robot = channelIdHex,
- model = channelPicture,
- contentDescription = stringRes(Res.string.channel_image),
- modifier = AccountPictureModifier,
- loadProfilePicture = loadProfilePicture,
- loadRobohash = loadRobohash,
- autoPlayGif = autoPlayGif,
- )
+ if (fallbackSymbol != null && channelPicture.isNullOrBlank()) {
+ ChatRowTonalAvatar(fallbackSymbol)
+ } else {
+ RobohashFallbackAsyncImage(
+ robot = channelIdHex,
+ model = channelPicture,
+ contentDescription = stringRes(Res.string.channel_image),
+ modifier = ChatRowPictureModifier,
+ loadProfilePicture = loadProfilePicture,
+ loadRobohash = loadRobohash,
+ autoPlayGif = autoPlayGif,
+ )
+ }
},
channelTitle,
channelLastTime,
@@ -1401,45 +1415,63 @@ fun ChannelName(
channelPicture = channelPicture,
firstRow = {
channelTitle(Modifier.weight(1f))
- TimeAgo(channelLastTime)
+ TimeAgo(channelLastTime, hasNewMessages)
},
secondRow = {
- if (channelLastContent != null) {
- Text(
- channelLastContent,
- color = MaterialTheme.colorScheme.grayText,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
- modifier = Modifier.weight(1f),
- )
- } else {
- Text(
- stringRes(Res.string.referenced_event_not_found),
- color = MaterialTheme.colorScheme.grayText,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
- modifier = Modifier.weight(1f),
- )
- }
-
- if (hasNewMessages) {
- Spacer(modifier = Height4dpModifier)
- NewItemsBubble()
- }
+ PreviewLine(channelLastContent?.let { rememberTidyPreview(it) } ?: stringRes(Res.string.referenced_event_not_found))
},
onClick = onClick,
onLongClick = onLongClick,
+ hasNewMessages = hasNewMessages,
)
}
+/** The preview line of a row. Its color comes from [ChatHeaderLayout], which lifts it when unread. */
@Composable
-private fun TimeAgo(channelLastTime: Long?) {
- if (channelLastTime == null) return
- ToggleableTimeAgoText(
- timestamp = channelLastTime,
- style = TimeAgoStyle.Dotted,
- color = MaterialTheme.colorScheme.grayText,
+private fun RowScope.PreviewLine(text: String) {
+ Text(
+ text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
+ modifier = Modifier.weight(1f),
)
}
+
+/**
+ * [text] as a preview line should say it: see [ChatPreviewText.tidy]. Keyed on the text, so a row
+ * re-tidies only when its newest message changes.
+ */
+@Composable
+private fun rememberTidyPreview(text: String): String {
+ val labels =
+ ChatPreviewLabels(
+ photo = stringRes(Res.string.chat_preview_photo),
+ video = stringRes(Res.string.chat_preview_video),
+ note = stringRes(Res.string.chat_preview_note),
+ )
+ return remember(text, labels) {
+ ChatPreviewText.tidy(text, labels) { hex -> LocalCache.getUserIfExists(hex)?.toBestDisplayName() }
+ }
+}
+
+@Composable
+private fun TimeAgo(
+ channelLastTime: Long?,
+ hasNewMessages: Boolean,
+) {
+ if (channelLastTime == null) return
+ // The compact "• 10h" form. Not TimeAgoStyle.Short: on Android that goes through the platform's
+ // relative-span formatter ("12 hr. ago", "4 days ago"), which is several times wider and eats
+ // into the title. Tight variant because TimeAgoGap already provides the space before the dot.
+ // It takes the accent only when the row is unread, beside the dot below it.
+ ToggleableTimeAgoText(
+ timestamp = channelLastTime,
+ style = TimeAgoStyle.DottedTight,
+ color = if (hasNewMessages) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText,
+ fontSize = MaterialTheme.typography.bodySmall.fontSize,
+ modifier = TimeAgoGap,
+ )
+}
+
+private val TimeAgoGap = Modifier.padding(start = 8.dp)
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt
index 9e81bd344e..681cdc4119 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -63,7 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose
import com.vitorpamplona.amethyst.commons.ui.stringRes
-import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatListPadding
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.util.JavaSerializable
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -216,7 +215,7 @@ private fun FeedLoaded(
}
LazyColumn(
- contentPadding = rememberFeedContentPadding(FeedPadding),
+ contentPadding = rememberFeedContentPadding(ChatListPadding),
state = listState,
) {
itemsIndexed(
@@ -231,9 +230,8 @@ private fun FeedLoaded(
)
}
- HorizontalDivider(
- thickness = DividerThickness,
- )
+ // No divider between rows: the row padding already separates them, and a hairline under
+ // every row added a line of noise per conversation.
// Rendered unconditionally at the protocol's oldest room so the card can run its own
// "All caught up" crossfade-and-collapse when that protocol exhausts.
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt
index 96f6fe6045..327d271690 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
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.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.UrlUserTagOutputTransformation
@@ -140,7 +140,7 @@ fun NestEditFieldRow(
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
- text = stringRes(Res.string.reply_here),
+ text = stringRes(Res.string.chat_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/AmethystColorScheme.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/AmethystColorScheme.kt
index 7ee21e4412..98f0cd95b9 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/AmethystColorScheme.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/AmethystColorScheme.kt
@@ -420,11 +420,13 @@ val ColorScheme.subtleBorder: Color
val ColorScheme.chatBackground: Color
get() = if (isLight) LightChatBackground else DarkChatBackground
-// Accent-following bubble fill for the logged-in user's own chat messages. Stronger
-// than mediumImportanceLink so "mine" vs "theirs" vs background read at a glance
-// while the default onBackground text stays readable on top of it.
+// Accent-following bubble fill for the logged-in user's own chat messages. A tint, not a
+// fill: enough accent that "mine" vs "theirs" reads at a glance, low enough that a screen
+// 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
- 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
get() = if (isLight) LightChatBubbleThem else DarkChatBubbleThem
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Shape.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Shape.kt
index 69fe7f524a..43bf97ac8f 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Shape.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Shape.kt
@@ -134,13 +134,27 @@ val Size110dp = 110.dp
val Size165dp = 165.dp
/**
- * Max width for a type/label chip sitting beside a room name on a Messages-list row (the NIP-28
- * "Public Chat" pill, the NIP-29 relay-host chip, the Concord community chip, ...). Roughly half a
- * phone row so a long relay URL or community name is truncated (middle ellipsis) instead of crowding
- * the room name out — the name is weighted and keeps whatever the capped chip doesn't take.
+ * Max width for a label sitting beside a room name (the NIP-29 relay host, the Concord community, the
+ * cordn coordinator on a Messages-list row; the relay/community chips in the Notifications feed).
+ * Roughly half a phone row so a long relay URL or community name is truncated (middle ellipsis)
+ * instead of crowding the room name out — the name is weighted and keeps whatever the label doesn't take.
*/
val ChatLabelMaxWidth = 140.dp
+/**
+ * Avatar size of a Messages-list row. Smaller than the 55dp feed avatar on purpose: in a list that is
+ * read by name, a big saturated picture on every row outweighs the name it sits next to.
+ */
+val ChatRowAvatarSize = 48.dp
+val ChatRowAvatarModifier = Modifier.size(ChatRowAvatarSize)
+val ChatRowPictureModifier = Modifier.size(ChatRowAvatarSize).clip(shape = CircleShape)
+
+/**
+ * Content padding of the Messages list: a little air on top, and enough at the bottom that the last
+ * row's timestamp scrolls clear of the new-conversation FAB.
+ */
+val ChatListPadding = PaddingValues(top = 4.dp, bottom = 96.dp)
+
val StdEndPadding = Modifier.padding(end = 10.dp)
val HalfEndPadding = Modifier.padding(end = 5.dp)
val HalfStartPadding = Modifier.padding(start = 5.dp)
@@ -231,7 +245,9 @@ val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(15.dp)
val ShowMoreRelaysButtonIconModifier = Modifier.size(20.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)
@@ -258,14 +274,17 @@ val FeedPadding = PaddingValues(top = 10.dp, bottom = 10.dp)
val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
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 =
Modifier
.fillMaxWidth(1f)
.padding(
start = 12.dp,
end = 12.dp,
- top = 3.dp,
- bottom = 3.dp,
+ top = 9.dp,
+ bottom = 1.dp,
)
// Tighter top gap for a message visually connected to the previous message of the
@@ -277,7 +296,7 @@ val ChatPaddingGroupedModifier =
start = 12.dp,
end = 12.dp,
top = 1.dp,
- bottom = 3.dp,
+ bottom = 1.dp,
)
val profileContentHeaderModifier =
diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/SubjectChangeTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/SubjectChangeTest.kt
new file mode 100644
index 0000000000..f17617b04c
--- /dev/null
+++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/SubjectChangeTest.kt
@@ -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(" ")))
+ }
+}
diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatScreenRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatScreenRenderTest.kt
new file mode 100644
index 0000000000..60268eaa3e
--- /dev/null
+++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatScreenRenderTest.kt
@@ -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()
+ 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? = 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")
+ }
+}
diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/MessagesListRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/MessagesListRenderTest.kt
new file mode 100644
index 0000000000..bbfdc0e1e2
--- /dev/null
+++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/MessagesListRenderTest.kt
@@ -0,0 +1,240 @@
+/*
+ * 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.rooms
+
+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.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+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.style.TextOverflow
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.public_chat
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowAvatarSize
+import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowPictureModifier
+import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.utils.TimeUtils
+import org.jetbrains.skia.EncodedImageFormat
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * Renders a Messages list offscreen (no device, no display) through `ImageComposeScene`, in the dark
+ * and light themes side by side, and writes it to `commonsUI/build/messages-list/messages.png`.
+ *
+ * The rows go through the real [ChannelName] / `ChatHeaderLayout` / title / label / preview-cleanup
+ * code; only the pictures are stand-ins (flat discs), because a real one needs the network and an
+ * account. It exists so the list's visual weight can be looked at while it is tuned, and it fails if
+ * the list throws or draws nothing.
+ */
+class MessagesListRenderTest {
+ private val outDir = File("build/messages-list").apply { mkdirs() }
+
+ @Test
+ fun messages() {
+ val density = 2f
+ val widthDp = 840
+ val heightDp = 760
+ val scene =
+ ImageComposeScene(
+ width = (widthDp * density).toInt(),
+ height = (heightDp * density).toInt(),
+ density = Density(density),
+ ) { ThemeComparisonRow { SampleMessagesList() } }
+ 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, "messages.png").writeBytes(png.bytes)
+
+ val pixels = image.peekPixels() ?: error("no pixels")
+ val distinct = HashSet()
+ 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 list rendered almost nothing (${distinct.size} colours)")
+ } finally {
+ scene.close()
+ }
+ }
+
+ private companion object {
+ const val SETTLE_FRAMES = 12
+ const val FRAME_MILLIS = 60L
+ }
+}
+
+private val Fabrica = Color(0xFF6D8B74)
+private val Sunset = Color(0xFFD9825B)
+private val Ocean = Color(0xFF4F86C6)
+private val Forest = Color(0xFF55745A)
+private val Buzz = Color(0xFFE6195E)
+private val Soapbox = Color(0xFFB400E0)
+private val Amethyst = Color(0xFFF2F2F2)
+private val Robot = Color(0xFF3E6E8E)
+private val Bender = Color(0xFF3C8C73)
+
+@Composable
+private fun Disc(color: Color) {
+ Box(ChatRowPictureModifier.background(color))
+}
+
+/** Stand-in for [com.vitorpamplona.amethyst.commons.ui.note.ChatRoomFaces]: same geometry, flat faces. */
+@Composable
+private fun TwoFaces(
+ back: Color,
+ front: Color,
+) {
+ val face = ChatRowAvatarSize * 0.68f
+ Box(Modifier.size(ChatRowAvatarSize)) {
+ Box(Modifier.align(Alignment.TopStart).size(face).background(back, CircleShape))
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier =
+ Modifier
+ .align(Alignment.BottomEnd)
+ .size(face + 4.dp)
+ .background(MaterialTheme.colorScheme.background, CircleShape),
+ ) {
+ Box(Modifier.size(face).background(front, CircleShape))
+ }
+ }
+}
+
+@Composable
+private fun DmTitle(
+ name: String,
+ modifier: Modifier,
+ pinned: Boolean = false,
+) {
+ Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
+ Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
+ if (pinned) {
+ Icon(
+ symbol = MaterialSymbols.PushPin,
+ contentDescription = null,
+ modifier = Modifier.size(15.dp),
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.42f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun SampleMessagesList() {
+ val now = TimeUtils.now()
+ val nevent = NEvent.create("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", null, 1, relay = null)
+
+ Column {
+ ChannelName(
+ channelPicture = { TwoFaces(Fabrica, Sunset) },
+ channelTitle = { DmTitle("NosFabrica", it, pinned = true) },
+ channelLastTime = now - 57 * 60,
+ channelLastContent = "You: Test2",
+ hasNewMessages = false,
+ onClick = {},
+ )
+ ChannelName(
+ channelPicture = { TwoFaces(Ocean, Forest) },
+ channelTitle = { DmTitle("Minced, greenart7c3, Dee, KotlinG", it, pinned = true) },
+ channelLastTime = now - 2 * 24 * 3600,
+ channelLastContent = "You: testing group message from armada",
+ hasNewMessages = false,
+ onClick = {},
+ )
+ ChannelName(
+ channelPicture = { Disc(Buzz) },
+ channelTitle = { ChannelTitleWithLabelInfo("buzz-fork", MaterialSymbols.Dns, "buzz.relay.tools", it) },
+ channelLastTime = now - 2 * 60,
+ channelLastContent = "buzz-watcher: [github block/buzz@main] `a3870e1` **fix** the relay reconnect loop",
+ hasNewMessages = true,
+ onClick = {},
+ )
+ ChannelName(
+ channelPicture = { Disc(Robot) },
+ channelTitle = { DmTitle("Test - npub18aj6sjrr…jsmpnxpv", it) },
+ channelLastTime = now - 22 * 60,
+ channelLastContent = "You: https://nostr.download/ee078c63eab4f77eb2c1a9.png",
+ hasNewMessages = false,
+ onClick = {},
+ )
+ ChannelName(
+ channelPicture = { Disc(Amethyst) },
+ channelTitle = { ChannelTitleWithLabelInfo("Amethyst Users", MaterialSymbols.Public, Res.string.public_chat, it) },
+ channelLastTime = now - 3600,
+ channelLastContent = "OK3E: nostr:$nevent",
+ hasNewMessages = false,
+ onClick = {},
+ )
+ ChannelName(
+ channelPicture = { Disc(Soapbox) },
+ channelTitle = { ChannelTitleWithLabelInfo("armada", MaterialSymbols.Group, "Soapbox Community", it) },
+ channelLastTime = now - 3600,
+ channelLastContent = "Dino Dini: i found also some kind of way to post gifs from the app",
+ hasNewMessages = false,
+ onClick = {},
+ )
+ ChannelName(
+ channelPicture = { Disc(Soapbox) },
+ channelTitle = { ChannelTitleWithLabelInfo("general", MaterialSymbols.Group, "Soapbox Community", it) },
+ channelLastTime = now - 8 * 3600,
+ channelLastContent = "nycta: !meme",
+ hasNewMessages = true,
+ onClick = {},
+ )
+ ChannelName(
+ channelPicture = { Disc(Bender) },
+ channelTitle = { ChannelTitleWithLabelInfo("Nostr", MaterialSymbols.Public, Res.string.public_chat, it) },
+ channelLastTime = now - 12 * 3600,
+ channelLastContent = "J Smith: In my surroundings, there is one elderly neighbour who still posts",
+ hasNewMessages = false,
+ onClick = {},
+ )
+ ChannelName(
+ channelIdHex = "9q8yy",
+ channelPicture = null,
+ channelTitle = { ChannelTitleWithLabelInfo("#9q8yy", MaterialSymbols.LocationOn, "San Francisco", it) },
+ channelLastTime = now - 14 * 3600,
+ channelLastContent = "anon: anyone at the meetup tonight?",
+ hasNewMessages = false,
+ loadProfilePicture = false,
+ loadRobohash = false,
+ autoPlayGif = false,
+ onClick = {},
+ fallbackSymbol = MaterialSymbols.LocationOn,
+ )
+ }
+}