mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(messages): calm the Messages list down
The list styled every row as if it were the most important one: bold on every title, purple-filled type pills, 55dp saturated avatars with trust scores on every collage face, a divider under every row and accent on all the chrome. The one signal that matters, unread, was a 10dp dot. - Unread drives emphasis: ChatHeaderLayout takes hasNewMessages and owns the title weight (Medium read / Bold unread), the preview color (gray read / onSurface unread), the accent timestamp and the dot. - Room-kind pills become a muted glyph after the name; names that tell rooms apart (relay host, Concord community, cordn coordinator) become an unfilled muted ChatRowLabel. The filled chips stay in Notifications. - 48dp avatars, 16/12dp row padding, no row dividers, bottom padding so the FAB no longer covers the last row, no leading dot on the time. - Group rooms show at most two overlapping faces without badges (ChatRoomFaces); picture-less rooms get a tonal disc with their kind glyph instead of a robohash. - Previews go through ChatPreviewText.tidy: nostr: mentions become @Name, nevent/note/naddr become "Shared a note", image/video links become Photo/Video, other links their host, markdown is flattened. - Chrome: Messages tabs use a label-width indicator and gray unselected tab; bottom bar selection pill is a neutral wash; favorite-app favicons are grayscale until selected. - MessagesListRenderTest renders the list offscreen (dark | light) to commonsUI/build/messages-list/messages.png. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0113Y3NP1fRnbQJyrH6D6YQj
This commit is contained in:
+1
-4
@@ -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)
|
||||
|
||||
+7
-2
@@ -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) } },
|
||||
)
|
||||
}
|
||||
|
||||
+132
@@ -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
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.privateChats
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ChatPreviewTextTest {
|
||||
private val labels = ChatPreviewLabels(photo = "Photo", video = "Video", note = "Note")
|
||||
private val hex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
private val names = mapOf(hex to "Vitor")
|
||||
|
||||
private fun tidy(text: String) = ChatPreviewText.tidy(text, labels) { names[it] }
|
||||
|
||||
@Test
|
||||
fun plainTextIsUntouched() {
|
||||
assertEquals("hello there", tidy("hello there"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun collapsesLineBreaksIntoOneLine() {
|
||||
assertEquals("first line second line", tidy("first line\n\n second line"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventReferenceBecomesTheNoteLabel() {
|
||||
val nevent = NEvent.create(hex, hex, 1, relay = null)
|
||||
assertEquals("Note", tidy("nostr:$nevent"))
|
||||
assertEquals("look: Note", tidy("look: nostr:${NNote.create(hex)}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userMentionBecomesTheName() {
|
||||
assertEquals("hi @Vitor!", tidy("hi nostr:${NPub.create(hex)}!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownUserMentionIsShortened() {
|
||||
val other = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
|
||||
val npub = NPub.create(other)
|
||||
assertEquals("@${npub.take(10)}…${npub.takeLast(4)}", tidy("nostr:$npub"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageAndVideoLinksBecomeLabels() {
|
||||
assertEquals("Photo", tidy("https://nostr.download/ee078c63eab4f77eb1234.jpg"))
|
||||
assertEquals("watch Video.", tidy("watch https://example.com/clip.mp4."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherLinksBecomeTheirHost() {
|
||||
assertEquals("see github.com", tidy("see https://www.github.com/vitorpamplona/amethyst/pull/1"))
|
||||
assertEquals("nostr.download", tidy("https://nostr.download/ee078c63eab4f77eb1234"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markdownIsFlattened() {
|
||||
assertEquals("[github block/buzz@main] a3870 fix the build", tidy("[github block/buzz@main] `a3870` **fix** the build"))
|
||||
assertEquals("the docs here", tidy("the [docs](https://example.com/docs) here"))
|
||||
assertEquals("Title quoted", tidy("## Title\n> quoted"))
|
||||
}
|
||||
}
|
||||
@@ -407,6 +407,9 @@
|
||||
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
|
||||
<string name="chat_preview_decrypting">Decrypting…</string>
|
||||
<string name="chat_preview_you_prefix">You: %1$s</string>
|
||||
<string name="chat_preview_photo">Photo</string>
|
||||
<string name="chat_preview_video">Video</string>
|
||||
<string name="chat_preview_note">Shared a note</string>
|
||||
<string name="group_picture">Group Picture</string>
|
||||
<string name="explicit_content">Explicit Content</string>
|
||||
<string name="relay_notice">Relay Notice</string>
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*/
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+52
-14
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -21,12 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.commons.ui.note
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -221,6 +223,66 @@ fun ClickableUserPicture(
|
||||
BaseUserPicture(baseUserHex, size, accountViewModel, modifier, myModifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* The faces of a Messages-list room. A 1:1 room shows its counterpart with the usual follow and
|
||||
* trust badges. A group shows at most two faces, overlapped, and no badges: at list size a 2x2
|
||||
* mosaic leaves each face ~24dp, and a trust score plus a follow shield on every one of them is
|
||||
* noise nobody can read.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatRoomFaces(
|
||||
userHexList: List<HexKey>,
|
||||
size: Dp,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
when (userHexList.size) {
|
||||
0 -> {}
|
||||
|
||||
1 -> {
|
||||
LoadUser(baseUserHex = userHexList.first()) {
|
||||
it?.let { BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier.size(size)) }
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val face = size * 0.68f
|
||||
val ring = 2.dp
|
||||
Box(Modifier.size(size)) {
|
||||
Box(Modifier.align(Alignment.TopStart)) {
|
||||
BadgelessUserPicture(userHexList[0], face, accountViewModel)
|
||||
}
|
||||
// The front face sits in a disc of the background color, which cuts a clean ring
|
||||
// out of the face behind it instead of letting the two pictures smear together.
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(face + ring * 2)
|
||||
.background(MaterialTheme.colorScheme.background, CircleShape),
|
||||
) {
|
||||
BadgelessUserPicture(userHexList[1], face, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgelessUserPicture(
|
||||
userHex: HexKey,
|
||||
size: Dp,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
LoadUser(userHex) {
|
||||
if (it != null) {
|
||||
ObserveAndDrawInnerUserPicture(it, size, accountViewModel)
|
||||
} else {
|
||||
InnerUserPicture(userHex = userHex, userPicture = null, userName = null, size = size, modifier = Modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NonClickableUserPictures(
|
||||
room: ChatroomKey,
|
||||
|
||||
+9
-7
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+125
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+159
-129
@@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -47,7 +46,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.chats.publicChannels.concord.concordCommunityHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.commons.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.commons.chats.rooms.rowHasUnread
|
||||
import com.vitorpamplona.amethyst.commons.concord.ui.ConcordCommunityPill
|
||||
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
@@ -74,9 +72,10 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreview
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreviewLabels
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreviewText
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.chatPreviewOf
|
||||
import com.vitorpamplona.amethyst.commons.model.relayGroupChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayNameChip
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteHasEvent
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderByParentFilterAssemblerSubscription
|
||||
@@ -91,6 +90,9 @@ import com.vitorpamplona.amethyst.commons.resources.channel_invite_leave
|
||||
import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you
|
||||
import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you_by
|
||||
import com.vitorpamplona.amethyst.commons.resources.chat_preview_decrypting
|
||||
import com.vitorpamplona.amethyst.commons.resources.chat_preview_note
|
||||
import com.vitorpamplona.amethyst.commons.resources.chat_preview_photo
|
||||
import com.vitorpamplona.amethyst.commons.resources.chat_preview_video
|
||||
import com.vitorpamplona.amethyst.commons.resources.chat_preview_you_prefix
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_from
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_label
|
||||
@@ -121,17 +123,16 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_server_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
|
||||
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
|
||||
import com.vitorpamplona.amethyst.commons.resources.unpin_conversation
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.chatRowTitleWeight
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ChatRoomFaces
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.LoadDecryptedContentOrNull
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.LoadPublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ObserveDraftEvent
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
|
||||
@@ -151,11 +152,9 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.AccountPictureModifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatLabelMaxWidth
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowAvatarSize
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowPictureModifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
@@ -417,6 +416,7 @@ private fun ChannelRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Public,
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
onLongClick = { menuOpen = true },
|
||||
)
|
||||
@@ -477,6 +477,7 @@ private fun ChannelRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Timer,
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
)
|
||||
}
|
||||
@@ -512,6 +513,7 @@ private fun GeohashRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.LocationOn,
|
||||
onClick = { nav.nav(Route.GeohashChat(geohashChannel.geohash)) },
|
||||
)
|
||||
}
|
||||
@@ -543,7 +545,7 @@ private fun MarmotGroupRoomCompose(
|
||||
// An unnamed group without an avatar (White Noise's 1:1 chats) is about its people:
|
||||
// show them, as a NIP-17 room does, instead of a relay icon.
|
||||
ChannelName(
|
||||
channelPicture = { NonClickableUserPictures(userHexList = otherMembers, size = Size55dp, accountViewModel = accountViewModel) },
|
||||
channelPicture = { ChatRoomFaces(userHexList = otherMembers, size = ChatRowAvatarSize, accountViewModel = accountViewModel) },
|
||||
channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, MaterialSymbols.Lock, Res.string.marmot_group, modifier) },
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
@@ -577,6 +579,7 @@ private fun MarmotGroupRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Lock,
|
||||
onClick = { nav.nav(Route.MarmotGroupChat(chatroom.nostrGroupId)) },
|
||||
)
|
||||
}
|
||||
@@ -681,6 +684,7 @@ internal fun CordnGroupRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Groups,
|
||||
onClick = { nav.nav(Route.CordnGroupChat(chatroom.coordinatorPubKey, chatroom.gid)) },
|
||||
)
|
||||
}
|
||||
@@ -789,20 +793,15 @@ fun RelayGroupRow(
|
||||
channelIdHex = channel.groupId.id,
|
||||
channelPicture = channelPicture,
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
RelayNameChip(
|
||||
label = channel.groupId.relayUrl.displayUrl(),
|
||||
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
}
|
||||
// A group id is only unique within its host relay, so the relay is part of the
|
||||
// room's identity and is spelled out; tapping it opens that relay's groups.
|
||||
ChannelTitleWithLabelInfo(
|
||||
channelName = channel.toBestDisplayName(),
|
||||
labelIcon = MaterialSymbols.Dns,
|
||||
labelText = channel.groupId.relayUrl.displayUrl(),
|
||||
modifier = modifier,
|
||||
onLabelClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
},
|
||||
channelLastTime = lastTime,
|
||||
channelLastContent = lastContent,
|
||||
@@ -821,6 +820,7 @@ fun RelayGroupRow(
|
||||
nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url))
|
||||
}
|
||||
},
|
||||
fallbackSymbol = MaterialSymbols.Groups,
|
||||
onLongClick = { menuOpen = true },
|
||||
)
|
||||
|
||||
@@ -957,22 +957,20 @@ private fun ConcordRoomCompose(
|
||||
channelIdHex = channel.channelId.channelId,
|
||||
channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel),
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
val communityName = channel.communityName
|
||||
if (communityName != null) {
|
||||
// The label names the parent community and, when tapped, opens that community's
|
||||
// channel list — the "chip that opens the Concord Channel" entry point.
|
||||
ChannelTitleWithLabelInfo(
|
||||
channelName = channel.toBestDisplayName(),
|
||||
labelIcon = MaterialSymbols.Group,
|
||||
labelText = communityName,
|
||||
modifier = modifier,
|
||||
onLabelClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
|
||||
)
|
||||
channel.communityName?.let { communityName ->
|
||||
Spacer(Modifier.width(6.dp))
|
||||
// The chip names the parent community and, when tapped, opens that community's
|
||||
// channel list — the "chip that opens the Concord Channel" entry point.
|
||||
ConcordCommunityPill(
|
||||
communityName = communityName,
|
||||
onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
|
||||
)
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
ChannelTitleText(channel.toBestDisplayName())
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -985,6 +983,7 @@ private fun ConcordRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Group,
|
||||
onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) },
|
||||
onLongClick = { menuOpen = true },
|
||||
)
|
||||
@@ -1042,6 +1041,7 @@ private fun RelayGroupServerRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Dns,
|
||||
onClick = { nav.nav(Route.RelayGroupServer(relay.url)) },
|
||||
)
|
||||
}
|
||||
@@ -1079,6 +1079,7 @@ private fun ConcordDirectInviteRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Group,
|
||||
onClick = { nav.nav(Route.Concords) },
|
||||
)
|
||||
}
|
||||
@@ -1132,56 +1133,71 @@ private fun ConcordServerRoomCompose(
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
fallbackSymbol = MaterialSymbols.Group,
|
||||
onClick = { nav.nav(Route.ConcordServer(row.communityId)) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Messages row title as the channel name followed by a muted [HeaderPill] naming the room
|
||||
* type (Public Chat, Marmot Group, ...). The pill mirrors the Concord community chip so every group
|
||||
* kind reads the same way across the screen: bold name, then a faint rounded chip with a type icon
|
||||
* and short label. The name yields space to the chip so a long title can't crowd it out.
|
||||
* Renders a Messages row title as the channel name followed by the room-kind glyph (globe for a
|
||||
* Public Chat, lock for a Marmot Group, ...). The kind's word is the glyph's content description:
|
||||
* spelled out in a chip on every row it competed with the room name for attention. The title takes
|
||||
* its weight from [ChatHeaderLayout] (bold only when unread), so none is set here.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelTitleWithLabelInfo(
|
||||
internal fun ChannelTitleWithLabelInfo(
|
||||
channelName: String,
|
||||
labelIcon: MaterialSymbol,
|
||||
label: StringResource,
|
||||
modifier: Modifier,
|
||||
labelContentDescription: String? = null,
|
||||
) = ChannelTitleWithLabelInfo(channelName, labelIcon, stringRes(id = label), modifier, labelContentDescription)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
ChannelTitleText(channelName)
|
||||
Spacer(TitleLabelGap)
|
||||
ChatRowTypeIcon(labelIcon, labelContentDescription ?: stringRes(id = label))
|
||||
}
|
||||
}
|
||||
|
||||
private val TitleLabelGap = Modifier.width(6.dp)
|
||||
|
||||
@Composable
|
||||
private fun RowScope.ChannelTitleText(channelName: String) {
|
||||
Text(
|
||||
text = channelName,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* As above, for a pill whose text is a name rather than a fixed word.
|
||||
* As above, for a label that is a name rather than a fixed word, so it is spelled out as a muted
|
||||
* [ChatRowLabel]: the relay a NIP-29 group lives on, the Concord community a channel belongs to.
|
||||
*
|
||||
* A cordn room's pill carries its coordinator, which is a value and not a
|
||||
* A cordn room's label carries its coordinator, which is a value and not a
|
||||
* string resource: the coordinator is the one server that carries every message
|
||||
* in that group, so which one it is tells a reader more than being told twice
|
||||
* that this is a cordn chat.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelTitleWithLabelInfo(
|
||||
internal fun ChannelTitleWithLabelInfo(
|
||||
channelName: String,
|
||||
labelIcon: MaterialSymbol,
|
||||
labelText: String,
|
||||
modifier: Modifier,
|
||||
labelContentDescription: String? = null,
|
||||
onLabelClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channelName,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
HeaderPill(
|
||||
ChannelTitleText(channelName)
|
||||
Spacer(TitleLabelGap)
|
||||
ChatRowLabel(
|
||||
symbol = labelIcon,
|
||||
text = labelText,
|
||||
modifier = Modifier.widthIn(max = ChatLabelMaxWidth),
|
||||
contentDescription = labelContentDescription,
|
||||
onClick = onLabelClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1224,25 +1240,36 @@ private fun UserRoomCompose(
|
||||
// menu-text slots, confining pin-toggle invalidations to those scopes.
|
||||
val pinnedRooms = accountViewModel.pinnedChatroomsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
// A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
|
||||
val newestEvent = lastMessage.event
|
||||
val countsAsRead =
|
||||
!isDraft &&
|
||||
newestEvent != null &&
|
||||
chatMessageMarksRoomAsRead(newestEvent, room, accountViewModel.account.signer.pubKey)
|
||||
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow(privateChatLastReadRoute(room)).collectAsStateWithLifecycle()
|
||||
val hasNewMessages = !countsAsRead && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime
|
||||
|
||||
ChatHeaderLayout(
|
||||
channelPicture = {
|
||||
NonClickableUserPictures(
|
||||
room = room,
|
||||
ChatRoomFaces(
|
||||
userHexList = remember(room) { room.users.toList() },
|
||||
size = ChatRowAvatarSize,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size55dp,
|
||||
)
|
||||
},
|
||||
firstRow = {
|
||||
val titleWeight = chatRowTitleWeight(hasNewMessages)
|
||||
val counterpartHex = room.users.singleOrNull()
|
||||
if (counterpartHex != null) {
|
||||
// 1:1 room: resolve the counterpart once and share it between the name and the
|
||||
// report-warning icon below, instead of each doing its own LoadUser.
|
||||
LoadUser(baseUserHex = counterpartHex) { counterpart ->
|
||||
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = counterpart)
|
||||
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = counterpart, fontWeight = titleWeight)
|
||||
RoomReportWarningIcon(counterpart, accountViewModel)
|
||||
}
|
||||
} else {
|
||||
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = null)
|
||||
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel, preloadedUser = null, fontWeight = titleWeight)
|
||||
}
|
||||
if (room in pinnedRooms.value) {
|
||||
Icon(
|
||||
@@ -1251,28 +1278,15 @@ private fun UserRoomCompose(
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
TimeAgo(lastMessage.createdAt())
|
||||
TimeAgo(lastMessage.createdAt(), hasNewMessages)
|
||||
},
|
||||
secondRow = {
|
||||
LastMessagePreview(lastMessage, accountViewModel)
|
||||
|
||||
// A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
|
||||
val newestEvent = lastMessage.event
|
||||
val countsAsRead =
|
||||
!isDraft &&
|
||||
newestEvent != null &&
|
||||
chatMessageMarksRoomAsRead(newestEvent, room, accountViewModel.account.signer.pubKey)
|
||||
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow(privateChatLastReadRoute(room)).collectAsStateWithLifecycle()
|
||||
if (!countsAsRead && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) {
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
NewItemsBubble()
|
||||
}
|
||||
},
|
||||
onClick = { nav.nav(Route.Room(room)) },
|
||||
onLongClick = { popupExpanded = true },
|
||||
hasNewMessages = hasNewMessages,
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
@@ -1330,14 +1344,7 @@ private fun RowScope.LastMessagePreview(
|
||||
ChatPreview.Missing -> stringRes(Res.string.referenced_event_not_found)
|
||||
}
|
||||
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
PreviewLine(rememberTidyPreview(text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,18 +1361,25 @@ fun ChannelName(
|
||||
autoPlayGif: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
// A room with no picture of its own shows this glyph on a tonal disc instead of a robohash.
|
||||
// Null keeps the robohash (search results, where a robot face is the established look).
|
||||
fallbackSymbol: MaterialSymbol? = null,
|
||||
) {
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = channelIdHex,
|
||||
model = channelPicture,
|
||||
contentDescription = stringRes(Res.string.channel_image),
|
||||
modifier = AccountPictureModifier,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
loadRobohash = loadRobohash,
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
if (fallbackSymbol != null && channelPicture.isNullOrBlank()) {
|
||||
ChatRowTonalAvatar(fallbackSymbol)
|
||||
} else {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = channelIdHex,
|
||||
model = channelPicture,
|
||||
contentDescription = stringRes(Res.string.channel_image),
|
||||
modifier = ChatRowPictureModifier,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
loadRobohash = loadRobohash,
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
}
|
||||
},
|
||||
channelTitle,
|
||||
channelLastTime,
|
||||
@@ -1390,45 +1404,61 @@ fun ChannelName(
|
||||
channelPicture = channelPicture,
|
||||
firstRow = {
|
||||
channelTitle(Modifier.weight(1f))
|
||||
TimeAgo(channelLastTime)
|
||||
TimeAgo(channelLastTime, hasNewMessages)
|
||||
},
|
||||
secondRow = {
|
||||
if (channelLastContent != null) {
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringRes(Res.string.referenced_event_not_found),
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
if (hasNewMessages) {
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
NewItemsBubble()
|
||||
}
|
||||
PreviewLine(channelLastContent?.let { rememberTidyPreview(it) } ?: stringRes(Res.string.referenced_event_not_found))
|
||||
},
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
hasNewMessages = hasNewMessages,
|
||||
)
|
||||
}
|
||||
|
||||
/** The preview line of a row. Its color comes from [ChatHeaderLayout], which lifts it when unread. */
|
||||
@Composable
|
||||
private fun TimeAgo(channelLastTime: Long?) {
|
||||
if (channelLastTime == null) return
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = channelLastTime,
|
||||
style = TimeAgoStyle.Dotted,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
private fun RowScope.PreviewLine(text: String) {
|
||||
Text(
|
||||
text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [text] as a preview line should say it: see [ChatPreviewText.tidy]. Keyed on the text, so a row
|
||||
* re-tidies only when its newest message changes.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberTidyPreview(text: String): String {
|
||||
val labels =
|
||||
ChatPreviewLabels(
|
||||
photo = stringRes(Res.string.chat_preview_photo),
|
||||
video = stringRes(Res.string.chat_preview_video),
|
||||
note = stringRes(Res.string.chat_preview_note),
|
||||
)
|
||||
return remember(text, labels) {
|
||||
ChatPreviewText.tidy(text, labels) { hex -> LocalCache.getUserIfExists(hex)?.toBestDisplayName() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeAgo(
|
||||
channelLastTime: Long?,
|
||||
hasNewMessages: Boolean,
|
||||
) {
|
||||
if (channelLastTime == null) return
|
||||
// No leading "•": the time sits alone at the end of the title line, so there is nothing to
|
||||
// separate it from. It takes the accent only when the row is unread, beside the dot below it.
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = channelLastTime,
|
||||
style = TimeAgoStyle.Short,
|
||||
color = if (hasNewMessages) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
modifier = TimeAgoGap,
|
||||
)
|
||||
}
|
||||
|
||||
private val TimeAgoGap = Modifier.padding(start = 8.dp)
|
||||
|
||||
+4
-6
@@ -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.
|
||||
|
||||
+18
-4
@@ -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)
|
||||
|
||||
+240
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user