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

NIP-29 message pinning: UI bar, moderation events, and relay group integration
This commit is contained in:
Vitor Pamplona
2026-07-15 19:56:53 -04:00
committed by GitHub
18 changed files with 701 additions and 22 deletions
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
@@ -70,6 +71,14 @@ class RelayGroupChannel(
private set
private var adminsUpdatedAt: Long = 0
/**
* Relay-signed pinned message ids (kind 39005), in the relay's display order.
* Pinning replaces the whole list, so this is always the full current set.
*/
var pinnedEventIds: List<HexKey> = emptyList()
private set
private var pinnedUpdatedAt: Long = 0
/**
* Members admins, recomputed only when a roster event lands. [memberCount] and the discovery
* feed read this per note / per recomposition, so caching it avoids rebuilding the set each read.
@@ -158,6 +167,17 @@ class RelayGroupChannel(
updateChannelInfo()
}
fun updatePinned(event: GroupPinnedEvent) {
// Only newer lists supersede; equal-or-older is dropped so a duplicate
// arrival isn't reprocessed (no redundant emit).
if (event.createdAt <= pinnedUpdatedAt) return
pinnedEventIds = event.pinnedEventIds()
pinnedUpdatedAt = event.createdAt
updateChannelInfo()
}
fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds
/**
* The shareable NIP-19 `naddr` coordinate for this group's metadata (kind
* 39000, authored by the relay's own key, with the host relay as a hint), or
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -79,6 +80,70 @@ class RelayGroupChannelTest {
return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupAdminsEvent.KIND, tags, "", "22".repeat(64)) as GroupAdminsEvent
}
private val msg1 = "11".repeat(32)
private val msg2 = "22".repeat(32)
private val msg3 = "33".repeat(32)
private fun pinned(
createdAt: Long,
vararg eventIds: String,
): GroupPinnedEvent {
val tags = (listOf(arrayOf("d", gid)) + eventIds.map { arrayOf("e", it) }).toTypedArray()
return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) as GroupPinnedEvent
}
@Test
fun pinnedListFoldsInOrderAndReportsMembership() {
val c = channel()
assertTrue(c.pinnedEventIds.isEmpty())
assertFalse(c.isPinned(msg1))
c.updatePinned(pinned(100, msg1, msg2))
assertEquals(listOf(msg1, msg2), c.pinnedEventIds)
assertTrue(c.isPinned(msg1))
assertTrue(c.isPinned(msg2))
assertFalse(c.isPinned(msg3))
}
@Test
fun pinListIsFullyReplacedByNewerEvent() {
val c = channel()
c.updatePinned(pinned(100, msg1, msg2))
// A newer list drops msg1, keeps msg2, adds msg3 — the whole set is replaced.
c.updatePinned(pinned(200, msg2, msg3))
assertEquals(listOf(msg2, msg3), c.pinnedEventIds)
assertFalse(c.isPinned(msg1))
assertTrue(c.isPinned(msg3))
}
@Test
fun clearingPinsWithEmptyNewerList() {
val c = channel()
c.updatePinned(pinned(100, msg1))
c.updatePinned(pinned(200))
assertTrue(c.pinnedEventIds.isEmpty())
assertFalse(c.isPinned(msg1))
}
@Test
fun stalePinEventDoesNotSupersede() {
val c = channel()
c.updatePinned(pinned(200, msg1, msg2))
// An OLDER pin list arrives late — ignore it.
c.updatePinned(pinned(100, msg3))
assertEquals(listOf(msg1, msg2), c.pinnedEventIds)
}
@Test
fun equalCreatedAtPinDoesNotResupersede() {
val c = channel()
c.updatePinned(pinned(100, msg1, msg2))
c.updatePinned(pinned(100, msg3))
assertEquals(listOf(msg1, msg2), c.pinnedEventIds)
}
@Test
fun placeholderNoteCarriesChannelGathererAndIsStable() {
val c = channel()