Merge pull request #3586 from vitorpamplona/claude/nip29-message-pinning-elzck9

NIP-29 message pinning: UI bar, moderation events, and relay group integration
This commit is contained in:
Vitor Pamplona
2026-07-15 19:56:53 -04:00
committed by GitHub
18 changed files with 701 additions and 22 deletions
@@ -255,6 +255,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
@@ -2757,6 +2758,37 @@ class Account(
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Replace the group's pinned-message list with a kind 9010 update-pin-list event
* (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and
* republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
*/
suspend fun updateRelayGroupPins(
channel: RelayGroupChannel,
pinnedEventIds: List<HexKey>,
) {
val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Pin [eventId] by appending it to the current list (no-op if already pinned). */
suspend fun pinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds + eventId)
}
/** Unpin [eventId] by removing it from the current list (no-op if not pinned). */
suspend fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (!channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds - eventId)
}
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
suspend fun removeRelayGroupUser(
channel: RelayGroupChannel,
@@ -162,6 +162,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
@@ -170,6 +171,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
@@ -1903,6 +1905,20 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/** NIP-29 relay-signed pinned-message list (kind 39005) → the group's pins. */
fun consume(
event: GroupPinnedEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) }
}
return new
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
@@ -3856,6 +3872,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is GroupPinnedEvent -> {
consume(event, relay, wasVerified)
}
// Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003
// roles, 39004 AV participants) are durable group state alongside 39000/39001/39002,
// so they're stored replaceably. The 9xxx moderation actions and join/leave requests
@@ -3886,6 +3906,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeRegularEvent(event, relay, wasVerified)
}
is UpdatePinListEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is DeleteGroupEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -1618,6 +1618,16 @@ class AccountViewModel(
body: String,
) = launchSigner { account.postRelayGroupThread(channel, title, body) }
fun pinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) }
fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) }
fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
@@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -74,6 +75,11 @@ fun RefreshingChatroomFeedView(
// LazyColumn), so a caller (private DMs) can drive demand-driven paging off viewport visibility
// rather than per-row composition. No-op for callers that don't paginate.
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
// Optional external jump request (e.g. a "pinned message" bar): when it holds a note id, the feed
// scrolls to that message and highlights it, then calls [onJumpHandled] to clear it. Null for
// callers with no external jump affordance.
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -89,6 +95,8 @@ fun RefreshingChatroomFeedView(
olderBoundary,
markersInGap,
sentinels,
jumpToNoteId,
onJumpHandled,
)
}
}
@@ -106,6 +114,8 @@ fun RenderChatFeedView(
olderBoundary: (@Composable () -> Unit)? = null,
markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null,
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -136,6 +146,8 @@ fun RenderChatFeedView(
olderBoundary,
markersInGap,
sentinels,
jumpToNoteId,
onJumpHandled,
)
}
}
@@ -155,6 +167,8 @@ fun ChatFeedLoaded(
olderBoundary: (@Composable () -> Unit)? = null,
markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null,
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -180,6 +194,21 @@ fun ChatFeedLoaded(
}
}
// External jump request (pinned-message bar). Keyed on the id alone, so a message arriving mid-jump
// can't cancel the scroll animation or restart the effect. Always clears the request after one
// attempt — even when the target isn't loaded — so it never sticks and a repeat tap fires again.
val jumpId = jumpToNoteId?.value
LaunchedEffect(jumpId) {
if (jumpId != null) {
val index = items.list.indexOfFirst { it.idHex == jumpId }
if (index >= 0) {
listState.animateScrollToItem(index)
highlightedNoteId.value = jumpId
}
onJumpHandled()
}
}
LazyColumn(
contentPadding = FeedPadding,
modifier = Modifier.fillMaxSize(),
@@ -59,9 +59,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -323,6 +325,13 @@ fun ChatMessageActionSheet(
}
}
}
// NIP-29 moderator action: pin/unpin this message in its relay group.
// Only rendered (and only subscribes) when the note belongs to a group.
val relayGroup = remember(note) { note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } }
if (relayGroup != null && !note.isDraft()) {
RelayGroupPinTile(note, relayGroup, onDismiss, accountViewModel)
}
}
}
@@ -331,6 +340,43 @@ fun ChatMessageActionSheet(
}
}
/**
* NIP-29 moderator-only tile that pins/unpins this message in its relay group.
* Observes the group's live roster + pin list, so it shows the right verb and
* hides itself entirely for members who can't moderate. Pinning replaces the whole
* kind-9010 list; the relay republishes the kind-39005 pins the bar reads.
*/
@Composable
private fun RelayGroupPinTile(
note: Note,
baseChannel: RelayGroupChannel,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val canModerate = channel.membershipOf(accountViewModel.userProfile().pubkeyHex).canModerate()
if (!canModerate) return
val pinned = channel.isPinned(note.idHex)
SectionDivider()
TileRow {
if (pinned) {
ActionTile(MaterialSymbols.PushPin, stringRes(R.string.relay_group_unpin_message)) {
accountViewModel.unpinRelayGroupMessage(channel, note)
onDismiss()
}
} else {
ActionTile(MaterialSymbols.PushPin, stringRes(R.string.relay_group_pin_message)) {
accountViewModel.pinRelayGroupMessage(channel, note)
onDismiss()
}
}
}
}
/**
* The expand/collapse control between the always-visible quick actions and the full
* action inventory. Keeps the sheet short on open (the common case is a reaction, a
@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
@@ -78,9 +79,13 @@ fun chatFooterHasMeta(note: Note): Boolean {
return event is PrivateDmEvent ||
event.expiration() != null ||
event.geoHashOrScope() != null ||
event.strongPoWOrNull() != null
event.strongPoWOrNull() != null ||
note.isPinnedInRelayGroup()
}
/** True when this note's NIP-29 group has it in its pinned list (kind 39005). */
fun Note.isPinnedInRelayGroup(): Boolean = inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.isPinned(idHex) == true
/**
* The small row at the bottom of a chat bubble: inline status glyphs (legacy-DM,
* expiration, geohash, proof-of-work each shown only when present) followed, on the
@@ -102,6 +107,7 @@ fun ChatMessageFooter(
Row(verticalAlignment = Alignment.CenterVertically) {
// Each glyph self-gates and renders nothing when not applicable.
ChatPinnedBadge(note)
IncognitoBadge(note)
ChatExpiration(note)
@@ -115,7 +121,7 @@ fun ChatMessageFooter(
}
if (showTime) {
val hasGlyph = event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null
val hasGlyph = note.isPinnedInRelayGroup() || event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null
if (hasGlyph) Spacer(StdHorzSpacer)
// Drafts aren't published, so no relay/delivery detail; everything else gets
@@ -129,6 +135,19 @@ fun ChatMessageFooter(
}
}
/** A small pin glyph on a bubble whose message is pinned in its NIP-29 group. */
@Composable
fun ChatPinnedBadge(note: Note) {
if (!note.isPinnedInRelayGroup()) return
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = stringRes(R.string.relay_group_pinned_content_description),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(StdHorzSpacer)
}
@Composable
fun ChatExpiration(note: Note) {
val event = note.event
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -58,4 +59,18 @@ fun ChannelFilterAssemblerSubscription(
dataSource.invalidateFilters()
}
}
// Relay groups: when the kind-39005 pin list changes, re-invalidate so the id-based back-fill
// for pinned message bodies (see filterMetadataToRelayGroup) picks up the new ids. Keyed on the
// pin list alone, so unrelated roster/metadata churn doesn't force a re-subscribe.
if (channel is RelayGroupChannel) {
val metadataState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pinnedEventIds ?: channel.pinnedEventIds
LaunchedEffect(pinnedIds) {
dataSource.invalidateFilters()
}
}
}
@@ -27,15 +27,17 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
/** Relay-signed group directory kinds: metadata + admins + members + roles. */
/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */
private val RELAY_GROUP_METADATA_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
)
/**
@@ -47,15 +49,32 @@ private val RELAY_GROUP_METADATA_KINDS =
fun filterMetadataToRelayGroup(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to listOf(channel.groupId.id)),
since = since?.get(it)?.time,
),
)
}
): List<RelayBasedFilter> {
val relays = channel.relays().toSet()
val directory =
relays.map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to listOf(channel.groupId.id)),
since = since?.get(it)?.time,
),
)
}
// Back-fill the bodies of pinned messages by id from the host relay. A pin can point at a
// message older than the 200-item timeline window, which the `h`-scoped timeline filter would
// never return — without this the pin bar shows a blank preview and can't jump to it. No `since`:
// pinned events are immutable, so we want them regardless of age.
val pinnedIds = channel.pinnedEventIds
val pins =
if (pinnedIds.isEmpty()) {
emptyList()
} else {
relays.map { RelayBasedFilter(relay = it, filter = Filter(ids = pinnedIds)) }
}
return directory + pins
}
@@ -32,6 +32,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
@@ -128,7 +129,26 @@ private fun ChannelView(
WatchLifecycleAndUpdateModel(feedViewModel)
ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel)
// Collect the metadata flow once for the whole screen: it drives both the pinned-message
// bar (kind-39005 pins) and the composer gating (roster membership), and updates the moment
// a pin lands or my join is accepted.
val channelState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
// A pinned-bar tap requests an in-feed jump; the feed consumes it, scrolls + highlights,
// then clears it back to null so the same pin can be tapped again later.
val jumpToNoteId = remember { mutableStateOf<String?>(null) }
Column(Modifier.fillMaxHeight()) {
RelayGroupPinnedBar(
channel = liveChannel,
accountViewModel = accountViewModel,
onJumpToNote = { jumpToNoteId.value = it.idHex },
)
Column(
modifier =
remember {
@@ -146,19 +166,15 @@ private fun ChannelView(
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
jumpToNoteId = jumpToNoteId,
onJumpHandled = { jumpToNoteId.value = null },
)
}
Spacer(modifier = DoubleVertSpacer)
// NIP-29 relays reject writes from non-members, so only show the composer when the
// relay-signed roster (39001/39002) lists me as a member/mod/admin. Collect the metadata
// flow so the composer appears the moment my join is accepted. Otherwise, explain why.
val channelState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
// relay-signed roster (39001/39002) lists me as a member/mod/admin. Otherwise, explain why.
val canPost = liveChannel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember()
if (canPost) {
@@ -0,0 +1,158 @@
/*
* 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.publicChannels.relayGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
/**
* Self-hiding NIP-29 pinned-message bar shown under the group's top bar. Renders
* nothing when the group has no pins (kind-39005 empty), so it never obstructs a
* normal chat. When there are pins it shows a single-line preview of the current
* one; tapping [onJumpToNote] scrolls the feed to that message and, if the group
* has more than one pin, advances to the next for the following tap (Telegram-style
* cycling) rather than piling every pin on screen at once.
*/
@Composable
fun RelayGroupPinnedBar(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
onJumpToNote: (Note) -> Unit,
) {
val pinnedIds = channel.pinnedEventIds
if (pinnedIds.isEmpty()) return
// Newest pin (last in the relay's display order) surfaced first; reset when the list changes.
var index by remember(pinnedIds) { mutableIntStateOf(pinnedIds.lastIndex) }
val safeIndex = index.coerceIn(0, pinnedIds.lastIndex)
val currentId = pinnedIds[safeIndex]
val note = remember(currentId) { accountViewModel.checkGetOrCreateNote(currentId) } ?: return
// Fetch + observe the pinned message so its author and content fill in once it loads.
val noteState by observeNote(note, accountViewModel)
val liveNote = noteState.note
Surface(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
onJumpToNote(liveNote)
// Cycle to the previous (older) pin so a repeat tap walks the history.
if (pinnedIds.size > 1) {
index = if (safeIndex == 0) pinnedIds.lastIndex else safeIndex - 1
}
}.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = stringRes(R.string.relay_group_pinned_content_description),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringRes(R.string.relay_group_pinned_label),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
if (pinnedIds.size > 1) {
Text(
text = "${safeIndex + 1}/${pinnedIds.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = pinnedPreview(liveNote) ?: stringRes(R.string.relay_group_pinned_content_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
HorizontalDivider(thickness = DividerThickness, color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp))
}
}
}
/**
* A one-line "Author: message" preview for the bar, or just the message when the
* author's name hasn't loaded. Null when the pinned event body isn't loaded yet, so
* the caller can fall back to a generic label.
*/
private fun pinnedPreview(note: Note): String? {
val content =
note.event
?.content
?.replace('\n', ' ')
?.trim()
val author = note.author?.toBestDisplayName()
return when {
content.isNullOrBlank() -> author
author != null -> "$author: $content"
else -> content
}
}
+4
View File
@@ -2166,6 +2166,10 @@
<string name="relay_group_menu_members">Members</string>
<string name="relay_group_menu_edit">Edit group</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Pin message</string>
<string name="relay_group_unpin_message">Unpin message</string>
<string name="relay_group_pinned_label">Pinned</string>
<string name="relay_group_pinned_content_description">Pinned messages</string>
<string name="relay_group_open">Open group</string>
<string name="relay_group_channels_empty">No groups on this relay yet.</string>
<string name="relay_group_channels_not_nip29">This relay does not advertise support for groups (NIP-29), so it may not host any.</string>
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
@@ -70,6 +71,14 @@ class RelayGroupChannel(
private set
private var adminsUpdatedAt: Long = 0
/**
* Relay-signed pinned message ids (kind 39005), in the relay's display order.
* Pinning replaces the whole list, so this is always the full current set.
*/
var pinnedEventIds: List<HexKey> = emptyList()
private set
private var pinnedUpdatedAt: Long = 0
/**
* Members admins, recomputed only when a roster event lands. [memberCount] and the discovery
* feed read this per note / per recomposition, so caching it avoids rebuilding the set each read.
@@ -158,6 +167,17 @@ class RelayGroupChannel(
updateChannelInfo()
}
fun updatePinned(event: GroupPinnedEvent) {
// Only newer lists supersede; equal-or-older is dropped so a duplicate
// arrival isn't reprocessed (no redundant emit).
if (event.createdAt <= pinnedUpdatedAt) return
pinnedEventIds = event.pinnedEventIds()
pinnedUpdatedAt = event.createdAt
updateChannelInfo()
}
fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds
/**
* The shareable NIP-19 `naddr` coordinate for this group's metadata (kind
* 39000, authored by the relay's own key, with the host relay as a hint), or
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -79,6 +80,70 @@ class RelayGroupChannelTest {
return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupAdminsEvent.KIND, tags, "", "22".repeat(64)) as GroupAdminsEvent
}
private val msg1 = "11".repeat(32)
private val msg2 = "22".repeat(32)
private val msg3 = "33".repeat(32)
private fun pinned(
createdAt: Long,
vararg eventIds: String,
): GroupPinnedEvent {
val tags = (listOf(arrayOf("d", gid)) + eventIds.map { arrayOf("e", it) }).toTypedArray()
return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) as GroupPinnedEvent
}
@Test
fun pinnedListFoldsInOrderAndReportsMembership() {
val c = channel()
assertTrue(c.pinnedEventIds.isEmpty())
assertFalse(c.isPinned(msg1))
c.updatePinned(pinned(100, msg1, msg2))
assertEquals(listOf(msg1, msg2), c.pinnedEventIds)
assertTrue(c.isPinned(msg1))
assertTrue(c.isPinned(msg2))
assertFalse(c.isPinned(msg3))
}
@Test
fun pinListIsFullyReplacedByNewerEvent() {
val c = channel()
c.updatePinned(pinned(100, msg1, msg2))
// A newer list drops msg1, keeps msg2, adds msg3 — the whole set is replaced.
c.updatePinned(pinned(200, msg2, msg3))
assertEquals(listOf(msg2, msg3), c.pinnedEventIds)
assertFalse(c.isPinned(msg1))
assertTrue(c.isPinned(msg3))
}
@Test
fun clearingPinsWithEmptyNewerList() {
val c = channel()
c.updatePinned(pinned(100, msg1))
c.updatePinned(pinned(200))
assertTrue(c.pinnedEventIds.isEmpty())
assertFalse(c.isPinned(msg1))
}
@Test
fun stalePinEventDoesNotSupersede() {
val c = channel()
c.updatePinned(pinned(200, msg1, msg2))
// An OLDER pin list arrives late — ignore it.
c.updatePinned(pinned(100, msg3))
assertEquals(listOf(msg1, msg2), c.pinnedEventIds)
}
@Test
fun equalCreatedAtPinDoesNotResupersede() {
val c = channel()
c.updatePinned(pinned(100, msg1, msg2))
c.updatePinned(pinned(100, msg3))
assertEquals(listOf(msg1, msg2), c.pinnedEventIds)
}
@Test
fun placeholderNoteCarriesChannelGathererAndIsStable() {
val c = channel()
@@ -0,0 +1,69 @@
/*
* 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.quartz.nip29RelayGroups.metadata
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-29 relay-signed list of a group's pinned messages (kind 39005). The relay
* regenerates it from the accepted [com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent]
* (kind 9010) moderation actions, so this is the read side clients render the
* source of truth for which messages are pinned and in what display order.
*
* Addressed by the group id (`d` tag). The pinned event ids are carried as `e`
* tags in display order.
*/
@Immutable
class GroupPinnedEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun groupId() = dTag()
/** Pinned message ids, in the relay's display order. */
fun pinnedEventIds(): List<HexKey> = tags.mapValueTagged("e") { it }
companion object {
const val KIND = 39005
fun build(
groupId: String,
pinnedEventIds: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GroupPinnedEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(groupId)
pinnedEventIds.forEach { add(arrayOf("e", it)) }
initializer()
}
}
}
@@ -37,4 +37,6 @@ fun TagArray.userPubKeys(): List<HexKey> = mapNotNull(PTag::parseKey)
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it }
fun TagArray.pinnedEventIds(): List<HexKey> = mapValueTagged("e") { it }
fun TagArray.inviteCode() = firstNotNullOfOrNull(CodeTag::parse)
@@ -0,0 +1,64 @@
/*
* 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.quartz.nip29RelayGroups.moderation
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-29 `update-pin-list` moderation event (kind 9010). Carries the group `h`
* tag plus the FULL list of pinned message ids as `e` tags pinning, unpinning,
* reordering and clearing pins are all done by submitting a new complete list.
* The relay checks the sender's role, applies it, and republishes the group's
* kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
*/
@Immutable
class UpdatePinListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun groupId() = tags.groupId()
fun pinnedEventIds() = tags.pinnedEventIds()
companion object {
const val KIND = 9010
fun build(
groupId: String,
pinnedEventIds: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<UpdatePinListEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
groupId(groupId)
pinnedEventIds.forEach { add(arrayOf("e", it)) }
initializer()
}
}
}
@@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
@@ -111,6 +112,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
@@ -401,6 +403,8 @@ class EventFactory {
GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig)
SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig)
GroupParticipantsEvent.KIND -> GroupParticipantsEvent(id, pubKey, createdAt, tags, content, sig)
GroupPinnedEvent.KIND -> GroupPinnedEvent(id, pubKey, createdAt, tags, content, sig)
UpdatePinListEvent.KIND -> UpdatePinListEvent(id, pubKey, createdAt, tags, content, sig)
ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig)
CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig)
RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,83 @@
/*
* 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.quartz.nip29RelayGroups
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* NIP-29 message-pinning wire format (PR #2379): the kind-9010 `update-pin-list`
* moderation write and the relay-signed kind-39005 pinned-list read side. Verifies
* both are dispatched to the right class by [EventFactory] and that the `h`/`d` group
* scope plus the ordered `e` id list round-trip through build parse.
*/
class PinEventsTest {
private val relaySelf = "aa".repeat(32)
private val gid = "0123456789abcdef"
private val id1 = "11".repeat(32)
private val id2 = "22".repeat(32)
private val id3 = "33".repeat(32)
@Test
fun groupPinnedEventParsesGroupAndOrderedIds() {
val tags = arrayOf(arrayOf("d", gid), arrayOf("e", id1), arrayOf("e", id2), arrayOf("e", id3))
val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, GroupPinnedEvent.KIND, tags, "", "22".repeat(64))
assertEquals(true, event is GroupPinnedEvent)
event as GroupPinnedEvent
assertEquals(gid, event.groupId())
assertEquals(listOf(id1, id2, id3), event.pinnedEventIds())
}
@Test
fun updatePinListEventParsesGroupAndIds() {
val tags = arrayOf(arrayOf("h", gid), arrayOf("e", id1), arrayOf("e", id2))
val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, UpdatePinListEvent.KIND, tags, "", "22".repeat(64))
assertEquals(true, event is UpdatePinListEvent)
event as UpdatePinListEvent
assertEquals(gid, event.groupId())
assertEquals(listOf(id1, id2), event.pinnedEventIds())
}
@Test
fun updatePinListBuildCarriesHTagAndFullList() {
val template = UpdatePinListEvent.build(gid, listOf(id1, id2))
assertEquals(UpdatePinListEvent.KIND, template.kind)
assertEquals(gid, template.tags.firstOrNull { it[0] == GroupIdTag.TAG_NAME }?.getOrNull(1))
assertEquals(listOf(id1, id2), template.tags.filter { it[0] == "e" }.map { it[1] })
}
@Test
fun groupPinnedBuildCarriesDTagAndFullList() {
val template = GroupPinnedEvent.build(gid, listOf(id1, id2, id3))
assertEquals(GroupPinnedEvent.KIND, template.kind)
assertEquals(gid, template.tags.firstOrNull { it[0] == "d" }?.getOrNull(1))
assertEquals(listOf(id1, id2, id3), template.tags.filter { it[0] == "e" }.map { it[1] })
}
}