diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2a30c95093..ab22669284 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, + ) { + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 40bc4ba53d..90a6adbe3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a0c9adc1a1..2fc75b1839 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index f3b7d11a4c..d00d3120de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -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, 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? = 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, listState: LazyListState) -> Unit)? = null, + jumpToNoteId: State? = 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, listState: LazyListState) -> Unit)? = null, + jumpToNoteId: State? = 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(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index b6f9e307ae..3fbd768630 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt index cddb259cd9..1c97a4d7fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index 7c748382b6..e0154c8373 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -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() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt index a9c208ca76..005b066e1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt @@ -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 = - 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 { + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index b84c48923e..befde7fc84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -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(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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt new file mode 100644 index 0000000000..1649276dac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt @@ -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 + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d8573277d6..6c8d8cb12b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2166,6 +2166,10 @@ Members Edit group Threads + Pin message + Unpin message + Pinned + Pinned messages Open group No groups on this relay yet. This relay does not advertise support for groups (NIP-29), so it may not host any. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 4a05bfa61c..aa702e13c5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -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 = 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 diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt index d346cfaabf..6d54bf43bb 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt new file mode 100644 index 0000000000..a15aa5f589 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt @@ -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>, + 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 = tags.mapValueTagged("e") { it } + + companion object { + const val KIND = 39005 + + fun build( + groupId: String, + pinnedEventIds: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(groupId) + pinnedEventIds.forEach { add(arrayOf("e", it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 318fed79ac..32115a9b7c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -37,4 +37,6 @@ fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } +fun TagArray.pinnedEventIds(): List = mapValueTagged("e") { it } + fun TagArray.inviteCode() = firstNotNullOfOrNull(CodeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt new file mode 100644 index 0000000000..9a53d7e7cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt @@ -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>, + 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, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + groupId(groupId) + pinnedEventIds.forEach { add(arrayOf("e", it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4957e42c81..fc216e3ae0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt new file mode 100644 index 0000000000..3cdd3fbe2d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt @@ -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] }) + } +}