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
This commit is contained in:
Claude
2026-06-11 01:36:16 +00:00
parent 7050264d70
commit 049db06844
7 changed files with 105 additions and 4 deletions
@@ -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<ChatroomKey>()
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(),
@@ -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<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = 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<Set<ChatroomKey>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = 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
// ---
@@ -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() }
@@ -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()
@@ -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
@@ -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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
override fun sort(items: Set<Note>): List<Note> {
val pinned = account.settings.pinnedChatrooms.value
if (pinned.isEmpty()) return items.sortedWith(DefaultFeedOrder)
val me = account.userProfile().pubkeyHex
return items.sortedWith(
compareByDescending<Note> { isPinned(it, me, pinned) }.then(DefaultFeedOrder),
)
}
private fun isPinned(
note: Note,
myPubKey: HexKey,
pinned: Set<ChatroomKey>,
): 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
+3
View File
@@ -185,6 +185,9 @@
<string name="image_saved_to_the_gallery">Image saved to the phone\'s photo gallery</string>
<string name="video_download_has_started_toast">Video download has started…</string>
<string name="media_download_has_started_toast">Media download has started…</string>
<string name="pin_conversation">Pin to top</string>
<string name="unpin_conversation">Unpin</string>
<string name="pinned_to_top">Pinned to top</string>
<string name="failed_to_save_the_image">Failed to save the image</string>
<string name="video_saved_to_the_gallery">Video saved to the phone\'s video gallery</string>
<string name="failed_to_save_the_video">Failed to save the video</string>