feat(messages): calm the Messages list down

The list styled every row as if it were the most important one: bold on
every title, purple-filled type pills, 55dp saturated avatars with trust
scores on every collage face, a divider under every row and accent on all
the chrome. The one signal that matters, unread, was a 10dp dot.

- Unread drives emphasis: ChatHeaderLayout takes hasNewMessages and owns
  the title weight (Medium read / Bold unread), the preview color (gray
  read / onSurface unread), the accent timestamp and the dot.
- Room-kind pills become a muted glyph after the name; names that tell
  rooms apart (relay host, Concord community, cordn coordinator) become an
  unfilled muted ChatRowLabel. The filled chips stay in Notifications.
- 48dp avatars, 16/12dp row padding, no row dividers, bottom padding so
  the FAB no longer covers the last row, no leading dot on the time.
- Group rooms show at most two overlapping faces without badges
  (ChatRoomFaces); picture-less rooms get a tonal disc with their kind
  glyph instead of a robohash.
- Previews go through ChatPreviewText.tidy: nostr: mentions become
  @Name, nevent/note/naddr become "Shared a note", image/video links
  become Photo/Video, other links their host, markdown is flattened.
- Chrome: Messages tabs use a label-width indicator and gray unselected
  tab; bottom bar selection pill is a neutral wash; favorite-app favicons
  are grayscale until selected.
- MessagesListRenderTest renders the list offscreen (dark | light) to
  commonsUI/build/messages-list/messages.png.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113Y3NP1fRnbQJyrH6D6YQj
This commit is contained in:
Claude
2026-10-02 00:13:29 +00:00
parent ea139b964b
commit 02a5ec4820
17 changed files with 916 additions and 170 deletions
@@ -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)
@@ -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) } },
)
}
@@ -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
}
@@ -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"))
}
}
@@ -407,6 +407,9 @@
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
<string name="chat_preview_decrypting">Decrypting…</string>
<string name="chat_preview_you_prefix">You: %1$s</string>
<string name="chat_preview_photo">Photo</string>
<string name="chat_preview_video">Video</string>
<string name="chat_preview_note">Shared a note</string>
<string name="group_picture">Group Picture</string>
<string name="explicit_content">Explicit Content</string>
<string name="relay_notice">Relay Notice</string>
@@ -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.
*/
@@ -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,
)
}
}
@@ -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
@@ -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()
}
}
}
}
}
@@ -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,
)
}
}
@@ -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,66 @@ fun ClickableUserPicture(
BaseUserPicture(baseUserHex, size, accountViewModel, modifier, myModifier)
}
/**
* The faces of a Messages-list room. A 1:1 room shows its counterpart with the usual follow and
* trust badges. A group shows at most two faces, overlapped, and no badges: at list size a 2x2
* mosaic leaves each face ~24dp, and a trust score plus a follow shield on every one of them is
* noise nobody can read.
*/
@Composable
fun ChatRoomFaces(
userHexList: List<HexKey>,
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)) {
BadgelessUserPicture(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),
) {
BadgelessUserPicture(userHexList[1], face, accountViewModel)
}
}
}
}
}
@Composable
private fun BadgelessUserPicture(
userHex: HexKey,
size: Dp,
accountViewModel: AccountViewModel,
) {
LoadUser(userHex) {
if (it != null) {
ObserveAndDrawInnerUserPicture(it, size, accountViewModel)
} else {
InnerUserPicture(userHex = userHex, userPicture = null, userName = null, size = size, modifier = Modifier)
}
}
}
@Composable
fun NonClickableUserPictures(
room: ChatroomKey,
@@ -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) }
}
}
@@ -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),
)
}
}
@@ -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.concord_direct_invite_from
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_label
@@ -121,17 +123,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
@@ -151,11 +152,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
@@ -417,6 +416,7 @@ private fun ChannelRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
fallbackSymbol = MaterialSymbols.Public,
onClick = { nav.nav(routeFor(channel)) },
onLongClick = { menuOpen = true },
)
@@ -477,6 +477,7 @@ private fun ChannelRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
fallbackSymbol = MaterialSymbols.Timer,
onClick = { nav.nav(routeFor(channel)) },
)
}
@@ -512,6 +513,7 @@ private fun GeohashRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
fallbackSymbol = MaterialSymbols.LocationOn,
onClick = { nav.nav(Route.GeohashChat(geohashChannel.geohash)) },
)
}
@@ -543,7 +545,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,
@@ -577,6 +579,7 @@ private fun MarmotGroupRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
fallbackSymbol = MaterialSymbols.Lock,
onClick = { nav.nav(Route.MarmotGroupChat(chatroom.nostrGroupId)) },
)
}
@@ -681,6 +684,7 @@ internal fun CordnGroupRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
fallbackSymbol = MaterialSymbols.Groups,
onClick = { nav.nav(Route.CordnGroupChat(chatroom.coordinatorPubKey, chatroom.gid)) },
)
}
@@ -789,20 +793,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,
@@ -821,6 +820,7 @@ fun RelayGroupRow(
nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url))
}
},
fallbackSymbol = MaterialSymbols.Groups,
onLongClick = { menuOpen = true },
)
@@ -957,22 +957,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())
}
}
},
@@ -985,6 +983,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 },
)
@@ -1042,6 +1041,7 @@ private fun RelayGroupServerRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
fallbackSymbol = MaterialSymbols.Dns,
onClick = { nav.nav(Route.RelayGroupServer(relay.url)) },
)
}
@@ -1079,6 +1079,7 @@ private fun ConcordDirectInviteRoomCompose(
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
fallbackSymbol = MaterialSymbols.Group,
onClick = { nav.nav(Route.Concords) },
)
}
@@ -1132,56 +1133,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,
)
}
}
@@ -1224,25 +1240,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(
@@ -1251,28 +1278,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(
@@ -1330,14 +1344,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))
}
}
@@ -1354,18 +1361,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,
@@ -1390,45 +1404,61 @@ 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
// No leading "•": the time sits alone at the end of the title line, so there is nothing to
// separate it from. It takes the accent only when the row is unread, beside the dot below it.
ToggleableTimeAgoText(
timestamp = channelLastTime,
style = TimeAgoStyle.Short,
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)
@@ -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.
@@ -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)
@@ -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<Int>()
for (y in 0 until image.height step 7) for (x in 0 until image.width step 7) distinct += pixels.getColor(x, y)
assertTrue(distinct.size > 20, "the 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,
)
}
}