diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2490348e6d..e3502bbf9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) { 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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt index 4fa775cc6f..91e60aa4ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index ad9a878212..05ab71f7d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MarkChatRoomsAsRead.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MarkChatRoomsAsRead.kt new file mode 100644 index 0000000000..d923113984 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MarkChatRoomsAsRead.kt @@ -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 -> {} + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index f7543a0a1a..ffca10a088 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -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)