mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+5
-23
@@ -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()
|
||||
}
|
||||
|
||||
+15
-15
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+149
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user