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, + ) + } +}