mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #3582 from vitorpamplona/claude/channel-list-enhancements-wktw8t
Concord: richer channel rows — previews, unread counts, facepiles, live typing
This commit is contained in:
@@ -790,7 +790,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
// permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message)
|
||||
// is already handled by the normal deletion cascade unlinking the note from its gatherers.
|
||||
messageRow?.let { (ch, note) ->
|
||||
if (note.event == null) ch.removeNote(note)
|
||||
if (note.event == null) {
|
||||
ch.removeNote(note)
|
||||
} else {
|
||||
// The row was attached (addNote) BEFORE justConsume set the event, so addNote saw a
|
||||
// null createdAt and could not pick lastNote or order the feed. The event is loaded
|
||||
// now — refresh so the channel's last-message preview, unread count, and ordering are
|
||||
// correct (otherwise lastNote stays null forever and every row reads "No messages yet").
|
||||
ch.refreshAfterEventLoad(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+210
-21
@@ -47,7 +47,9 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -56,20 +58,31 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||
|
||||
@@ -94,6 +107,21 @@ fun ConcordChannelListScreen(
|
||||
val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) })
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Live typing heartbeats (kind 23311) per channel, so a channel where someone is composing shows
|
||||
// "typing…" in place of its last-message preview. A single screen-level ticker re-applies the
|
||||
// freshness window for every row at once, and only spins while at least one heartbeat is live.
|
||||
val typingMap by (session?.typing ?: remember { kotlinx.coroutines.flow.MutableStateFlow(emptyMap<HexKey, Map<HexKey, Long>>()) })
|
||||
.collectAsStateWithLifecycle()
|
||||
var typingNow by remember { mutableLongStateOf(TimeUtils.now()) }
|
||||
LaunchedEffect(typingMap) {
|
||||
if (typingMap.values.all { it.isEmpty() }) return@LaunchedEffect
|
||||
while (true) {
|
||||
typingNow = TimeUtils.now()
|
||||
if (typingMap.values.all { per -> per.values.none { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } }) break
|
||||
delay(2000L)
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var inviteLink by remember { mutableStateOf<String?>(null) }
|
||||
var minting by remember { mutableStateOf(false) }
|
||||
@@ -254,28 +282,26 @@ fun ConcordChannelListScreen(
|
||||
def.private == true -> MaterialSymbols.Lock
|
||||
else -> MaterialSymbols.Tag
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
|
||||
.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SymbolIcon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1)
|
||||
if (canManageChannels) {
|
||||
ConcordChannelRowMenu(
|
||||
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
)
|
||||
val typingAuthors =
|
||||
remember(typingMap, typingNow, entry.key) {
|
||||
(typingMap[entry.key] ?: emptyMap())
|
||||
.filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS }
|
||||
.keys
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
ConcordChannelListRow(
|
||||
communityId = communityId,
|
||||
channelKey = entry.key,
|
||||
channelName = name,
|
||||
icon = icon,
|
||||
isVoice = def.voice == true,
|
||||
typingAuthors = typingAuthors,
|
||||
canManageChannels = canManageChannels,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Concord(communityId, entry.key)) },
|
||||
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
)
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
@@ -283,6 +309,169 @@ fun ConcordChannelListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One channel row in the community's server view: its icon, name, a preview of the last message
|
||||
* (author + snippet), the relative time of that message, and an unread-message count badge — plus
|
||||
* the manager-only overflow menu. Name/icon come from the folded Control Plane definition; the
|
||||
* preview, time, and unread count are read reactively from the channel's own message store so they
|
||||
* fill in the moment messages fold in and clear as soon as the channel is opened (last-read advances).
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordChannelListRow(
|
||||
communityId: String,
|
||||
channelKey: String,
|
||||
channelName: String,
|
||||
icon: MaterialSymbol,
|
||||
isVoice: Boolean,
|
||||
typingAuthors: List<HexKey>,
|
||||
canManageChannels: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
// getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note
|
||||
// yet; its notes flow then makes the preview/time/unread/facepile reactive as messages arrive.
|
||||
val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) }
|
||||
val channelState by channel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
// The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and
|
||||
// hidden authors so the preview + time match the channel feed and the unread badge below.
|
||||
val lastNote = remember(channelState) { channel.newestTimelineNote(account) }
|
||||
val unread by
|
||||
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
val hasUnread = unread > 0
|
||||
// The recent posters' faces — recomputed as the channel's notes change (keyed on channelState).
|
||||
val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) }
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = if (canManageChannels) 4.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SymbolIcon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// Line 1: channel name + the recent-posters facepile pushed to the right.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
channelName,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ConcordAuthorFacepile(faceAuthors, accountViewModel)
|
||||
}
|
||||
// Line 2: the last-message preview (or a live "typing…"), then the time + unread badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
|
||||
}
|
||||
lastNote?.createdAt()?.let { ts ->
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
}
|
||||
}
|
||||
if (canManageChannels) {
|
||||
ConcordChannelRowMenu(
|
||||
onRename = onRename,
|
||||
onDelete = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How many recent-poster avatars a channel row's facepile shows at most. */
|
||||
private const val FACEPILE_MAX = 4
|
||||
|
||||
/**
|
||||
* The line under a channel name. When someone is composing it shows a live italic "X is typing…";
|
||||
* otherwise the last message's author + a snippet ("author: hello"), or a muted "No messages yet"
|
||||
* placeholder before anything has folded in. Author names resolve reactively (hex → profile name).
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordChannelPreviewLine(
|
||||
lastNote: Note?,
|
||||
isVoice: Boolean,
|
||||
typingAuthors: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (typingAuthors.isNotEmpty()) {
|
||||
val label =
|
||||
when (typingAuthors.size) {
|
||||
1 -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_one, rememberConcordDisplayName(typingAuthors[0], accountViewModel))
|
||||
2 ->
|
||||
stringRes(
|
||||
com.vitorpamplona.amethyst.R.string.concord_typing_two,
|
||||
rememberConcordDisplayName(typingAuthors[0], accountViewModel),
|
||||
rememberConcordDisplayName(typingAuthors[1], accountViewModel),
|
||||
)
|
||||
else -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_many)
|
||||
}
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val event = lastNote?.event
|
||||
val author = lastNote?.author
|
||||
val preview: String =
|
||||
if (event != null && author != null) {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
val body = event.content.take(80)
|
||||
if (body.isBlank()) authorName else "$authorName: $body"
|
||||
} else if (event != null) {
|
||||
event.content.take(80)
|
||||
} else {
|
||||
// Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank.
|
||||
if (isVoice) return
|
||||
stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_no_messages)
|
||||
}
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
|
||||
@Composable
|
||||
private fun rememberConcordDisplayName(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String {
|
||||
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
|
||||
val name by observeUserName(user, accountViewModel)
|
||||
return name
|
||||
}
|
||||
|
||||
/** A pending channel create ([channelIdHex] null) or rename target. */
|
||||
private data class ConcordChannelEditor(
|
||||
val channelIdHex: String?,
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A horizontal stack of overlapping avatars for the recent posters in a channel — the "who's here"
|
||||
* cue that makes a busy channel feel alive. Each avatar wears a thin ring in the row's background
|
||||
* colour so the overlap reads as a deck of cards; the newest poster sits on top (leftmost, highest
|
||||
* z-index). Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordAuthorFacepile(
|
||||
authorHexes: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
avatarSize: Dp = 22.dp,
|
||||
maxShown: Int = 4,
|
||||
) {
|
||||
if (authorHexes.isEmpty()) return
|
||||
val shown = authorHexes.take(maxShown)
|
||||
// A ring one-and-a-half dp wide, and each avatar pulled left over its neighbour by a third of
|
||||
// its width — enough overlap to read as a stack without hiding faces.
|
||||
val ring = 1.5.dp
|
||||
val overlap = avatarSize * 0.36f
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(-overlap)) {
|
||||
shown.forEachIndexed { index, hex ->
|
||||
Box(
|
||||
Modifier
|
||||
// Newest (index 0) on top so it isn't clipped by the one after it.
|
||||
.zIndex((shown.size - index).toFloat())
|
||||
.size(avatarSize)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(ring),
|
||||
) {
|
||||
LoadUser(baseUserHex = hex, accountViewModel) { user ->
|
||||
if (user != null) {
|
||||
ObserveAndDrawInnerUserPicture(user, avatarSize - ring * 2, accountViewModel)
|
||||
} else {
|
||||
DisplayBlankAuthor(avatarSize - ring * 2, Modifier, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-44
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -258,10 +257,9 @@ private fun communityActivity(
|
||||
} ?: 0L
|
||||
|
||||
/**
|
||||
* The number of a community's channels with a message newer than this account last read there —
|
||||
* combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its
|
||||
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on
|
||||
* [revision] so a freshly-folded message flips the badge.
|
||||
* The total number of new messages across a community's channels — the sum, over every channel, of
|
||||
* messages newer than this account last read that channel ([concordChannelUnreadCountFlow]).
|
||||
* Recomputed reactively as messages fold in and as channels are opened (last-read advances).
|
||||
*/
|
||||
@Composable
|
||||
private fun communityUnreadCount(
|
||||
@@ -271,23 +269,14 @@ private fun communityUnreadCount(
|
||||
): Int {
|
||||
if (channelKeys.isEmpty()) return 0
|
||||
// Keyed only on the channel set (not the global revision): each per-channel flow reacts to both
|
||||
// its last-read marker AND the channel's own notes flow, so a folded message flips the badge
|
||||
// its last-read marker AND the channel's own notes flow, so a folded message updates the badge
|
||||
// without tearing down and restarting every flow on every unrelated fold (which reset the badge
|
||||
// to 0 and made it flicker).
|
||||
val flow =
|
||||
remember(communityId, channelKeys) {
|
||||
combine(
|
||||
channelKeys.map { key ->
|
||||
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key))
|
||||
combine(
|
||||
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)),
|
||||
channel.flow().notes.stateFlow,
|
||||
) { lastRead, state ->
|
||||
val last = state.channel.lastNote?.createdAt() ?: 0L
|
||||
if (last > lastRead) 1 else 0
|
||||
}
|
||||
},
|
||||
) { flags -> flags.sum() }
|
||||
channelKeys.map { key -> concordChannelUnreadCountFlow(account, communityId, key) },
|
||||
) { counts -> counts.sum() }
|
||||
}
|
||||
return flow.collectAsStateWithLifecycle(0).value
|
||||
}
|
||||
@@ -357,7 +346,7 @@ private fun CommunityHeader(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (unread > 0) UnreadBadge(unread)
|
||||
ConcordUnreadBadge(unread)
|
||||
SymbolIcon(
|
||||
// ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal").
|
||||
symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
@@ -388,25 +377,6 @@ private fun CommunityBanner(
|
||||
)
|
||||
}
|
||||
|
||||
/** A small pill showing the unread-channel count next to a community. */
|
||||
@Composable
|
||||
private fun UnreadBadge(count: Int) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One channel row with its last message (author + snippet), relative time, and an unread marker —
|
||||
* bold + a dot when there's a message newer than this account last read the channel.
|
||||
@@ -432,9 +402,13 @@ private fun ConcordChannelRow(
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val lastNote = channelState.channel.lastNote
|
||||
val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle()
|
||||
val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime
|
||||
// The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and
|
||||
// hidden authors so the preview + time match the channel feed and the unread badge below.
|
||||
val lastNote = remember(channelState) { channel.newestTimelineNote(account) }
|
||||
val unreadCount by
|
||||
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
val unread = unreadCount > 0
|
||||
|
||||
// In the UNREAD peek, a read channel simply isn't shown.
|
||||
if (hideIfRead && !unread) return
|
||||
@@ -486,12 +460,10 @@ private fun ConcordChannelRow(
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (unread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (unread) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary))
|
||||
}
|
||||
ConcordUnreadBadge(unreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
/**
|
||||
* A reactive flow of how many messages in [communityId]/[channelKey] are newer than the
|
||||
* timestamp this account last read that channel. It combines the persisted last-read marker
|
||||
* ([concordChannelLastReadRoute]) with the channel's own notes flow, so the count updates both
|
||||
* when a fresh message folds in and when the user opens the channel (which advances last-read).
|
||||
*
|
||||
* Uses `getOrCreate` (not `getIfExists`): a channel folded on the Control Plane may have no
|
||||
* message-buffer note yet, and the flow must still exist so the badge appears the moment its
|
||||
* first message lands. Counting straight off [ConcordChannel.notes] ignores the synthetic
|
||||
* placeholder note (it is never added to that cache) and any note with no timestamp.
|
||||
*/
|
||||
fun concordChannelUnreadCountFlow(
|
||||
account: Account,
|
||||
communityId: String,
|
||||
channelKey: String,
|
||||
): Flow<Int> {
|
||||
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey))
|
||||
// The notes flow drives recomputation (each new message re-emits its channel state); the count
|
||||
// itself reads the channel's own note store, so it survives the base-typed ChannelState.channel.
|
||||
return combine(
|
||||
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)),
|
||||
channel.flow().notes.stateFlow,
|
||||
) { lastRead, _ ->
|
||||
channel.newMessagesSince(account, lastRead)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a note the Concord channel *timeline* actually renders — the same predicate as
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedFilter]'s
|
||||
* `isTimelineMessage`: a loaded, acceptable message that is **not** a kind-1111 [CommentEvent].
|
||||
*
|
||||
* A [CommentEvent] is a *minichat thread reply* that lives inside its parent's thread, not on the
|
||||
* flat timeline, so it never composes on the channel screen and never advances the last-read
|
||||
* marker. Every list-row surface that summarizes a channel — the unread badge
|
||||
* ([newMessagesSince]), the last-message preview + timestamp ([newestTimelineNote]), and the
|
||||
* Messages hub row — reuses this so none of them can disagree with the open channel's feed:
|
||||
* a trailing comment can't stick the badge at a count the user can never clear, nor show up as a
|
||||
* "last message" that isn't in the timeline. Unacceptable (muted/blocked) authors are hidden for
|
||||
* the same reason.
|
||||
*/
|
||||
fun isConcordTimelineMessage(
|
||||
note: Note,
|
||||
account: Account,
|
||||
): Boolean = note.event.let { it != null && it !is CommentEvent } && account.isAcceptable(note)
|
||||
|
||||
/**
|
||||
* The newest timeline message in this channel (see [isConcordTimelineMessage]), or null if none —
|
||||
* the note the list/hub rows show as the channel's "last message". Unlike [ConcordChannel.lastNote]
|
||||
* (the raw newest note of any kind), this skips thread replies and hidden authors so the preview
|
||||
* matches what the channel feed renders and the unread badge counts.
|
||||
*/
|
||||
fun ConcordChannel.newestTimelineNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, note -> isConcordTimelineMessage(note, account) }
|
||||
.minWithOrNull(Channel.DefaultFeedOrder)
|
||||
|
||||
/** The number of this channel's timeline messages created strictly after [sinceSecs] (0 if none). */
|
||||
private fun ConcordChannel.newMessagesSince(
|
||||
account: Account,
|
||||
sinceSecs: Long,
|
||||
): Int =
|
||||
notes.count { _, note ->
|
||||
(note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the
|
||||
* facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a
|
||||
* chatty author counts once (at their newest message) rather than crowding out quieter voices.
|
||||
*/
|
||||
fun ConcordChannel.recentAuthorHexes(limit: Int): List<HexKey> {
|
||||
val latestByAuthor = HashMap<HexKey, Long>()
|
||||
for (note in notes.values()) {
|
||||
val author = note.author?.pubkeyHex ?: continue
|
||||
val at = note.createdAt() ?: continue
|
||||
val prev = latestByAuthor[author]
|
||||
if (prev == null || at > prev) latestByAuthor[author] = at
|
||||
}
|
||||
return latestByAuthor.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
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.res.pluralStringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */
|
||||
private const val CONCORD_UNREAD_CAP = 99
|
||||
|
||||
/**
|
||||
* A small pill showing an unread-message [count] (new messages since this account last read).
|
||||
* Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at
|
||||
* [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordUnreadBadge(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (count <= 0) return
|
||||
val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString()
|
||||
val description = pluralStringResource(R.plurals.concord_unread_messages, count, count)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.semantics { contentDescription = description }
|
||||
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
|
||||
// snapping — a small touch that makes new activity feel noticed.
|
||||
.animateContentSize()
|
||||
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
@@ -400,10 +401,15 @@ class ChatroomListKnownFeedFilter(
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/** The newest decrypted message loaded in this Concord channel, or null if none yet. */
|
||||
/**
|
||||
* The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet.
|
||||
* Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author)
|
||||
* isn't shown as the Messages-row "last message" — the same predicate the channel feed and the
|
||||
* unread badge use, so the row summary can't disagree with what opening the channel renders.
|
||||
*/
|
||||
private fun ConcordChannel.newestConcordNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, it -> account.isAcceptable(it) && it.event != null }
|
||||
.filter { _, it -> isConcordTimelineMessage(it, account) }
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
|
||||
@@ -341,6 +341,11 @@
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<plurals name="concord_unread_messages">
|
||||
<item quantity="one">%1$d new message</item>
|
||||
<item quantity="other">%1$d new messages</item>
|
||||
</plurals>
|
||||
<string name="concord_channel_no_messages">No messages yet</string>
|
||||
<string name="concord_create_action">Create</string>
|
||||
<string name="concord_invite_action">Invite people</string>
|
||||
<string name="concord_invite_title">Invite link</string>
|
||||
|
||||
@@ -116,6 +116,24 @@ abstract class Channel : NotesGatherer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh derived state after an already-[addNote]'d [note] finishes loading its event.
|
||||
*
|
||||
* Most channels attach a note whose event is already set, so [addNote] can pick [lastNote] and
|
||||
* order the feed right away. Concord is the exception: it attaches a message row to its channel
|
||||
* *before* the rumor is consumed (so the row already carries its gatherer when it flows through
|
||||
* the Messages filter), which means [addNote] ran while `createdAt()` was still null and could
|
||||
* neither set [lastNote] nor sort correctly. Once the event lands, call this so [lastNote] and
|
||||
* any notes-flow observers (row previews, unread counts, ordering) reflect the real message.
|
||||
*/
|
||||
fun refreshAfterEventLoad(note: Note) {
|
||||
if (!notes.containsKey(note.idHex)) return
|
||||
if ((note.createdAt() ?: 0L) > (lastNote?.createdAt() ?: 0L)) {
|
||||
lastNote = note
|
||||
}
|
||||
flowSet?.notes?.invalidateData()
|
||||
}
|
||||
|
||||
override fun removeNote(note: Note) {
|
||||
if (notes.containsKey(note.idHex)) {
|
||||
notes.remove(note.idHex)
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Regression cover for the Concord attach-before-consume ordering: `LocalCache.consumeConcordRumor`
|
||||
* attaches a message row to its [ConcordChannel] (so the note carries its gatherer through the
|
||||
* Messages filter) *before* `justConsume` loads the event, so [com.vitorpamplona.amethyst.commons.model.Channel.addNote]
|
||||
* runs while `createdAt()` is null and cannot pick [com.vitorpamplona.amethyst.commons.model.Channel.lastNote].
|
||||
* Without the [com.vitorpamplona.amethyst.commons.model.Channel.refreshAfterEventLoad] follow-up,
|
||||
* `lastNote` stays null forever and every channel row renders "No messages yet" even after messages
|
||||
* have loaded.
|
||||
*/
|
||||
class ConcordChannelLastNoteTest {
|
||||
private val community = "aa".repeat(32)
|
||||
private val channelId = "cc".repeat(32)
|
||||
private val author = "bb".repeat(32)
|
||||
|
||||
private fun channel() = ConcordChannel(ConcordChannelId(community, channelId))
|
||||
|
||||
private fun event(createdAt: Long): Event = EventFactory.create("00".repeat(32), author, createdAt, 1, arrayOf(), "hi", "22".repeat(64))
|
||||
|
||||
@Test
|
||||
fun lastNoteStaysNullUntilEventLoads_thenReflectsIt() {
|
||||
val c = channel()
|
||||
val note = Note("11".repeat(32))
|
||||
|
||||
// Attach exactly as Concord does — before the rumor is consumed, so the event is still null.
|
||||
c.addNote(note)
|
||||
assertNull(c.lastNote, "addNote can't pick lastNote while createdAt() is null (the bug)")
|
||||
|
||||
// justConsume loads the event; the follow-up refresh must now set lastNote.
|
||||
note.event = event(1000)
|
||||
c.refreshAfterEventLoad(note)
|
||||
assertEquals(note, c.lastNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshKeepsTheNewestAndIgnoresLaterOlderLoads() {
|
||||
val c = channel()
|
||||
val older = Note("11".repeat(32))
|
||||
val newer = Note("22".repeat(32))
|
||||
c.addNote(older)
|
||||
c.addNote(newer)
|
||||
|
||||
older.event = event(1000)
|
||||
c.refreshAfterEventLoad(older)
|
||||
newer.event = event(2000)
|
||||
c.refreshAfterEventLoad(newer)
|
||||
assertEquals(newer, c.lastNote)
|
||||
|
||||
// An out-of-order older message loading afterwards must not overwrite the newest.
|
||||
val evenOlder = Note("33".repeat(32))
|
||||
c.addNote(evenOlder)
|
||||
evenOlder.event = event(500)
|
||||
c.refreshAfterEventLoad(evenOlder)
|
||||
assertEquals(newer, c.lastNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshIsANoOpForANoteNotInThisChannel() {
|
||||
val c = channel()
|
||||
val stray = Note("44".repeat(32)).apply { event = event(9999) }
|
||||
c.refreshAfterEventLoad(stray)
|
||||
assertNull(c.lastNote)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user