Merge pull request #3707 from vitorpamplona/claude/message-screen-channel-rendering-f8zkvd

Extract chat room mark-as-read logic into reusable function
This commit is contained in:
Vitor Pamplona
2026-07-24 23:28:09 -04:00
committed by GitHub
5 changed files with 185 additions and 42 deletions
@@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.markRoomNoteAsRead
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
@@ -144,7 +145,6 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -159,7 +159,6 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
@@ -2166,27 +2165,10 @@ class AccountViewModel(
fun markAllChatNotesAsRead(notes: List<Note>) {
viewModelScope.launch(Dispatchers.IO) {
for (note in notes) {
val noteEvent = note.event
when {
noteEvent is IsInPublicChatChannel -> {
account.markAsRead("Channel/${noteEvent.channelId()}", noteEvent.createdAt)
}
noteEvent is ChatroomKeyable -> {
account.markAsRead(privateChatLastReadRoute(noteEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt)
}
noteEvent is DraftWrapEvent -> {
val innerEvent = account.draftsDecryptionCache.preCachedDraft(noteEvent)
if (innerEvent is IsInPublicChatChannel) {
account.markAsRead("Channel/${innerEvent.channelId()}", noteEvent.createdAt)
} else if (innerEvent is ChatroomKeyable) {
account.markAsRead(privateChatLastReadRoute(innerEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt)
}
}
}
}
// markRoomNoteAsRead resolves each row's last-read route the same way ChatroomEntry does,
// so every room kind shown on the Messages screen — public chats, DMs, NIP-29 relay groups,
// Concord, Marmot, geohash, ephemeral, and the collapsed per-server rows — is covered.
notes.forEach { markRoomNoteAsRead(account, it) }
markHiddenChatroomsAsRead()
}
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -36,27 +37,26 @@ 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.MaterialSymbols
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.ChatLabelMaxWidth
/**
* A tappable chip naming the Concord community a message belongs to. Deliberately **muted** the same
* faint wash the note-header markers use because the community's logo is now the row avatar, so the
* name only needs to read as tappable metadata, not compete with it. (The NIP-29 relay-host chip stays
* highlighted; a relay group has no avatar of its own.) Shared by the Messages row and the Notifications
* feed so a Concord message reads the same wherever it surfaces; the name is hard-capped so a long title
* can't crowd the row.
* 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
* width is capped at [ChatLabelMaxWidth] with a middle ellipsis so a long community name is truncated
* instead of crowding the room name out.
*/
@Composable
fun ConcordCommunityPill(
communityName: String,
onClick: () -> Unit,
maxChars: Int = 20,
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f),
contentColor = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -66,15 +66,15 @@ fun ConcordCommunityPill(
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.placeholderText,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(11.dp),
)
Text(
text = if (communityName.length > maxChars) communityName.take(maxChars).trimEnd() + "" else communityName,
text = communityName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.placeholderText,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -102,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServ
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.ChatLabelMaxWidth
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -683,7 +685,7 @@ private fun RelayNameChip(
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.clickable(onClick = onClick),
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -701,7 +703,7 @@ private fun RelayNameChip(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
@@ -733,6 +735,7 @@ private fun ChannelTitleWithLabelInfo(
HeaderPill(
symbol = labelIcon,
text = stringRes(id = label),
modifier = Modifier.widthIn(max = ChatLabelMaxWidth),
)
}
}
@@ -886,7 +889,7 @@ private fun RowScope.LastMessagePreview(
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f),
)
}
@@ -965,7 +968,7 @@ fun ChannelName(
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f),
)
} else {
@@ -974,6 +977,7 @@ fun ChannelName(
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f),
)
}
@@ -0,0 +1,149 @@
/*
* 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.rooms
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
/**
* Marks one Messages-list row as read by advancing the very same last-read route(s) the row's unread
* dot reads from. This MUST stay in lockstep with [ChatroomEntry]'s type dispatch: every row kind that
* can light an unread dot needs a branch here, or "mark all as read" silently skips it and its dot can
* only ever be cleared by opening the room. The resolution order mirrors [ChatroomEntry] exactly
* synthetic grouped rows, then gatherer-attached channels, then the `h`-tag fallback, then the raw
* event type so the route computed here is byte-for-byte the one each row badge collects.
*
* The two synthetic "grouped by server" rows collapse many rooms behind one aggregate dot, so they
* fan out: every joined group on the relay ([RelayGroupServerRoomNote]) / every channel in the
* community ([ConcordServerRoomNote]) is marked read up to the row's newest message. [Account.markAsRead]
* only ever advances a route, so over-reaching a quieter room to the aggregate cutoff never un-reads it.
*/
fun markRoomNoteAsRead(
account: Account,
note: Note,
) {
when (note) {
is RelayGroupServerRoomNote -> {
val cutoff = note.createdAt() ?: return
account.relayGroupList.liveRelayGroupList.value.forEach { tag ->
if (RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) == note.relay) {
account.markAsRead(relayGroupChannelLastReadRoute(GroupId(tag.groupId, note.relay)), cutoff)
}
}
return
}
is ConcordServerRoomNote -> {
val cutoff = note.createdAt() ?: return
account.concordSessions
.sessionFor(note.communityId)
?.state
?.value
?.channels
?.keys
?.forEach { channelKey ->
account.markAsRead(concordChannelLastReadRoute(note.communityId, channelKey), cutoff)
}
return
}
}
val createdAt = note.createdAt() ?: return
// Gatherer-attached channels first, in the same priority order as ChatroomEntry.
note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }?.let {
account.markAsRead(marmotGroupLastReadRoute(it.nostrGroupId), createdAt)
return
}
note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.let {
account.markAsRead(relayGroupChannelLastReadRoute(it.groupId), createdAt)
return
}
note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let {
account.markAsRead(concordChannelLastReadRoute(it.channelId.communityId, it.channelId.channelId), createdAt)
return
}
note.inGatherers?.firstNotNullOfOrNull { it as? GeohashChatChannel }?.let {
account.markAsRead("Geohash/${it.geohash}", createdAt)
return
}
// A NIP-29 group message whose channel gatherer never attached: resolve the group from its `h` tag
// + provenance relay, exactly like ChatroomEntry's fallback row.
val groupScopedEvent = note.event?.takeIf { it.isGroupScoped() }
if (groupScopedEvent != null) {
val gid = groupScopedEvent.groupId()
val hostRelay = note.relays.firstOrNull()
if (gid != null && hostRelay != null) {
account.markAsRead(relayGroupChannelLastReadRoute(GroupId(gid, hostRelay)), createdAt)
return
}
}
markEventRoomAsRead(account, note.event, createdAt)
}
/**
* Marks the room of a plain (non-gathered) chat event read: public chats (NIP-28), ephemeral relay
* chats, and 1:1/group DMs (NIP-17/04), unwrapping a [DraftWrapEvent] to the same three cases. Drafts
* for group-scoped rooms are already caught by the gatherer checks above, so they don't recur here.
*/
private fun markEventRoomAsRead(
account: Account,
event: Event?,
createdAt: Long,
) {
when (event) {
is IsInPublicChatChannel -> event.channelId()?.let { account.markAsRead("Channel/$it", createdAt) }
is ChannelCreateEvent -> account.markAsRead("Channel/${event.id}", createdAt)
is EphemeralChatEvent -> event.roomId()?.let { account.markAsRead("Channel/${it.toKey()}", createdAt) }
is ChatroomKeyable -> account.markAsRead(privateChatLastReadRoute(event.chatroomKey(account.signer.pubKey)), createdAt)
is DraftWrapEvent -> {
when (val inner = account.draftsDecryptionCache.preCachedDraft(event)) {
is IsInPublicChatChannel -> inner.channelId()?.let { account.markAsRead("Channel/$it", createdAt) }
is ChannelCreateEvent -> account.markAsRead("Channel/${inner.id}", createdAt)
is ChatroomKeyable -> account.markAsRead(privateChatLastReadRoute(inner.chatroomKey(account.signer.pubKey)), createdAt)
else -> {}
}
}
else -> {}
}
}
@@ -133,6 +133,14 @@ val Size100dp = 100.dp
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.
*/
val ChatLabelMaxWidth = 140.dp
val StdEndPadding = Modifier.padding(end = 10.dp)
val HalfEndPadding = Modifier.padding(end = 5.dp)
val HalfStartPadding = Modifier.padding(start = 5.dp)