From 049db06844f8224490868c65f40330c960add259 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 23:34:58 +0000 Subject: [PATCH] feat: pin DM conversations to the top of the chat list Long-pressing a private chat row in the messages tab now offers Pin to top / Unpin. Pinned rooms sort above everything else in the known-chats list (ties broken by the usual newest-first order) and show a small pin icon next to the room name. Pins are stored per account as a local-only setting (encrypted SharedPreferences via AccountSettings.pinnedChatrooms) because there is no standard NIP-51 list for pinned DMs; this can be migrated to a synced list later if one is standardized. https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i --- .../amethyst/LocalPreferences.kt | 12 +++++++ .../amethyst/model/AccountSettings.kt | 17 +++++++++ .../amethyst/ui/layouts/ChatHeaderLayout.kt | 10 ++++-- .../loggedIn/AccountFeedContentStates.kt | 9 +++++ .../chats/rooms/ChatroomHeaderCompose.kt | 36 +++++++++++++++++++ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 22 ++++++++++-- amethyst/src/main/res/values/strings.xml | 3 ++ 7 files changed, 105 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 79182b7505..e14793993b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent @@ -156,6 +157,7 @@ private object PrefKeys { const val SIGNER_PACKAGE_NAME = "signer_package_name" const val HAS_DONATED_IN_VERSION = "has_donated_in_version" const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids" + const val PINNED_CHATROOMS = "pinned_chatrooms" const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids" const val PENDING_ATTESTATIONS = "pending_attestations" @@ -464,6 +466,11 @@ object LocalPreferences { ) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value) + // Each room is its member pubkeys joined by "," (hex keys never contain commas). + putStringSet( + PrefKeys.PINNED_CHATROOMS, + settings.pinnedChatrooms.value.mapTo(mutableSetOf()) { it.users.sorted().joinToString(",") }, + ) putString( PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, JsonMapper.toJson(settings.viewedPollResultNoteIds.value), @@ -560,6 +567,10 @@ object LocalPreferences { val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() + val pinnedChatrooms = + getStringSet(PrefKeys.PINNED_CHATROOMS, null) + ?.mapTo(mutableSetOf()) { ChatroomKey(it.split(",").toSet()) } + ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() @@ -724,6 +735,7 @@ object LocalPreferences { lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), + pinnedChatrooms = MutableStateFlow(pinnedChatrooms), viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()), pendingAttestations = MutableStateFlow(pendingAttestations.await()), backupNipA3PaymentTargets = latestPaymentTargets.await(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index a899b593e8..2e8f19b1e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent @@ -237,6 +238,9 @@ class AccountSettings( val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val dismissedPollNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), + // Rooms pinned to the top of the chat list. Local-only (not synced as a Nostr + // event): there is no standard NIP-51 list for pinned DMs. + val pinnedChatrooms: MutableStateFlow> = MutableStateFlow(setOf()), val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, @@ -1138,6 +1142,19 @@ class AccountSettings( } } + // --- + // pinned chatrooms + // --- + + fun isChatroomPinned(room: ChatroomKey) = pinnedChatrooms.value.contains(room) + + fun toggleChatroomPin(room: ChatroomKey) { + pinnedChatrooms.update { + if (room in it) it - room else it + room + } + saveAccountSettings() + } + // --- // viewed poll results // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index 44b025b61a..ab1b69fd10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.layouts +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -134,15 +135,20 @@ fun ChannelNamePreview() { } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatHeaderLayout( channelPicture: @Composable () -> Unit, firstRow: @Composable RowScope.() -> Unit, secondRow: @Composable RowScope.() -> Unit, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, ) { Row( - modifier = Modifier.clickable(onClick = onClick).padding(10.dp), + modifier = + Modifier + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(10.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(Size55Modifier) { channelPicture() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index b72c382605..900537b5f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch class AccountFeedContentStates( @@ -148,6 +149,14 @@ class AccountFeedContentStates( } } + // Pinning/unpinning a room only changes sort order, not membership, so no + // event flows through LocalCache. Force a rebuild to re-sort. + scope.launch(Dispatchers.IO) { + account.settings.pinnedChatrooms.drop(1).collect { + dmKnown.invalidateData() + } + } + scope.launch(Dispatchers.IO) { account.hiddenUsers.flow.collect { dmKnown.invalidateData() 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 16e0941d2f..1162e3e4aa 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 @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,6 +42,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -68,7 +72,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemC import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -338,6 +344,11 @@ private fun UserRoomCompose( accountViewModel: AccountViewModel, nav: INav, ) { + var popupExpanded by remember { mutableStateOf(false) } + val pinnedRooms by accountViewModel.account.settings.pinnedChatrooms + .collectAsStateWithLifecycle() + val isPinned = room in pinnedRooms + ChatHeaderLayout( channelPicture = { NonClickableUserPictures( @@ -348,6 +359,15 @@ private fun UserRoomCompose( }, firstRow = { RoomNameDisplay(room, Modifier.weight(1f), accountViewModel) + if (isPinned) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(R.string.pinned_to_top), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(modifier = StdHorzSpacer) + } TimeAgo(lastMessage.createdAt()) }, secondRow = { @@ -379,7 +399,23 @@ private fun UserRoomCompose( } }, onClick = { nav.nav(Route.Room(room)) }, + onLongClick = { popupExpanded = true }, ) + + DropdownMenu( + expanded = popupExpanded, + onDismissRequest = { popupExpanded = false }, + ) { + DropdownMenuItem( + text = { + Text(stringRes(if (isPinned) R.string.unpin_conversation else R.string.pin_conversation)) + }, + onClick = { + accountViewModel.account.settings.toggleChatroomPin(room) + popupExpanded = false + }, + ) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index eeabb81cc3..a4f5786ac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -88,7 +89,7 @@ class ChatroomListKnownFeedFilter( } } - return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder) + return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups).toSet()) } override fun updateListWith( @@ -261,7 +262,24 @@ class ChatroomListKnownFeedFilter( return newRelevantPrivateMessages } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List { + val pinned = account.settings.pinnedChatrooms.value + if (pinned.isEmpty()) return items.sortedWith(DefaultFeedOrder) + + val me = account.userProfile().pubkeyHex + return items.sortedWith( + compareByDescending { isPinned(it, me, pinned) }.then(DefaultFeedOrder), + ) + } + + private fun isPinned( + note: Note, + myPubKey: HexKey, + pinned: Set, + ): Boolean { + val room = (note.event as? ChatroomKeyable)?.chatroomKey(myPubKey) ?: return false + return room in pinned + } // Maps a note that represents a public chat row to its channel id. The // representative note for a channel may be the channel's create event diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ad5044f7e9..8c62e152a2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -185,6 +185,9 @@ Image saved to the phone\'s photo gallery Video download has started… Media download has started… + Pin to top + Unpin + Pinned to top Failed to save the image Video saved to the phone\'s video gallery Failed to save the video