Merge pull request #4305 from vitorpamplona/ccr-b9297681-li4yrq

Refactor chat UI: extract preview text logic, add render tests
This commit is contained in:
Vitor Pamplona
2026-10-02 09:42:39 -04:00
committed by GitHub
37 changed files with 1538 additions and 316 deletions
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
@@ -42,7 +41,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -69,10 +67,9 @@ fun ChannelNamePreview() {
},
secondRow = {
Text(PREVIEW_MESSAGE, Modifier.weight(1f))
Spacer(modifier = Height4dpModifier)
NewItemsBubble()
},
onClick = {},
hasNewMessages = true,
)
HorizontalDivider(thickness = DividerThickness)
@@ -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,
)
}
@@ -27,7 +27,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -81,7 +81,10 @@ fun MessagesTabHeader(
val coroutineScope = rememberCoroutineScope()
Box(Modifier.fillMaxWidth()) {
SecondaryTabRow(
// A primary row draws its indicator under the label only, not across the whole half of the
// screen, and the unselected tab steps back to the placeholder gray so the selection reads
// from the text itself instead of from a wide accent bar.
PrimaryTabRow(
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
@@ -91,6 +94,8 @@ fun MessagesTabHeader(
Tab(
selected = pagerState.currentPage == index,
text = { Text(text = stringRes(tab.resource)) },
selectedContentColor = MaterialTheme.colorScheme.onBackground,
unselectedContentColor = MaterialTheme.colorScheme.placeholderText,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
)
}
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.privateChats
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
/**
* The words a one-line preview puts in place of things that only make sense rendered: a photo, a
* video, a quoted note. Resolved from string resources by the UI and handed in, so the cleanup itself
* stays a pure function.
*/
data class ChatPreviewLabels(
val photo: String,
val video: String,
val note: String,
)
/**
* Turns a raw message body into what a Messages-list preview line should say.
*
* The bubble renders a message; the preview line only has one line of plain text, so anything that
* relies on rendering comes out as machine text there — `nostr:nevent1qqs…`, a 90-char blob URL,
* markdown backticks. Those strings are the harshest thing on a list screen, so the preview swaps
* each for what a reader would call it:
*
* - `nostr:npub…` / `nprofile…` → `@Name` (via [nameOf]; a short npub when the name is unknown)
* - `nostr:nevent…` / `note…` / `naddr…` / `nembed…` → [ChatPreviewLabels.note]
* - an image URL → [ChatPreviewLabels.photo], a video URL → [ChatPreviewLabels.video],
* any other URL → its host
* - markdown `[label](url)` → `label`; emphasis markers, backticks, heading and quote markers dropped
* - line breaks and runs of spaces → one space, so the line shows as much of the message as it can
*/
object ChatPreviewText {
private val whitespace = Regex("\\s+")
private val markdownLink = Regex("\\[([^\\]\\n]+)]\\((https?://[^)\\s]+)\\)")
private val url = Regex("https?://[^\\s<>\"]+", RegexOption.IGNORE_CASE)
private val lineMarkers = Regex("(?m)^[ \\t]*(#{1,6}[ \\t]+|>[ \\t]?)")
private val emphasis = Regex("\\*\\*|__|~~|`")
private val trailingPunctuation = charArrayOf('.', ',', ')', '!', '?', ';', ':', '"', '\'')
private val nip19Prefixes = arrayOf("npub1", "nprofile1", "nevent1", "note1", "naddr1", "nembed1")
fun tidy(
text: String,
labels: ChatPreviewLabels,
nameOf: (HexKey) -> String?,
): String {
if (text.isEmpty()) return text
var out = text
if (out.contains("](")) out = markdownLink.replace(out) { it.groupValues[1] }
if (out.contains('#') || out.contains('>')) out = lineMarkers.replace(out, "")
if (nip19Prefixes.any { out.contains(it, ignoreCase = true) }) out = replaceNip19(out, labels, nameOf)
if (out.contains("://")) out = replaceUrls(out, labels)
out = emphasis.replace(out, "")
return whitespace.replace(out, " ").trim()
}
private fun replaceNip19(
text: String,
labels: ChatPreviewLabels,
nameOf: (HexKey) -> String?,
): String =
Nip19Parser.nip19regex.replace(text) { match ->
val type = match.groups[3]?.value ?: match.groups[5]?.value
val key = match.groups[4]?.value ?: match.groups[6]?.value
val trailing = match.groups[7]?.value ?: ""
when (val entity = Nip19Parser.parseComponents(type ?: "", key, null)?.entity) {
is NPub -> "@${nameOf(entity.hex) ?: shortBech32(type + key)}$trailing"
is NProfile -> "@${nameOf(entity.hex) ?: shortBech32(type + key)}$trailing"
is NEvent, is NNote, is NAddress, is NEmbed -> "${labels.note}$trailing"
else -> match.value
}
}
private fun replaceUrls(
text: String,
labels: ChatPreviewLabels,
): String =
url.replace(text) { match ->
val raw = match.value
val link = raw.trimEnd(*trailingPunctuation)
val trailing = raw.substring(link.length)
val replacement =
when {
RichTextParser.isImageUrl(link) -> labels.photo
RichTextParser.isVideoUrl(link) -> labels.video
else -> hostOf(link) ?: link
}
replacement + trailing
}
private fun hostOf(link: String): String? {
val afterScheme = link.substringAfter("://", "")
val host =
afterScheme
.substringBefore('/')
.substringBefore('?')
.substringBefore('#')
.substringAfter('@')
return host.removePrefix("www.").ifBlank { null }
}
private fun shortBech32(bech32: String): String = if (bech32.length > 16) bech32.take(10) + "…" + bech32.takeLast(4) else bech32
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.privateChats
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlin.test.Test
import kotlin.test.assertEquals
class ChatPreviewTextTest {
private val labels = ChatPreviewLabels(photo = "Photo", video = "Video", note = "Note")
private val hex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
private val names = mapOf(hex to "Vitor")
private fun tidy(text: String) = ChatPreviewText.tidy(text, labels) { names[it] }
@Test
fun plainTextIsUntouched() {
assertEquals("hello there", tidy("hello there"))
}
@Test
fun collapsesLineBreaksIntoOneLine() {
assertEquals("first line second line", tidy("first line\n\n second line"))
}
@Test
fun eventReferenceBecomesTheNoteLabel() {
val nevent = NEvent.create(hex, hex, 1, relay = null)
assertEquals("Note", tidy("nostr:$nevent"))
assertEquals("look: Note", tidy("look: nostr:${NNote.create(hex)}"))
}
@Test
fun userMentionBecomesTheName() {
assertEquals("hi @Vitor!", tidy("hi nostr:${NPub.create(hex)}!"))
}
@Test
fun unknownUserMentionIsShortened() {
val other = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
val npub = NPub.create(other)
assertEquals("@${npub.take(10)}…${npub.takeLast(4)}", tidy("nostr:$npub"))
}
@Test
fun imageAndVideoLinksBecomeLabels() {
assertEquals("Photo", tidy("https://nostr.download/ee078c63eab4f77eb1234.jpg"))
assertEquals("watch Video.", tidy("watch https://example.com/clip.mp4."))
}
@Test
fun otherLinksBecomeTheirHost() {
assertEquals("see github.com", tidy("see https://www.github.com/vitorpamplona/amethyst/pull/1"))
assertEquals("nostr.download", tidy("https://nostr.download/ee078c63eab4f77eb1234"))
}
@Test
fun markdownIsFlattened() {
assertEquals("[github block/buzz@main] a3870 fix the build", tidy("[github block/buzz@main] `a3870` **fix** the build"))
assertEquals("the docs here", tidy("the [docs](https://example.com/docs) here"))
assertEquals("Title quoted", tidy("## Title\n> quoted"))
}
}
@@ -407,6 +407,12 @@
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
<string name="chat_preview_decrypting">Decrypting…</string>
<string name="chat_preview_you_prefix">You: %1$s</string>
<string name="chat_subject_changed_by">%1$s renamed the chat to “%2$s”</string>
<string name="chat_subject_changed_by_you">You renamed the chat to “%1$s”</string>
<string name="chat_composer_hint">Message</string>
<string name="chat_preview_photo">Photo</string>
<string name="chat_preview_video">Video</string>
<string name="chat_preview_note">Shared a note</string>
<string name="group_picture">Group Picture</string>
<string name="explicit_content">Explicit Content</string>
<string name="relay_notice">Relay Notice</string>
@@ -4318,6 +4324,10 @@
<string name="marmot_group_disbanded_toast">Group disbanded</string>
<string name="marmot_group_disbanding_toast">Ending the group. It finishes once the group agrees.</string>
<string name="marmot_group_info_updated">Group info updated</string>
<plurals name="chat_room_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="marmot_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
@@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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<Color>) -> 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,
@@ -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)
@@ -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
}
@@ -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)
@@ -42,8 +42,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
/**
* A tappable chip naming the Concord community a message belongs to. Wears the same highlighted wash as
* the NIP-29 relay-host chip ([secondaryContainer] — a gray on the dark theme) so every "which server /
* community does this room belong to" chip reads the same across the Messages screen. Shared by the
* Messages row and the Notifications feed so a Concord message reads the same wherever it surfaces; the
* community does this room belong to" chip reads the same in the Notifications feed. (The Messages
* list names the community with the quieter `ChatRowLabel` instead, since it repeats on every row.) The
* width is capped at [ChatLabelMaxWidth] with a middle ellipsis so a long community name is truncated
* instead of crowding the room name out.
*/
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -43,6 +44,8 @@ fun FavoriteAppIcon(
tint: Color,
modifier: Modifier = Modifier,
iconModel: Any? = null,
// Applied to the drawn icon (image or its placeholder glyph), e.g. to desaturate it.
colorFilter: ColorFilter? = null,
) {
val symbol = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public
val model = iconModel ?: app.iconUrl?.takeIf { it.isNotBlank() }
@@ -58,6 +61,7 @@ fun FavoriteAppIcon(
placeholder = glyph,
error = glyph,
fallback = glyph,
colorFilter = colorFilter,
)
}
}
@@ -48,8 +48,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
* so every "which server / community does this room belong to" chip reads the same way. Unlike the
* muted note-header [com.vitorpamplona.amethyst.commons.ui.note.HeaderPill] (PoW/OTS/location
* markers), this one is a first-class navigation entry point, so it keeps the stronger
* `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Buzz
* message reads the same wherever it surfaces; the width is capped at [ChatLabelMaxWidth] with a
* `secondaryContainer` highlight. Used by the Notifications feed; the Messages list names the relay
* with the quieter `ChatRowLabel` instead, since there it repeats on every row. The width is capped at [ChatLabelMaxWidth] with a
* middle ellipsis so a long host is truncated instead of crowding the channel name out.
*/
@Composable
@@ -29,14 +29,39 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowAvatarModifier
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
private val ChatRowPadding = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
private val ChatRowAvatarGap = Modifier.width(12.dp)
private val ChatRowLineGap = Modifier.padding(top = 3.dp)
private val ChatRowDotGap = Modifier.width(8.dp)
/**
* Title weight of a Messages-list row. Read rows sit at Medium so the list is calm; only a row with
* something new goes Bold, which makes bold *mean* unread instead of being on every line.
*/
fun chatRowTitleWeight(hasNewMessages: Boolean): FontWeight = if (hasNewMessages) FontWeight.Bold else FontWeight.Medium
/**
* One Messages-list row: picture, then a title line over a preview line.
*
* The row owns its unread emphasis so every room kind agrees on it: [hasNewMessages] turns the title
* Bold (titles inherit it from [LocalTextStyle] — don't hardcode a weight in [firstRow]), lifts the
* preview from gray to full-contrast (preview text inherits [LocalContentColor] — don't hardcode a
* color in [secondRow]), and draws the unread dot at the end of the preview line.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatHeaderLayout(
@@ -45,33 +70,46 @@ fun ChatHeaderLayout(
secondRow: @Composable RowScope.() -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
hasNewMessages: Boolean = false,
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(10.dp),
.then(ChatRowPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Size55Modifier) { channelPicture() }
Box(ChatRowAvatarModifier) { channelPicture() }
Spacer(modifier = DoubleHorzSpacer)
Spacer(modifier = ChatRowAvatarGap)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(fontWeight = chatRowTitleWeight(hasNewMessages)),
) {
firstRow()
Row(
verticalAlignment = Alignment.CenterVertically,
) {
firstRow()
}
}
Spacer(modifier = Height4dpModifier)
Row(
verticalAlignment = Alignment.CenterVertically,
CompositionLocalProvider(
LocalContentColor provides if (hasNewMessages) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText,
) {
secondRow()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ChatRowLineGap,
) {
secondRow()
if (hasNewMessages) {
Spacer(modifier = ChatRowDotGap)
NewItemsBubble()
}
}
}
}
}
@@ -34,6 +34,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -41,6 +42,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
@@ -145,6 +148,8 @@ internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? =
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
}
private val GrayscaleFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */
@Composable
internal fun FavoriteEntryIcon(
@@ -158,6 +163,9 @@ internal fun FavoriteEntryIcon(
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
modifier = Size25Modifier,
iconModel = iconModel,
// A favicon is a full-color brand mark sitting among monochrome glyphs; unselected it
// goes grayscale like its neighbours and gets its color back once it is the open tab.
colorFilter = if (selected) null else GrayscaleFilter,
)
}
}
@@ -192,6 +200,10 @@ private fun RenderBottomMenu(
) {
// Render in the user's saved order — built-ins, favorites and pinned groups interleaved.
// Each entry resolves to a shared BottomBarSlot (route + icon), the same one the rail uses.
// The selected pill is a neutral wash rather than the accent container: the selected
// icon is already accent-tinted, and an accent pill behind it made the bar one of the
// loudest things on every screen.
val itemColors = NavigationBarItemDefaults.colors(indicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
items.forEach { entry ->
val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach
val selected = slot.route == selectedRoute
@@ -200,6 +212,7 @@ private fun RenderBottomMenu(
icon = { slot.icon(selected) },
selected = selected,
onClick = { nav(slot.route) },
colors = itemColors,
)
}
}
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -221,6 +223,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<HexKey>,
size: Dp,
accountViewModel: AccountViewModel,
) {
when (userHexList.size) {
0 -> {}
1 -> {
LoadUser(baseUserHex = userHexList.first()) {
it?.let { BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier.size(size)) }
}
}
else -> {
val face = size * 0.68f
val ring = 2.dp
Box(Modifier.size(size)) {
Box(Modifier.align(Alignment.TopStart)) {
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,
@@ -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)
@@ -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 —
@@ -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,
@@ -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)
@@ -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,
)
}
@@ -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)
}
}
@@ -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,
)
},
@@ -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(
@@ -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) }
}
}
@@ -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,
)
},
@@ -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,
)
},
@@ -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,
)
},
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowPictureModifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
private val ChatRowTypeIconModifier = Modifier.size(15.dp)
private val ChatRowLabelIconModifier = Modifier.size(13.dp)
/**
* The room-kind glyph after a Messages-row title (globe for a public chat, lock for a Marmot group,
* pin for a geohash cell, ...). Icon only: the glyph is enough to tell kinds apart at a glance, and a
* word in a chip on every row competed with the room name for the eye. [contentDescription] carries
* the word for screen readers.
*/
@Composable
fun ChatRowTypeIcon(
symbol: MaterialSymbol,
contentDescription: String?,
) {
Icon(
symbol = symbol,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = ChatRowTypeIconModifier,
)
}
/**
* Muted metadata after a Messages-row title when the room needs a *name* to be told apart, not just a
* kind: the relay a NIP-29 group lives on, the Concord community a channel belongs to, the cordn
* coordinator. No fill — it reads as a caption of the title, not as a second title. Capped at
* [ChatLabelMaxWidth] with a middle ellipsis so it never crowds the room name out.
*/
@Composable
fun ChatRowLabel(
symbol: MaterialSymbol,
text: String,
contentDescription: String? = null,
onClick: (() -> Unit)? = null,
) {
val color = MaterialTheme.colorScheme.placeholderText
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier =
Modifier
.widthIn(max = ChatLabelMaxWidth)
.then(if (onClick != null) Modifier.clip(MaterialTheme.shapes.small).clickable(onClick = onClick) else Modifier),
) {
Icon(
symbol = symbol,
contentDescription = contentDescription,
tint = color,
modifier = ChatRowLabelIconModifier,
)
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Normal,
color = color,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
/**
* The picture of a room that has none: a quiet tonal disc with the room-kind glyph. Replaces the
* robohash for rooms (not people) — a robot face says nothing about a geohash cell or a relay group,
* and its saturated colors made picture-less rooms the loudest rows in the list.
*/
@Composable
fun ChatRowTonalAvatar(symbol: MaterialSymbol) {
Box(
contentAlignment = Alignment.Center,
modifier = ChatRowPictureModifier.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f), CircleShape),
) {
Icon(
symbol = symbol,
contentDescription = null,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.size(22.dp),
)
}
}
@@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalTextStyle
@@ -39,7 +39,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -47,7 +46,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.publicChannels.concord.concordCommunityHasUnreadFlow
import com.vitorpamplona.amethyst.commons.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
import com.vitorpamplona.amethyst.commons.chats.rooms.rowHasUnread
import com.vitorpamplona.amethyst.commons.concord.ui.ConcordCommunityPill
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
@@ -74,9 +72,10 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreview
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreviewLabels
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreviewText
import com.vitorpamplona.amethyst.commons.model.privateChats.chatPreviewOf
import com.vitorpamplona.amethyst.commons.model.relayGroupChannelLastReadRoute
import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayNameChip
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderByParentFilterAssemblerSubscription
@@ -91,6 +90,9 @@ import com.vitorpamplona.amethyst.commons.resources.channel_invite_leave
import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you
import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you_by
import com.vitorpamplona.amethyst.commons.resources.chat_preview_decrypting
import com.vitorpamplona.amethyst.commons.resources.chat_preview_note
import com.vitorpamplona.amethyst.commons.resources.chat_preview_photo
import com.vitorpamplona.amethyst.commons.resources.chat_preview_video
import com.vitorpamplona.amethyst.commons.resources.chat_preview_you_prefix
import com.vitorpamplona.amethyst.commons.resources.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)
@@ -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.
@@ -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,
)
},
@@ -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
@@ -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 =
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chats.ui
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class SubjectChangeTest {
private val key = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
private fun message(subject: String?) =
Event(
id = key,
pubKey = key,
createdAt = 1L,
kind = 14,
tags = if (subject != null) arrayOf(arrayOf("subject", subject)) else emptyArray(),
content = "hi",
sig = key,
)
@Test
fun noSubjectIsNoRename() {
assertNull(subjectChangeOf(message(null), message(null)))
}
@Test
fun aNewSubjectIsARename() {
assertEquals("Product", subjectChangeOf(message(null), message("Product")))
assertEquals("Product", subjectChangeOf(message("Old"), message("Product")))
}
@Test
fun aRepeatedSubjectIsNotARename() {
// Some clients tag every message of a named group with its subject.
assertNull(subjectChangeOf(message("Product"), message("Product")))
assertNull(subjectChangeOf(message("Product"), message(" Product ")))
}
@Test
fun aBlankSubjectIsNoRename() {
assertNull(subjectChangeOf(message("Product"), message(" ")))
}
}
@@ -0,0 +1,258 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
import com.vitorpamplona.amethyst.commons.chats.ui.ChatSystemCaption
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import org.jetbrains.skia.EncodedImageFormat
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Renders a group chat offscreen (no device, no display) through `ImageComposeScene`, in the dark and
* light themes side by side, and writes it to `commonsUI/build/chat-screen/chat.png`.
*
* Bubbles, dividers, the rename caption, reaction chips and the send button are the real shared
* composables. The header row, the bubble footer and the faces are stand-ins with the same geometry
* and styles, since the real ones need a Note, an account and the network. It exists so the chat's
* visual weight can be looked at while it is tuned, and it fails if the screen draws nothing.
*/
class ChatScreenRenderTest {
private val outDir = File("build/chat-screen").apply { mkdirs() }
@Test
fun chat() {
val density = 2f
val widthDp = 840
val heightDp = 1040
val scene =
ImageComposeScene(
width = (widthDp * density).toInt(),
height = (heightDp * density).toInt(),
density = Density(density),
) { ThemeComparisonRow { SampleGroupChat() } }
try {
var image = scene.render(0)
repeat(SETTLE_FRAMES) { frame ->
Thread.sleep(FRAME_MILLIS)
image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L)
}
val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode")
File(outDir, "chat.png").writeBytes(png.bytes)
val pixels = image.peekPixels() ?: error("no pixels")
val distinct = HashSet<Int>()
for (y in 0 until image.height step 7) for (x in 0 until image.width step 7) distinct += pixels.getColor(x, y)
assertTrue(distinct.size > 20, "the chat rendered almost nothing (${distinct.size} colours)")
} finally {
scene.close()
}
}
private companion object {
const val SETTLE_FRAMES = 12
const val FRAME_MILLIS = 60L
}
}
private const val OUTPOST = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
private const val DAVID = "3f9e2d1c0b0a99887766554433221100ffeeddccbbaa99887766554433221100"
@Composable
private fun Face(
color: Color,
size: Int = 28,
) {
Box(Modifier.size(size.dp).background(color, CircleShape))
}
/** Same geometry as `ChatRoomFaces` (two overlapping faces with a background ring). */
@Composable
private fun HeaderFaces() {
val face = 34.dp * 0.68f
Box(Modifier.size(34.dp)) {
Box(Modifier.align(Alignment.TopStart).size(face).background(Color(0xFF8C6E5A), CircleShape))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.align(Alignment.BottomEnd).size(face + 4.dp).background(MaterialTheme.colorScheme.background, CircleShape),
) {
Box(Modifier.size(face).background(Color(0xFF4F86C6), CircleShape))
}
}
}
@Composable
private fun Header() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText)
Spacer(Modifier.width(12.dp))
HeaderFaces()
Column(Modifier.padding(start = 10.dp)) {
Text("NosFabrica Product", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium)
Text("5 members", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
/** Same type and color as `ChatTimeAgo` + the delivered tick of `ChatTimeWithDelivery`. */
@Composable
private fun Footer(
time: String,
mine: Boolean,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(time, color = MaterialTheme.colorScheme.placeholderText, fontSize = Font12SP)
if (mine) {
Spacer(Modifier.width(4.dp))
Icon(MaterialSymbols.DoneAll, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(14.dp))
}
}
}
@Composable
private fun Bubble(
text: String,
mine: Boolean,
position: ChatGroupPosition = ChatGroupPosition.SINGLE,
time: String? = null,
author: Pair<String, String>? = null,
faceColor: Color = Color.Gray,
fire: Boolean = false,
) {
val isLight = MaterialTheme.colorScheme.isLight
ChatBubbleLayout(
isLoggedInUser = mine,
isDraft = false,
innerQuote = false,
drawAuthorInfo = author != null && position.isFirstOfGroup,
groupPosition = position,
onClick = { false },
onAuthorClick = {},
actionMenu = {},
reactionsRow =
if (fire) {
{ ChatChipFlowRow { ReactionChipView(ReactionChip("🔥", 1, false), {}, {}) } }
} else {
null
},
footerRow = time?.let { { Footer(it, mine) } },
drawAuthorLine = {
author?.let { (hex, name) ->
Text(name, color = authorNameColorFor(hex, isLight), fontWeight = ChatAuthorNameWeight, fontSize = ChatAuthorNameSize)
}
},
authorAvatar = if (author != null) ({ Face(faceColor) }) else null,
) { Text(text) }
}
@Composable
private fun Composer(typed: String?) {
Surface(
shape = EditFieldBorder,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 14.dp, end = 4.dp)) {
Icon(MaterialSymbols.AddPhotoAlternate, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText)
Spacer(Modifier.width(12.dp))
Text(
typed ?: "Message",
color = if (typed != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.weight(1f),
)
ThinSendButton(isActive = typed != null, modifier = Modifier) {}
}
}
}
@Composable
private fun SampleGroupChat() {
Column {
Header()
ChatDivisor("Today")
ChatSystemCaption("You renamed the chat to “NosFabrica Product”")
Bubble("Renamed the chat to “NosFabrica Product”", mine = true, position = ChatGroupPosition.TOP)
Bubble("Ok, this is working way better than I expected", mine = true, position = ChatGroupPosition.BOTTOM, time = "8:38", fire = true)
Bubble("A cool goal of this is to delete messages from unknown users automatically", mine = true, time = "8:49")
Bubble(
"to prevent random users or bots out of network from sliding in DMs?",
mine = false,
time = "8:55",
author = OUTPOST to "Relay Outpost",
faceColor = Color(0xFF5A5A5A),
)
Bubble("Delete the past scammers", mine = true, time = "8:59", fire = true)
Bubble(
"Big fat red alert next to the accounts that need it.",
mine = false,
position = ChatGroupPosition.TOP,
author = DAVID to "david",
faceColor = Color(0xFFD9825B),
)
Bubble(
"Unlike every other nostr app that give you no useful warnings at all.",
mine = false,
position = ChatGroupPosition.BOTTOM,
time = "9:08",
author = DAVID to "david",
faceColor = Color(0xFFD9825B),
)
Bubble("But also, send a message to the relays that store DMs to delete those Giftwraps.", mine = true, time = "9:09")
Spacer(Modifier.size(8.dp))
Composer(typed = null)
Composer(typed = "Sounds good")
}
}
@@ -0,0 +1,240 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.public_chat
import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowAvatarSize
import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowPictureModifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import org.jetbrains.skia.EncodedImageFormat
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Renders a Messages list offscreen (no device, no display) through `ImageComposeScene`, in the dark
* and light themes side by side, and writes it to `commonsUI/build/messages-list/messages.png`.
*
* The rows go through the real [ChannelName] / `ChatHeaderLayout` / title / label / preview-cleanup
* code; only the pictures are stand-ins (flat discs), because a real one needs the network and an
* account. It exists so the list's visual weight can be looked at while it is tuned, and it fails if
* the list throws or draws nothing.
*/
class MessagesListRenderTest {
private val outDir = File("build/messages-list").apply { mkdirs() }
@Test
fun messages() {
val density = 2f
val widthDp = 840
val heightDp = 760
val scene =
ImageComposeScene(
width = (widthDp * density).toInt(),
height = (heightDp * density).toInt(),
density = Density(density),
) { ThemeComparisonRow { SampleMessagesList() } }
try {
var image = scene.render(0)
repeat(SETTLE_FRAMES) { frame ->
Thread.sleep(FRAME_MILLIS)
image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L)
}
val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode")
File(outDir, "messages.png").writeBytes(png.bytes)
val pixels = image.peekPixels() ?: error("no pixels")
val distinct = HashSet<Int>()
for (y in 0 until image.height step 7) for (x in 0 until image.width step 7) distinct += pixels.getColor(x, y)
assertTrue(distinct.size > 20, "the list rendered almost nothing (${distinct.size} colours)")
} finally {
scene.close()
}
}
private companion object {
const val SETTLE_FRAMES = 12
const val FRAME_MILLIS = 60L
}
}
private val Fabrica = Color(0xFF6D8B74)
private val Sunset = Color(0xFFD9825B)
private val Ocean = Color(0xFF4F86C6)
private val Forest = Color(0xFF55745A)
private val Buzz = Color(0xFFE6195E)
private val Soapbox = Color(0xFFB400E0)
private val Amethyst = Color(0xFFF2F2F2)
private val Robot = Color(0xFF3E6E8E)
private val Bender = Color(0xFF3C8C73)
@Composable
private fun Disc(color: Color) {
Box(ChatRowPictureModifier.background(color))
}
/** Stand-in for [com.vitorpamplona.amethyst.commons.ui.note.ChatRoomFaces]: same geometry, flat faces. */
@Composable
private fun TwoFaces(
back: Color,
front: Color,
) {
val face = ChatRowAvatarSize * 0.68f
Box(Modifier.size(ChatRowAvatarSize)) {
Box(Modifier.align(Alignment.TopStart).size(face).background(back, CircleShape))
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.align(Alignment.BottomEnd)
.size(face + 4.dp)
.background(MaterialTheme.colorScheme.background, CircleShape),
) {
Box(Modifier.size(face).background(front, CircleShape))
}
}
}
@Composable
private fun DmTitle(
name: String,
modifier: Modifier,
pinned: Boolean = false,
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (pinned) {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.42f),
)
}
}
}
@Composable
private fun SampleMessagesList() {
val now = TimeUtils.now()
val nevent = NEvent.create("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", null, 1, relay = null)
Column {
ChannelName(
channelPicture = { TwoFaces(Fabrica, Sunset) },
channelTitle = { DmTitle("NosFabrica", it, pinned = true) },
channelLastTime = now - 57 * 60,
channelLastContent = "You: Test2",
hasNewMessages = false,
onClick = {},
)
ChannelName(
channelPicture = { TwoFaces(Ocean, Forest) },
channelTitle = { DmTitle("Minced, greenart7c3, Dee, KotlinG", it, pinned = true) },
channelLastTime = now - 2 * 24 * 3600,
channelLastContent = "You: testing group message from armada",
hasNewMessages = false,
onClick = {},
)
ChannelName(
channelPicture = { Disc(Buzz) },
channelTitle = { ChannelTitleWithLabelInfo("buzz-fork", MaterialSymbols.Dns, "buzz.relay.tools", it) },
channelLastTime = now - 2 * 60,
channelLastContent = "buzz-watcher: [github block/buzz@main] `a3870e1` **fix** the relay reconnect loop",
hasNewMessages = true,
onClick = {},
)
ChannelName(
channelPicture = { Disc(Robot) },
channelTitle = { DmTitle("Test - npub18aj6sjrr…jsmpnxpv", it) },
channelLastTime = now - 22 * 60,
channelLastContent = "You: https://nostr.download/ee078c63eab4f77eb2c1a9.png",
hasNewMessages = false,
onClick = {},
)
ChannelName(
channelPicture = { Disc(Amethyst) },
channelTitle = { ChannelTitleWithLabelInfo("Amethyst Users", MaterialSymbols.Public, Res.string.public_chat, it) },
channelLastTime = now - 3600,
channelLastContent = "OK3E: nostr:$nevent",
hasNewMessages = false,
onClick = {},
)
ChannelName(
channelPicture = { Disc(Soapbox) },
channelTitle = { ChannelTitleWithLabelInfo("armada", MaterialSymbols.Group, "Soapbox Community", it) },
channelLastTime = now - 3600,
channelLastContent = "Dino Dini: i found also some kind of way to post gifs from the app",
hasNewMessages = false,
onClick = {},
)
ChannelName(
channelPicture = { Disc(Soapbox) },
channelTitle = { ChannelTitleWithLabelInfo("general", MaterialSymbols.Group, "Soapbox Community", it) },
channelLastTime = now - 8 * 3600,
channelLastContent = "nycta: !meme",
hasNewMessages = true,
onClick = {},
)
ChannelName(
channelPicture = { Disc(Bender) },
channelTitle = { ChannelTitleWithLabelInfo("Nostr", MaterialSymbols.Public, Res.string.public_chat, it) },
channelLastTime = now - 12 * 3600,
channelLastContent = "J Smith: In my surroundings, there is one elderly neighbour who still posts",
hasNewMessages = false,
onClick = {},
)
ChannelName(
channelIdHex = "9q8yy",
channelPicture = null,
channelTitle = { ChannelTitleWithLabelInfo("#9q8yy", MaterialSymbols.LocationOn, "San Francisco", it) },
channelLastTime = now - 14 * 3600,
channelLastContent = "anon: anyone at the meetup tonight?",
hasNewMessages = false,
loadProfilePicture = false,
loadRobohash = false,
autoPlayGif = false,
onClick = {},
fallbackSymbol = MaterialSymbols.LocationOn,
)
}
}