mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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() }
|
||||
|
||||
+9
@@ -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()
|
||||
|
||||
+36
@@ -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
|
||||
|
||||
+20
-2
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user