mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+29
@@ -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(),
|
||||
|
||||
+46
@@ -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
|
||||
|
||||
+21
-2
@@ -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
|
||||
|
||||
+15
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-13
@@ -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
|
||||
}
|
||||
|
||||
+23
-7
@@ -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) {
|
||||
|
||||
+158
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+20
@@ -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
|
||||
|
||||
+65
@@ -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()
|
||||
|
||||
+69
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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)
|
||||
|
||||
+64
@@ -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)
|
||||
|
||||
+83
@@ -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] })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user