Merge pull request #4236 from vitorpamplona/claude/serene-lamport-3eyza2

feat: catch up with the last 3 months of NIP spec changes
This commit is contained in:
Vitor Pamplona
2026-09-27 20:14:05 -04:00
committed by GitHub
204 changed files with 9718 additions and 531 deletions
@@ -88,6 +88,7 @@ ent for NIP-04) |
| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage |
| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) |
| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events |
| A3 | `nipA3PaymentTargets/` | PaymentTargetsEvent.kt | Payment targets (`payto` tags, kind 10133) |
| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage |
### Web/Storage/Other
@@ -127,7 +128,6 @@ Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/
| `limits/` | Limit enforcement |
| `medical/` | Medical data |
| `nip95/` | File storage support |
| `nipA3/` | A3 protocol extension |
| `nns/` | Nostr Name System |
| `profileGallery/` | Profile gallery lists |
| `publicMessages/` | Public message lists |
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore
import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences
import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore
import com.vitorpamplona.amethyst.commons.model.preferences.TorSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore
@@ -363,6 +364,10 @@ class AppModules(
val relayGroupDeletionPrefs =
RelayGroupDeletionStore(sharedSettingsStore, applicationIOScope)
// Restore + persist the last-known admins of joined NIP-29 groups, so the migration/fork check
// can read their kind-10009 lists even while a group's host relay is down.
val relayGroupAdminCachePrefs = RelayGroupAdminCacheStore(sharedSettingsStore, applicationIOScope)
// Restore + persist which drawer section headings the user has folded away, so the side menu
// opens the way they left it (device-global: a collapsed heading is a per-device view choice,
// not an account setting worth syncing, unlike the hidden rows beside it in the drawer).
@@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -98,6 +97,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList.Block
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
@@ -149,6 +150,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.bookmarkSets.BookmarkSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.StarterPacksState
@@ -199,7 +201,6 @@ import com.vitorpamplona.quartz.experimental.nip95.header.dimension
import com.vitorpamplona.quartz.experimental.nip95.header.fileSize
import com.vitorpamplona.quartz.experimental.nip95.header.hash
import com.vitorpamplona.quartz.experimental.nip95.header.mimeType
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.profileGallery.blurhash
import com.vitorpamplona.quartz.experimental.profileGallery.dimension
@@ -337,6 +338,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -765,6 +767,9 @@ class Account(
val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings)
val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope)
val favoriteFollowSetsListDecryptionCache = FavoriteFollowSetsListDecryptionCache(signer)
val favoriteFollowSetsList = FavoriteFollowSetsListState(signer, cache, favoriteFollowSetsListDecryptionCache, scope)
val geohashListDecryptionCache = GeohashListDecryptionCache(signer)
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
@@ -2155,6 +2160,10 @@ class Account(
fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm)
suspend fun followFavoriteFollowSet(followSet: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteFollowSetsList.follow(followSet))
suspend fun unfollowFavoriteFollowSet(followSet: Address) = sendMyPublicAndPrivateOutbox(favoriteFollowSetsList.unfollow(followSet))
suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash))
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
@@ -2182,14 +2191,32 @@ class Account(
title: String?,
description: String,
hashtags: List<String> = emptyList(),
editing: WebBookmarkEvent? = null,
) {
if (!isWriteable()) return
val template = WebBookmarkEvent.build(url, title, description, tags = hashtags)
val now = TimeUtils.now()
val template =
WebBookmarkEvent.build(
url,
title,
description,
tags = hashtags,
createdAt = now,
firstPublishedAt = editing?.publishedAt() ?: now,
)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
// A different d tag is a different address, so the edit would otherwise leave the old
// bookmark behind. That happens when the URL was changed, and when re-saving a bookmark
// stored under the pre-2026 NIP-B0 rule, which also dropped `http://` (the d tag then
// reads as https, and saving the real http URL now keeps the scheme).
if (editing != null && editing.dTag() != signedEvent.dTag()) {
deleteWebBookmark(editing)
}
}
suspend fun deleteWebBookmark(event: WebBookmarkEvent) {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -43,6 +44,8 @@ import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
@@ -67,7 +70,10 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEv
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.serialization.encodeToString
@@ -335,6 +341,7 @@ class AccountRelayGroupActions(
geohashes: List<String> = emptyList(),
parent: String? = null,
channelType: String? = null,
banner: String? = null,
): GroupId {
// The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes
// its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without
@@ -356,6 +363,7 @@ class AccountRelayGroupActions(
name = name,
about = about,
picture = picture,
banner = banner,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
@@ -410,34 +418,69 @@ class AccountRelayGroupActions(
}
/**
* 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].
* Replace the group's pin list with a kind 9010 update-pin-list event (admin/moderator
* only). NIP-29 carries the FULL ordered list — `e` pins and `a` pins — so the relay applies
* it and republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
*/
suspend fun updateRelayGroupPins(
channel: RelayGroupChannel,
pinnedEventIds: List<HexKey>,
pins: List<GroupPin>,
) {
val template = GroupUpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
val template = GroupUpdatePinListEvent.build(channel.groupId.id, pins)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Pin [eventId] by appending it to the current list (no-op if already pinned). */
/**
* Pin [note] by appending it to the current list (no-op if already pinned). An addressable
* note is pinned by its address (`a`), so the pin follows its latest version; anything else
* by event id (`e`). Every existing pin — including other clients' `a` pins — is kept.
*/
suspend fun pinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
note: Note,
) {
if (channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds + eventId)
val pin = pinFor(note) ?: return
if (channel.isPinned(pin.ref)) return
updateRelayGroupPins(channel, channel.pinsWith(pin))
}
/** Unpin [eventId] by removing it from the current list (no-op if not pinned). */
/** Unpin [note] (by address or id) from the current list, keeping every other pin intact. */
suspend fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
note: Note,
) {
if (!channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds - eventId)
val refs = pinRefsFor(note)
if (refs.none { channel.isPinned(it) }) return
updateRelayGroupPins(channel, channel.pins.filter { it.ref !in refs })
}
private fun addressOf(note: Note): Address? = note.address() ?: (note.event as? AddressableEvent)?.address()
private fun pinFor(note: Note): GroupPin? {
addressOf(note)?.let { return AddressPin(it) }
return EventPin(note.event?.id ?: note.idHex)
}
/** Both references a note can be pinned under: its address (if addressable) and its event id. */
private fun pinRefsFor(note: Note): Set<String> =
buildSet {
addressOf(note)?.let { add(it.toValue()) }
note.event?.id?.let { add(it) }
add(note.idHex)
}
/**
* NIP-29 group migration: the same group id now lives on [newRelay] (moved or forked). Swap this
* group's entry in our kind-10009 for the one on [newRelay] and return the new channel, which the
* caller opens so its events load from the new relay.
*/
suspend fun moveRelayGroup(
channel: RelayGroupChannel,
newRelay: NormalizedRelayUrl,
): RelayGroupChannel {
val target = LocalCache.getOrCreateRelayGroupChannel(GroupId(channel.groupId.id, newRelay))
account.sendMyPublicAndPrivateOutbox(account.relayGroupList.move(channel, target))
return target
}
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
@@ -505,6 +548,11 @@ class AccountRelayGroupActions(
* re-parenting, we re-carry the group's current [parent] and full [children] list
* from its latest known metadata to keep the tree intact across a plain name/flag
* edit. Pass an explicit value to change them.
*
* A 9002 also carries "all the fields of group-metadata", so the current 39000's [banner]
* (unless replaced) and every tag this form doesn't manage (`livekit`, `supported_kinds`, a
* newer spec field…) ride along verbatim — otherwise a rename would erase them on a relay that
* rebuilds the metadata from the edit.
*/
suspend fun editRelayGroupMetadata(
channel: RelayGroupChannel,
@@ -519,6 +567,7 @@ class AccountRelayGroupActions(
geohashes: List<String> = emptyList(),
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
banner: String? = channel.bannerPicture(),
) {
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
@@ -530,12 +579,16 @@ class AccountRelayGroupActions(
name = name,
about = about,
picture = picture,
banner = banner,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
children = children,
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
// Buzz honours only its own subset of tags on a 9002 and stamps relay-internal ones on
// its 39000, so don't echo those back; a NIP-29 relay gets the unmanaged tags verbatim.
extraTags = if (isBuzz) emptyList() else channel.event?.unmanagedTags() ?: emptyList(),
)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
@@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -84,6 +83,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
@@ -0,0 +1,107 @@
/*
* 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.model.nip51Lists.favoriteFollowSetsLists
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListDecryptionCache
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
/**
* The account's NIP-51 kind 10021 "Favorite follow sets": kind:30000 follow sets, the user's own
* or anyone else's, pinned so they show up as feeds in the top-nav picker.
*/
class FavoriteFollowSetsListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: FavoriteFollowSetsListDecryptionCache,
val scope: CoroutineScope,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
val favoriteFollowSetsListNote = cache.getOrCreateAddressableNote(getFavoriteFollowSetsListAddress())
fun getFavoriteFollowSetsListAddress() = FavoriteFollowSetsListEvent.createAddress(signer.pubKey)
fun getFavoriteFollowSetsListFlow(): StateFlow<NoteState> = favoriteFollowSetsListNote.flow().metadata.stateFlow
fun getFavoriteFollowSetsList(): FavoriteFollowSetsListEvent? = favoriteFollowSetsListNote.event as? FavoriteFollowSetsListEvent
suspend fun favoriteFollowSets(note: Note): Set<Address> {
val event = note.event as? FavoriteFollowSetsListEvent ?: return emptySet()
return decryptionCache.favoriteFollowSets(event)
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<Address>> =
getFavoriteFollowSetsListFlow()
.transformLatest { noteState ->
emit(favoriteFollowSets(noteState.note))
}.onStart {
emit(favoriteFollowSets(favoriteFollowSetsListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
val flowNotes: StateFlow<List<AddressableNote>> =
flow
.map { addresses ->
addresses.map { cache.getOrCreateAddressableNote(it) }
}.onStart {
emit(flow.value.map { cache.getOrCreateAddressableNote(it) })
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun follow(followSet: AddressBookmark): FavoriteFollowSetsListEvent {
val list = getFavoriteFollowSetsList()
return if (list == null) {
FavoriteFollowSetsListEvent.create(followSet, false, signer)
} else {
FavoriteFollowSetsListEvent.add(list, followSet, false, signer)
}
}
suspend fun unfollow(followSet: Address): FavoriteFollowSetsListEvent? {
val list = getFavoriteFollowSetsList() ?: return null
return FavoriteFollowSetsListEvent.remove(list, followSet, signer)
}
}
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.model.nipA3PaymentTargets
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -28,12 +28,12 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
/**
* What payment rails would actually reach the recipient(s) of a zap.
@@ -29,14 +29,17 @@ import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_err
import com.vitorpamplona.amethyst.commons.resources.error_parsing_error_message
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
import com.vitorpamplona.amethyst.commons.resources.podcast_value_error_title
import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_not_supported
import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_requires_nwc
import com.vitorpamplona.amethyst.commons.resources.podcast_value_no_recipients
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
@@ -50,6 +53,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
/**
@@ -110,7 +114,14 @@ class V4VPaymentHandler(
// Keysend (node) recipients can only be paid over NWC.
if (nodeShares.isNotEmpty()) {
if (account.nip47SignerState.hasWalletConnectSetup()) {
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
if (defaultWalletMayKeysend()) {
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
} else {
onError(
loadStringRes(Res.string.podcast_value_error_title),
loadStringRes(Res.string.podcast_value_keysend_not_supported),
)
}
} else {
onError(
loadStringRes(Res.string.podcast_value_error_title),
@@ -140,6 +151,18 @@ class V4VPaymentHandler(
onProgress(1f)
}
/**
* `pay_keysend` lives in the NWC-04 extension. Only a wallet whose info event publishes an
* `extensions` tag without 04 (and doesn't list `pay_keysend` in its content) is skipped;
* a legacy wallet, or one whose info is unknown, is still asked, as before extensions
* existed. See [com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent.mayUseExtensionMethod].
*/
private suspend fun defaultWalletMayKeysend(): Boolean {
val walletUri = account.nip47SignerState.defaultWalletUri.value ?: return true
val info = withTimeoutOrNull(NwcSignerState.NIP44_NEGOTIATION_WAIT_MS) { account.nwcInfoCache.currentOrFetch(walletUri) }
return info?.mayUseExtensionMethod(NwcMethod.PAY_KEYSEND) != false
}
/** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */
private suspend fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
@@ -31,7 +31,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -36,6 +35,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -85,6 +86,8 @@ val AccountInfoAndListsFromKeyKinds2 =
Bolt12OfferListEvent.KIND,
FavoriteRelayListEvent.KIND,
InterestSetEvent.KIND,
// NIP-51 kind 10021: follow sets (anyone's) pinned as feeds in the top-nav picker.
FavoriteFollowSetsListEvent.KIND,
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers.
// Loaded up-front so "My Groups" and group memberships resolve immediately at login,
// without waiting for the groups screen to mount its own subscription.
@@ -89,6 +89,21 @@ fun observeUserNickname(
return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedNickname(user) })
}
/**
* The name the account knows [user] by, for display: the NIP-85 nickname, else the NIP-02 petname
* from the account's own follow list. Editing UIs should keep using [observeUserNickname].
*/
@Composable
fun observeUserDisplayNickname(
user: User,
accountViewModel: AccountViewModel,
): State<Nickname?> {
val userAssertions = accountViewModel.account.userAssertions
val flow = remember(user) { userAssertions.displayNicknameFlow(user) }
return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedDisplayNickname(user) })
}
@Composable
fun observeUserAboutMe(
user: User,
@@ -74,7 +74,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
@Composable
fun PaymentTargetsScreen(
@@ -23,9 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions.paymentTargets
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -62,7 +63,8 @@ class PaymentTargetsViewModel : ViewModel() {
type: String,
authority: String,
) {
val trimmedType = type.trim().lowercase()
// Stores the NIP-A3 spelling of known aliases (btc -> bitcoin, cashapp -> cashme).
val trimmedType = PaymentTargetTypes.canonical(type)
val trimmedAuthority = authority.trim()
if (trimmedType.isEmpty() || trimmedAuthority.isEmpty()) return
val target = PaymentTarget(trimmedType, trimmedAuthority)
@@ -55,8 +55,8 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.CustomEmojiChecker
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.util.njumpLink
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
@@ -306,7 +306,7 @@ fun RenderUserAsClickableText(
nav: INav,
) {
val userState by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val nickname by observeUserDisplayNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CreateClickableTextWithEmoji(
@@ -25,6 +25,7 @@ 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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -40,10 +41,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
@@ -51,6 +54,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_invite_only
import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_private
import com.vitorpamplona.amethyst.commons.resources.relay_group_field_banner
import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count
import com.vitorpamplona.amethyst.commons.resources.relay_group_open
import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink
@@ -135,6 +139,16 @@ private fun RelayGroupCardContent(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
// NIP-29 `banner`: a wide header strip across the top of the card, when the group has one.
val banner = channel.bannerPicture()?.takeIf { it.isNotBlank() }
if (banner != null && LocalDisplaySettings.current.showProfilePictures) {
AsyncImage(
model = banner,
contentDescription = stringRes(Res.string.relay_group_field_banner),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(72.dp),
)
}
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -124,8 +124,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.inlinePlaceholder
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview
@@ -982,7 +982,7 @@ private fun DisplayUserFromTag(
nav: INav,
) {
val meta by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val nickname by observeUserDisplayNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag") {
@@ -238,8 +238,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTarge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.paymentTargetStyleFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -248,6 +246,8 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
@@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserNameText
import com.vitorpamplona.amethyst.commons.ui.theme.StdButtonSizeModifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
@@ -103,7 +103,7 @@ fun UsernameDisplay(
accountViewModel: AccountViewModel,
) {
val userMetadata by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val nickname by observeUserDisplayNickname(baseUser, accountViewModel)
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") {
// the account's own nickname for this user wins over the user's metadata;
@@ -0,0 +1,89 @@
/*
* 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.note.types
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_follow_set_to_favorites
import com.vitorpamplona.amethyst.commons.resources.remove_follow_set_from_favorites
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
/**
* Star toggle that adds a kind:30000 follow set (anyone's) to the account's NIP-51 kind 10021
* favorite follow sets, which puts it in the top-nav feed picker; tapping again removes it.
*/
@Composable
fun FavoriteFollowSetToggle(
followSetNote: AddressableNote,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
iconSizeModifier: Modifier = Size20Modifier,
) {
if (!accountViewModel.isWriteable()) return
val favorites by accountViewModel.account.favoriteFollowSetsList.flow
.collectAsStateWithLifecycle()
val isFavorite = favorites.contains(followSetNote.address)
ClickableBox(
modifier = modifier,
onClick = {
if (isFavorite) {
accountViewModel.unfollowFavoriteFollowSet(followSetNote.address)
} else {
accountViewModel.followFavoriteFollowSet(
AddressBookmark(
address = followSetNote.address,
relayHint = followSetNote.relayHintUrl(),
),
)
}
},
) {
if (isFavorite) {
Icon(
symbol = MaterialSymbols.Star,
contentDescription = stringRes(Res.string.remove_follow_set_from_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
symbol = MaterialSymbols.StarBorder,
contentDescription = stringRes(Res.string.add_follow_set_to_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
@@ -67,13 +68,16 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
import com.vitorpamplona.amethyst.ui.components.rememberTranslation
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindName
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -100,7 +104,9 @@ fun RenderHighlight(
highlight = noteEvent.quote(),
context = noteEvent.contextOrReconstructed(),
authorHex = noteEvent.author(),
url = noteEvent.inUrl(),
url = noteEvent.inReference(),
externalId = remember(noteEvent) { noteEvent.inExternalIds().firstOrNull() },
externalIdText = remember(noteEvent) { noteEvent.inExternalIdValues().firstOrNull() },
textFragmentPrefix = selector?.prefix,
textFragmentSuffix = selector?.suffix,
postAddress = noteEvent.inPostAddress(),
@@ -176,6 +182,8 @@ fun DisplayHighlight(
postAddress: Address?,
postVersion: ETag?,
makeItShort: Boolean,
externalId: ExternalId? = null,
externalIdText: String? = null,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
@@ -251,6 +259,8 @@ fun DisplayHighlight(
textFragmentSuffix = textFragmentSuffix,
postAddress = postAddress,
postVersion = postVersion,
externalId = externalId,
externalIdText = externalIdText,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -306,6 +316,8 @@ private fun DisplayQuoteAuthor(
textFragmentSuffix: String? = null,
postAddress: Address?,
postVersion: ETag?,
externalId: ExternalId? = null,
externalIdText: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -362,15 +374,25 @@ private fun DisplayQuoteAuthor(
}
}
baseUrl != null -> {
// NIP-84: `r` may be a URL or any text naming the source; only a web address is a link.
baseUrl != null && HighlightEvent.isUrlReference(baseUrl) -> {
val url =
remember(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix) {
buildTextFragmentUrl(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix)
buildTextFragmentUrl(HttpUrlFormatter.addSchemeIfNeeded(baseUrl), highlightQuote, textFragmentPrefix, textFragmentSuffix)
}
DisplayEntryForAUrl(url, userBase, accountViewModel, nav)
}
// A NIP-73 source (`i` tag): a book, paper, podcast episode...
externalId != null -> {
DisplayEntryForExternalId(externalId, userBase, accountViewModel, nav)
}
baseUrl != null || externalIdText != null -> {
DisplayEntryForText(baseUrl ?: externalIdText ?: "", userBase, accountViewModel, nav)
}
userBase != null -> {
userBase?.let {
DisplayEntryForUser(it, accountViewModel, nav)
@@ -437,6 +459,35 @@ fun DisplayEntryForNote(
}
}
@Composable
fun DisplayEntryForExternalId(
externalId: ExternalId,
userBase: User?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (userBase != null) {
DisplayEntryForUser(userBase, accountViewModel, nav)
Text("-", maxLines = 1)
}
DisplayExternalId(externalId, accountViewModel, nav)
}
/** A source named in plain text (a non-URL `r` tag, or an `i` value no NIP-73 parser knows). */
@Composable
fun DisplayEntryForText(
source: String,
userBase: User?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (userBase != null) {
DisplayEntryForUser(userBase, accountViewModel, nav)
Text("-", maxLines = 1)
}
Text(source, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
@Composable
fun DisplayEntryForAUrl(
url: String,
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -83,17 +84,23 @@ fun DisplayPeopleList(
val name by remember(noteEvent) { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } }
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.weight(1f)
.padding(5.dp),
textAlign = TextAlign.Center,
)
(baseNote as? AddressableNote)?.let {
FavoriteFollowSetToggle(it, accountViewModel)
}
}
LaunchedEffect(noteEvent) {
accountViewModel.loadUsers(noteEvent.taggedUserIds()) {
@@ -140,6 +140,7 @@ class TopNavFilterState(
fun mergePeopleLists(
peopleLists: List<AddressableNote>,
followLists: List<AddressableNote>,
favoriteFollowSets: List<AddressableNote> = emptyList(),
): List<FeedDefinition> {
val peopleListsDefs =
peopleLists.map {
@@ -157,19 +158,36 @@ class TopNavFilterState(
)
}
return (peopleListsDefs + followListsDefs).sortedBy { it.name.name() }
// NIP-51 kind 10021: follow sets the user favorited, including other people's. The
// user's own sets are already listed above, so those are not repeated.
val listed = peopleLists.mapTo(HashSet()) { it.address }
val favoriteDefs =
favoriteFollowSets.mapNotNull {
if (it.address in listed) {
null
} else {
FeedDefinition(
TopFilter.PeopleList(it.address),
PeopleListName(it),
)
}
}
return (peopleListsDefs + followListsDefs + favoriteDefs).sortedBy { it.name.name() }
}
val livePeopleListsFlow: Flow<List<FeedDefinition>> =
combine(
account.followSets.peopleListNotes,
account.starterPacks.starterPackNotes,
account.favoriteFollowSetsList.flowNotes,
::mergePeopleLists,
).onStart {
emit(
mergePeopleLists(
account.followSets.peopleListNotes.value,
account.starterPacks.starterPackNotes.value,
account.favoriteFollowSetsList.flowNotes.value,
),
)
}
@@ -1726,6 +1726,12 @@ class AccountViewModel(
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) }
/** NIP-29 migration: point our kind-10009 entry for [channel] at [newRelay] instead. */
fun moveRelayGroup(
channel: RelayGroupChannel,
newRelay: NormalizedRelayUrl,
) = launchSigner { account.relayGroups.moveRelayGroup(channel, newRelay) }
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) }
@@ -1860,12 +1866,12 @@ class AccountViewModel(
fun pinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) }
) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note) }
fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) }
) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note) }
fun removeRelayGroupUser(
channel: RelayGroupChannel,
@@ -1935,6 +1941,10 @@ class AccountViewModel(
fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) }
fun followFavoriteFollowSet(followSet: AddressBookmark) = launchSigner { account.followFavoriteFollowSet(followSet) }
fun unfollowFavoriteFollowSet(followSet: Address) = launchSigner { account.unfollowFavoriteFollowSet(followSet) }
fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm)
fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) }
@@ -100,7 +100,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
@@ -118,6 +117,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
@@ -127,9 +127,6 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -161,6 +158,9 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import org.jetbrains.compose.resources.PluralStringResource
@@ -118,7 +118,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
@@ -149,6 +148,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import org.jetbrains.compose.resources.StringResource
@@ -38,8 +38,8 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.note.InnerUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderUserCards
import com.vitorpamplona.amethyst.ui.note.WatchUserFollows
@@ -84,7 +84,7 @@ private fun WatchAndDisplayUser(
nav: INav,
) {
val userState by observeUserInfo(author, accountViewModel)
val nickname by observeUserNickname(author, accountViewModel)
val nickname by observeUserDisplayNickname(author, accountViewModel)
val petName = nickname?.petName
// A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key.
val displayName = nameOverride ?: petName ?: userState?.info?.bestName()
@@ -70,7 +70,7 @@ fun ChannelFilterAssemblerSubscription(
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pinnedEventIds ?: channel.pinnedEventIds
val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pins ?: channel.pins
LaunchedEffect(pinnedIds) {
dataSource.invalidateFilters()
}
@@ -191,6 +191,12 @@ private fun ChannelView(
val jumpToNoteId = remember { mutableStateOf<String?>(null) }
Column(Modifier.fillMaxHeight()) {
RelayGroupMigrationBar(
channel = liveChannel,
accountViewModel = accountViewModel,
nav = nav,
)
RelayGroupPinnedBar(
channel = liveChannel,
accountViewModel = accountViewModel,
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_change_photo
import com.vitorpamplona.amethyst.commons.resources.relay_group_create_title
import com.vitorpamplona.amethyst.commons.resources.relay_group_edit_title
import com.vitorpamplona.amethyst.commons.resources.relay_group_field_about
import com.vitorpamplona.amethyst.commons.resources.relay_group_field_banner
import com.vitorpamplona.amethyst.commons.resources.relay_group_field_geohash
import com.vitorpamplona.amethyst.commons.resources.relay_group_field_geohash_hint
import com.vitorpamplona.amethyst.commons.resources.relay_group_field_name
@@ -212,6 +213,16 @@ private fun RelayGroupMetadataScaffold(
val context = LocalContext.current
val scrollState = rememberScrollState()
// NIP-29 §Subgroups relay support detection: `"nip29": { "subgroups": true }` in the NIP-11.
val subgroupsSupported by produceState(initialValue = false, viewModel.relay) {
val relay = viewModel.relay ?: return@produceState
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = relay,
onInfo = { info -> value = info.nip29?.subgroups == true },
onError = { _, _, _ -> value = false },
)
}
var wantsToPickImage by remember { mutableStateOf(false) }
if (wantsToPickImage) {
GallerySelectSingle(
@@ -281,8 +292,11 @@ private fun RelayGroupMetadataScaffold(
GroupMetadataFields(viewModel)
// Sub-groups are a NIP-29 relation; Buzz has no parent channel.
if (!viewModel.isBuzzRelay) {
// Sub-groups are a NIP-29 relation; Buzz has no parent channel. Offer the parent picker
// only when the relay advertises `nip29: { subgroups: true }` in its NIP-11 (otherwise
// it'd reject the `parent` tag), or when this group already has a parent so an admin
// can still detach it.
if (!viewModel.isBuzzRelay && (subgroupsSupported || viewModel.parentGroupId != null)) {
Spacer(Modifier.height(16.dp))
ParentGroupSection(viewModel, accountViewModel)
}
@@ -403,10 +417,22 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
)
// Everything below is NIP-29 vocabulary. A Buzz relay stores only `name`, `about` and a
// two-valued `visibility` (its 9002 handler accepts nothing else), so offering hashtags, a
// geohash, or the invite-only/restricted/hidden flags there would be four controls that look
// like they configure the channel and are silently dropped by the relay.
// two-valued `visibility` (its 9002 handler accepts nothing else), so offering a banner, hashtags,
// a geohash, or the invite-only/restricted/hidden flags there would be controls that look like
// they configure the channel and are silently dropped by the relay.
if (!viewModel.isBuzzRelay) {
OutlinedTextField(
value = viewModel.banner.value,
onValueChange = {
viewModel.banner.value = it
viewModel.markTouched()
},
singleLine = true,
label = { Text(stringRes(Res.string.relay_group_field_banner)) },
placeholder = { Text("https://…") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringRes(Res.string.relay_group_section_discovery),
@@ -65,7 +65,7 @@ import kotlin.coroutines.cancellation.CancellationException
/**
* Backs the create/edit NIP-29 group metadata screens. Holds the full editable metadata
* (name, about, picture, and the four status flags), lets the user pick a picture from the
* (name, about, picture, banner, and the four status flags), lets the user pick a picture from the
* gallery, uploads it to their configured media server on submit, then publishes the kind
* 9007+9002 (create) or 9002 (edit) events. Mirrors [EmojiPackMetadataViewModel].
*/
@@ -104,6 +104,9 @@ class RelayGroupMetadataViewModel : ViewModel() {
val about = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
/** NIP-29 `banner`: a wide header image URL for the group. */
val banner = mutableStateOf(TextFieldValue())
/** Comma/space-separated topic hashtags; drives the discovery hashtag filter. */
val topics = mutableStateOf(TextFieldValue())
@@ -172,6 +175,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
name.value = TextFieldValue(event?.name() ?: "")
about.value = TextFieldValue(event?.about() ?: "")
picture.value = TextFieldValue(event?.picture() ?: "")
banner.value = TextFieldValue(event?.banner() ?: "")
isPrivate = channel.isPrivate()
isClosed = channel.isClosed()
isHidden = event?.isHidden() ?: false
@@ -258,6 +262,10 @@ class RelayGroupMetadataViewModel : ViewModel() {
picture.value.text
.trim()
.ifBlank { null }
val banner =
banner.value.text
.trim()
.ifBlank { null }
val hashtags = parseTopics()
val geohashes = parseGeohashes()
val existing = channel
@@ -268,6 +276,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
name = name,
about = about,
picture = picture,
banner = banner,
isPrivate = isPrivate,
isClosed = isClosed,
isHidden = isHidden,
@@ -283,6 +292,8 @@ class RelayGroupMetadataViewModel : ViewModel() {
name = name,
about = about,
picture = picture,
// Buzz has no banner field: keep whatever the channel carries rather than clear it.
banner = if (isBuzzRelay) existing.bannerPicture() else banner,
isPrivate = isPrivate,
isClosed = isClosed,
isHidden = isHidden,
@@ -0,0 +1,192 @@
/*
* 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.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.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupAdminCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMigrationDetector
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_by_admins
import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_by_friends
import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_dismiss
import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_fork_title
import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_move
import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_moved_title
import com.vitorpamplona.amethyst.commons.resources.relay_group_migration_open
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
/** Cap on how many people's kind-10009 lists one open group fetches for the migration check. */
private const val MAX_WATCHED_LISTS = 24
/**
* NIP-29 §Detecting migrations and forks, for a group the user is in: watches the kind-10009 lists
* of the group's admins (live from kind 39001, plus the locally cached set so this still works while
* the host relay is down) and of the members the user follows. When any of them lists this group id
* on a different relay, a self-hiding bar tells the user the group may have moved (or forked) and
* offers to open it on the new relay, or to move there — which rewrites the user's own kind-10009.
*/
@Composable
fun RelayGroupMigrationBar(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val joined by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
if (channel.groupId !in joined) return
val liveAdmins = channel.admins
LaunchedEffect(channel.groupId, liveAdmins) {
RelayGroupAdminCache.remember(channel.groupId, liveAdmins.mapTo(HashSet()) { it.pubKey })
}
val cachedAdmins by RelayGroupAdminCache.flow.collectAsStateWithLifecycle()
val follows by accountViewModel.account.kind3FollowList.flow
.collectAsStateWithLifecycle()
val me = accountViewModel.userProfile().pubkeyHex
val admins =
remember(liveAdmins, cachedAdmins) {
liveAdmins.mapTo(HashSet()) { it.pubKey } + (cachedAdmins[channel.groupId.toKey()] ?: emptySet())
}
val friends = remember(channel.memberCount(), follows, admins) { channel.participatingFollows(follows.authors).toSet() - admins }
val watched = remember(admins, friends) { (admins + friends - me).take(MAX_WATCHED_LISTS) }
// Fetches (via the event finder) and observes each watched person's kind-10009.
val lists =
watched.map { pubkey ->
key(pubkey) {
val note = remember(pubkey) { LocalCache.getOrCreateAddressableNote(SimpleGroupListEvent.createAddress(pubkey)) }
observeNoteEvent<SimpleGroupListEvent>(note, accountViewModel).value
}
}
val relocations =
remember(lists, admins, friends) {
RelayGroupMigrationDetector.detect(channel.groupId, lists.filterNotNull(), admins, friends, me)
}
var dismissed by rememberSaveable(channel.groupId.toKey()) { mutableStateOf(emptyList<String>()) }
val relocation = relocations.firstOrNull { it.relay.url !in dismissed } ?: return
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column {
Column(Modifier.padding(start = 12.dp, end = 4.dp, top = 8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.SwapHoriz,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size(20.dp),
)
Column(Modifier.weight(1f)) {
Text(
text =
stringRes(
if (relocation.looksLikeMove) {
Res.string.relay_group_migration_moved_title
} else {
Res.string.relay_group_migration_fork_title
},
),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text =
stringRes(
if (relocation.admins.isNotEmpty()) {
Res.string.relay_group_migration_by_admins
} else {
Res.string.relay_group_migration_by_friends
},
relocation.relay.displayUrl(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { dismissed = dismissed + relocation.relay.url }) {
Text(stringRes(Res.string.relay_group_migration_dismiss))
}
TextButton(onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, relocation.relay.url)) }) {
Text(stringRes(Res.string.relay_group_migration_open))
}
TextButton(
onClick = {
accountViewModel.moveRelayGroup(channel, relocation.relay)
nav.nav(Route.RelayGroup(channel.groupId.id, relocation.relay.url))
},
) {
Text(stringRes(Res.string.relay_group_migration_move))
}
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
@@ -54,11 +54,13 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin
/**
* 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
* normal chat. When there are pins (`e` ids or `a` addresses) 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.
@@ -69,15 +71,23 @@ fun RelayGroupPinnedBar(
accountViewModel: AccountViewModel,
onJumpToNote: (Note) -> Unit,
) {
val pinnedIds = channel.pinnedEventIds
// The full pin list: `e` pins (regular events) and `a` pins (addressable events) interleaved.
val pinnedIds = channel.pins
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 currentPin = pinnedIds[safeIndex]
val note = remember(currentId) { LocalCache.checkGetOrCreateNote(currentId) } ?: return
// An `a` pin resolves to the addressable note, whose latest version loads by address.
val note =
remember(currentPin) {
when (currentPin) {
is AddressPin -> LocalCache.getOrCreateAddressableNote(currentPin.address)
is EventPin -> LocalCache.checkGetOrCreateNote(currentPin.eventId)
}
} ?: return
// Fetch + observe the pinned message so its author and content fill in once it loads.
val noteState by observeNote(note, accountViewModel)
@@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in PaymentTargetTypes.LIGHTNING_TYPES
@@ -72,7 +72,7 @@ fun inAppPaymentRouteFor(
/**
* The URI an external wallet app should receive for [target]: the type's own
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/$…`) and RFC 8905
* `payto://` for types Amethyst has no dedicated scheme for. Shared with the
* payment-target dialog so the same pill hands off to the same app wherever
* it is tapped.
@@ -159,6 +159,10 @@ fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
return when (type) {
"bitcoin", "btc", "onchain" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BITCOIN")
"bip352" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "SILENT PAYMENT")
"bip353" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BIP-353")
"lightning", "ln" ->
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LIGHTNING")
"lnurl" ->
@@ -183,8 +187,12 @@ fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
PaymentTargetStyle(walletIcon, SOLANA_PURPLE, "SOLANA")
"tron", "trx" ->
PaymentTargetStyle(walletIcon, TRON_RED, "TRON")
"cashapp" ->
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASHAPP")
"cashme", "cashapp" ->
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASH APP")
"nano", "xno" ->
PaymentTargetStyle(walletIcon, NANO_BLUE, "NANO")
"revolut" ->
PaymentTargetStyle(walletIcon, REVOLUT_BLUE, "REVOLUT")
"venmo" ->
PaymentTargetStyle(walletIcon, VENMO_BLUE, "VENMO")
"paypal" ->
@@ -213,4 +221,6 @@ private val TRON_RED = Color(0xFFEF0027)
private val CASHAPP_LIME = Color(0xFF00E64D)
private val VENMO_BLUE = Color(0xFF008CFF)
private val PAYPAL_DEEP_BLUE = Color(0xFF003087)
private val NANO_BLUE = Color(0xFF209CE9)
private val REVOLUT_BLUE = Color(0xFF0666EB)
private val GENERIC_TARGET_COLOR = Color(0xFF7C8DA0)
@@ -69,7 +69,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import kotlinx.coroutines.launch
@Composable
@@ -78,8 +78,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.rememberProfileClinkOffer
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress
import kotlinx.coroutines.launch
@@ -195,7 +195,6 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
@@ -324,6 +323,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
@@ -41,6 +41,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -54,20 +55,26 @@ 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRoleChips
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_members_count
import com.vitorpamplona.amethyst.commons.resources.relay_members_empty
import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code
import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code_hint
import com.vitorpamplona.amethyst.commons.resources.relay_members_join_sent
import com.vitorpamplona.amethyst.commons.resources.relay_members_leave_sent
import com.vitorpamplona.amethyst.commons.resources.relay_members_loading
import com.vitorpamplona.amethyst.commons.resources.relay_members_request_join
import com.vitorpamplona.amethyst.commons.resources.relay_members_request_leave
import com.vitorpamplona.amethyst.commons.resources.relay_members_title
import com.vitorpamplona.amethyst.commons.resources.relay_members_unverifiable
import com.vitorpamplona.amethyst.commons.resources.relay_members_you_are_member
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -83,6 +90,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.launch
@@ -97,34 +107,69 @@ fun RelayMembersScreen(
val normalizedRelayUrl = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) }
if (normalizedRelayUrl == null) return
var members by remember { mutableStateOf<List<HexKey>>(emptyList()) }
var members by remember { mutableStateOf<List<RelayMember>>(emptyList()) }
var roles by remember { mutableStateOf<Map<String, RelayRole>>(emptyMap()) }
var isLoading by remember { mutableStateOf(true) }
// The relay publishes no NIP-11 `self`, so nothing it serves can be verified as relay-signed.
var isUnverifiable by remember { mutableStateOf(false) }
var isMember by remember { mutableStateOf(false) }
var joinRequestSent by remember { mutableStateOf(false) }
var leaveRequestSent by remember { mutableStateOf(false) }
var inviteCode by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
// NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. Resolve it
// first (loading state meanwhile) and fetch once, by that author only. A relay that publishes no
// `self` gets an explanatory state instead of lists anyone could have signed.
LaunchedEffect(normalizedRelayUrl) {
launch(Dispatchers.IO) {
val filter =
Filter(
kinds = listOf(RelayMembershipListEvent.KIND),
limit = 1,
var relaySelf: HexKey? = null
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = normalizedRelayUrl,
onInfo = { relaySelf = it.self },
onError = { _, _, _ -> },
)
val self = relaySelf
if (self == null) {
members = emptyList()
roles = emptyMap()
isMember = false
isUnverifiable = true
isLoading = false
return@launch
}
val authors = listOf(self)
val filters =
listOf(
Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = authors, limit = 1),
Filter(kinds = listOf(RelayRoleEvent.KIND), authors = authors),
)
val events =
accountViewModel.account.client
.fetchAsFlow(normalizedRelayUrl, filter)
.fetchAsFlow(normalizedRelayUrl, filters)
.lastOrNull()
val membershipEvent =
events
?.mapNotNull { it as? RelayMembershipListEvent }
?.filter { it.pubKey == self }
?.maxByOrNull { it.createdAt }
val memberList = membershipEvent?.members() ?: emptyList()
roles =
events
?.mapNotNull { it as? RelayRoleEvent }
?.filter { it.pubKey == self }
?.groupBy { it.roleId() }
?.mapValues { (_, versions) -> versions.maxBy { it.createdAt }.role() }
?: emptyMap()
val memberList = membershipEvent?.membersWithRoles() ?: emptyList()
members = memberList
isMember = memberList.contains(accountViewModel.account.signer.pubKey)
isMember = memberList.any { it.pubKey == accountViewModel.account.signer.pubKey }
isUnverifiable = false
isLoading = false
}
}
@@ -162,9 +207,12 @@ fun RelayMembersScreen(
isLoading = isLoading,
joinRequestSent = joinRequestSent,
leaveRequestSent = leaveRequestSent,
inviteCode = inviteCode,
onInviteCodeChange = { inviteCode = it },
onJoinRequest = {
val claim = inviteCode.trim()
accountViewModel.launchSigner {
sendJoinRequest(normalizedRelayUrl, accountViewModel)
sendJoinRequest(normalizedRelayUrl, claim, accountViewModel)
joinRequestSent = true
}
},
@@ -190,7 +238,7 @@ fun RelayMembersScreen(
}
} else if (members.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -202,8 +250,9 @@ fun RelayMembersScreen(
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringRes(Res.string.relay_members_empty),
text = stringRes(if (isUnverifiable) Res.string.relay_members_unverifiable else Res.string.relay_members_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
} else {
@@ -215,13 +264,21 @@ fun RelayMembersScreen(
)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(members, key = { it }) { memberPubKey ->
val user = remember(memberPubKey) { accountViewModel.account.cache.getOrCreateUser(memberPubKey) }
UserCompose(
baseUser = user,
accountViewModel = accountViewModel,
nav = nav,
)
items(members, key = { it.pubKey }) { member ->
val user = remember(member.pubKey) { accountViewModel.account.cache.getOrCreateUser(member.pubKey) }
Column {
UserCompose(
baseUser = user,
accountViewModel = accountViewModel,
nav = nav,
)
// Role ids without a published 33534 definition still show, by id.
val memberRoles = remember(member, roles) { member.roles.map { roles[it] ?: RelayRole(it) } }
RelayRoleChips(
roles = memberRoles,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
)
}
}
}
}
@@ -235,9 +292,32 @@ fun MembershipActions(
isLoading: Boolean,
joinRequestSent: Boolean,
leaveRequestSent: Boolean,
inviteCode: String,
onInviteCodeChange: (String) -> Unit,
onJoinRequest: () -> Unit,
onLeaveRequest: () -> Unit,
) {
if (!isLoading && !isMember && !joinRequestSent) {
// NIP-43 join requests (kind 28934) must carry the invite code the relay issued.
Column(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp),
) {
Text(
text = stringRes(Res.string.relay_members_invite_code_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = inviteCode,
onValueChange = onInviteCodeChange,
label = { Text(stringRes(Res.string.relay_members_invite_code)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Row(
modifier =
Modifier
@@ -293,7 +373,7 @@ fun MembershipActions(
fontWeight = FontWeight.Bold,
)
} else {
Button(onClick = onJoinRequest) {
Button(onClick = onJoinRequest, enabled = inviteCode.isNotBlank()) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
@@ -316,6 +396,8 @@ private fun MembershipActionsNotMemberPreview() {
isLoading = false,
joinRequestSent = false,
leaveRequestSent = false,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -331,6 +413,8 @@ private fun MembershipActionsIsMemberPreview() {
isLoading = false,
joinRequestSent = false,
leaveRequestSent = false,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -346,6 +430,8 @@ private fun MembershipActionsJoinSentPreview() {
isLoading = false,
joinRequestSent = true,
leaveRequestSent = false,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -361,6 +447,8 @@ private fun MembershipActionsLeaveSentPreview() {
isLoading = false,
joinRequestSent = false,
leaveRequestSent = true,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -369,9 +457,10 @@ private fun MembershipActionsLeaveSentPreview() {
suspend fun sendJoinRequest(
relay: NormalizedRelayUrl,
claim: String,
accountViewModel: AccountViewModel,
) {
val template = RelayJoinRequestEvent.build()
val template = RelayJoinRequestEvent.build(claim)
val signedEvent = accountViewModel.account.signer.sign(template)
accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent)
accountViewModel.account.client.publish(signedEvent, setOf(relay))
@@ -82,8 +82,10 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_management_add
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_event
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_kind
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_pubkey
import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_pubkeys
import com.vitorpamplona.amethyst.commons.resources.relay_management_apply
@@ -96,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_block_ip
import com.vitorpamplona.amethyst.commons.resources.relay_management_blocked_ips
import com.vitorpamplona.amethyst.commons.resources.relay_management_cancel
import com.vitorpamplona.amethyst.commons.resources.relay_management_confirm
import com.vitorpamplona.amethyst.commons.resources.relay_management_disallowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_dismiss
import com.vitorpamplona.amethyst.commons.resources.relay_management_error
import com.vitorpamplona.amethyst.commons.resources.relay_management_event_id_hex
@@ -103,11 +106,13 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_ip_address
import com.vitorpamplona.amethyst.commons.resources.relay_management_kind_number
import com.vitorpamplona.amethyst.commons.resources.relay_management_loading
import com.vitorpamplona.amethyst.commons.resources.relay_management_moderation_queue
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_pubkeys
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_pubkeys
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_blocked_ips
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_disallowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_methods
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_moderation_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_reason_optional
@@ -288,12 +293,19 @@ private fun RelayManagementContent(
add(ManagementTab.PUBKEYS)
}
if (supportedMethods.any {
it in listOf(Nip86Method.BAN_EVENT, Nip86Method.LIST_BANNED_EVENTS, Nip86Method.ALLOW_EVENT, Nip86Method.LIST_EVENTS_NEEDING_MODERATION)
it in
listOf(
Nip86Method.BAN_EVENT,
Nip86Method.LIST_BANNED_EVENTS,
Nip86Method.ALLOW_EVENT,
Nip86Method.LIST_ALLOWED_EVENTS,
Nip86Method.LIST_EVENTS_NEEDING_MODERATION,
)
}
) {
add(ManagementTab.EVENTS)
}
if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS) }) {
if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS, Nip86Method.LIST_DISALLOWED_KINDS) }) {
add(ManagementTab.KINDS)
}
if (supportedMethods.any { it in listOf(Nip86Method.BLOCK_IP, Nip86Method.UNBLOCK_IP, Nip86Method.LIST_BLOCKED_IPS) }) {
@@ -571,8 +583,10 @@ private fun EventsTab(
supportedMethods: List<String>,
) {
val bannedEvents by viewModel.bannedEvents.collectAsState()
val allowedEvents by viewModel.allowedEvents.collectAsState()
val eventsNeedingModeration by viewModel.eventsNeedingModeration.collectAsState()
var showBanDialog by remember { mutableStateOf(false) }
var showAllowDialog by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(10.dp),
@@ -596,6 +610,7 @@ private fun EventsTab(
reason = entry.reason,
canAllow = supportedMethods.contains(Nip86Method.ALLOW_EVENT),
canBan = supportedMethods.contains(Nip86Method.BAN_EVENT),
// Approve: `allowevent` allow-lists the event (and lifts any ban).
onAllow = { viewModel.allowEvent(entry.id) },
onBan = { viewModel.banEvent(entry.id) },
)
@@ -616,16 +631,52 @@ private fun EventsTab(
if (bannedEvents.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_banned_events)) }
} else {
items(bannedEvents, key = { it.id }) { entry ->
items(bannedEvents, key = { "banned" + it.id }) { entry ->
HexEntryCard(
hex = entry.id,
reason = entry.reason,
showRemove = false,
onRemove = {},
showRemove = supportedMethods.contains(Nip86Method.UNBAN_EVENT),
onRemove = { viewModel.unbanEvent(entry.id) },
)
}
}
}
if (supportedMethods.contains(Nip86Method.LIST_ALLOWED_EVENTS)) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
SectionHeaderWithAdd(
stringRes(Res.string.relay_management_allowed_events),
showAdd = supportedMethods.contains(Nip86Method.ALLOW_EVENT),
onAdd = { showAllowDialog = true },
)
}
if (allowedEvents.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_events)) }
} else {
items(allowedEvents, key = { "allowed" + it.id }) { entry ->
HexEntryCard(
hex = entry.id,
reason = entry.reason,
showRemove = supportedMethods.contains(Nip86Method.UNALLOW_EVENT),
onRemove = { viewModel.unallowEvent(entry.id) },
)
}
}
}
}
if (showAllowDialog) {
HexInputDialog(
title = stringRes(Res.string.relay_management_allow_event),
label = stringRes(Res.string.relay_management_event_id_hex),
onConfirm = { hex, reason ->
viewModel.allowEvent(hex, reason.ifBlank { null })
showAllowDialog = false
},
onDismiss = { showAllowDialog = false },
)
}
if (showBanDialog) {
@@ -648,6 +699,7 @@ private fun KindsTab(
supportedMethods: List<String>,
) {
val allowedKinds by viewModel.allowedKinds.collectAsState()
val disallowedKinds by viewModel.disallowedKinds.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
LazyColumn(
@@ -665,7 +717,7 @@ private fun KindsTab(
if (allowedKinds.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_kinds)) }
} else {
items(allowedKinds, key = { it }) { kind ->
items(allowedKinds, key = { "allowed$it" }) { kind ->
KindEntryCard(
kind = kind,
showRemove = supportedMethods.contains(Nip86Method.DISALLOW_KIND),
@@ -673,6 +725,30 @@ private fun KindsTab(
)
}
}
// Read-only: NIP-86 has no "undisallow"; `allowkind` would also turn on the allow list.
if (supportedMethods.contains(Nip86Method.LIST_DISALLOWED_KINDS)) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
SectionHeaderWithAdd(
stringRes(Res.string.relay_management_disallowed_kinds),
showAdd = false,
onAdd = {},
)
}
if (disallowedKinds.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_disallowed_kinds)) }
} else {
items(disallowedKinds, key = { "disallowed$it" }) { kind ->
KindEntryCard(
kind = kind,
showRemove = false,
onRemove = {},
)
}
}
}
}
if (showAddDialog) {
@@ -29,11 +29,13 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BlockedIp
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.EventNeedingModeration
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -69,12 +71,18 @@ class RelayManagementViewModel(
private val _bannedEvents = MutableStateFlow<List<BannedEvent>>(emptyList())
val bannedEvents: StateFlow<List<BannedEvent>> = _bannedEvents
private val _allowedEvents = MutableStateFlow<List<AllowedEvent>>(emptyList())
val allowedEvents: StateFlow<List<AllowedEvent>> = _allowedEvents
private val _eventsNeedingModeration = MutableStateFlow<List<EventNeedingModeration>>(emptyList())
val eventsNeedingModeration: StateFlow<List<EventNeedingModeration>> = _eventsNeedingModeration
private val _allowedKinds = MutableStateFlow<List<Int>>(emptyList())
val allowedKinds: StateFlow<List<Int>> = _allowedKinds
private val _disallowedKinds = MutableStateFlow<List<Int>>(emptyList())
val disallowedKinds: StateFlow<List<Int>> = _disallowedKinds
private val _blockedIps = MutableStateFlow<List<BlockedIp>>(emptyList())
val blockedIps: StateFlow<List<BlockedIp>> = _blockedIps
@@ -147,6 +155,17 @@ class RelayManagementViewModel(
}
}
fun loadAllowedEvents() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listAllowedEvents())
if (response.error != null) {
_error.value = response.error
} else {
_allowedEvents.value = client.parseAllowedEvents(response)?.distinctBy { it.id } ?: emptyList()
}
}
}
fun loadEventsNeedingModeration() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listEventsNeedingModeration())
@@ -169,6 +188,17 @@ class RelayManagementViewModel(
}
}
fun loadDisallowedKinds() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listDisallowedKinds())
if (response.error != null) {
_error.value = response.error
} else {
_disallowedKinds.value = client.parseDisallowedKinds(response)?.distinctBy { it } ?: emptyList()
}
}
}
fun loadBlockedIps() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listBlockedIps())
@@ -189,7 +219,9 @@ class RelayManagementViewModel(
if (response.error != null) {
_error.value = response.error
} else {
// NIP-86: banning also drops the pubkey from the allow list.
loadBannedPubkeys()
loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() }
}
}
}
@@ -214,7 +246,9 @@ class RelayManagementViewModel(
if (response.error != null) {
_error.value = response.error
} else {
// NIP-86: allowing also lifts any ban on the pubkey.
loadAllowedPubkeys()
loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() }
}
}
}
@@ -236,6 +270,17 @@ class RelayManagementViewModel(
) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.banEvent(eventId, reason))
if (response.error != null) {
_error.value = response.error
} else {
reloadEventLists()
}
}
}
fun unbanEvent(eventId: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.unbanEvent(eventId))
if (response.error != null) {
_error.value = response.error
} else {
@@ -244,6 +289,10 @@ class RelayManagementViewModel(
}
}
/**
* Approves an event: NIP-86 `allowevent` puts it on the relay's event
* allow list and lifts any ban on it (it no longer means "unban").
*/
fun allowEvent(
eventId: String,
reason: String? = null,
@@ -253,11 +302,35 @@ class RelayManagementViewModel(
if (response.error != null) {
_error.value = response.error
} else {
loadEventsNeedingModeration()
reloadEventLists()
}
}
}
fun unallowEvent(eventId: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.unallowEvent(eventId))
if (response.error != null) {
_error.value = response.error
} else {
loadAllowedEvents()
}
}
}
private fun reloadEventLists() {
loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() }
loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() }
loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() }
}
private inline fun loadIfSupported(
method: String,
load: () -> Unit,
) {
if (_supportedMethods.value.contains(method)) load()
}
fun changeRelayName(newName: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.changeRelayName(newName))
@@ -292,6 +365,7 @@ class RelayManagementViewModel(
_error.value = response.error
} else {
loadAllowedKinds()
loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() }
}
}
}
@@ -303,6 +377,7 @@ class RelayManagementViewModel(
_error.value = response.error
} else {
loadAllowedKinds()
loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() }
}
}
}
@@ -337,12 +412,13 @@ class RelayManagementViewModel(
}
fun loadAllLists() {
val methods = _supportedMethods.value
if (methods.contains("listbannedpubkeys")) loadBannedPubkeys()
if (methods.contains("listallowedpubkeys")) loadAllowedPubkeys()
if (methods.contains("listbannedevents")) loadBannedEvents()
if (methods.contains("listeventsneedingmoderation")) loadEventsNeedingModeration()
if (methods.contains("listallowedkinds")) loadAllowedKinds()
if (methods.contains("listblockedips")) loadBlockedIps()
loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() }
loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() }
loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() }
loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() }
loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() }
loadIfSupported(Nip86Method.LIST_ALLOWED_KINDS) { loadAllowedKinds() }
loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() }
loadIfSupported(Nip86Method.LIST_BLOCKED_IPS) { loadBlockedIps() }
}
}
@@ -55,6 +55,9 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnamePath
import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
@@ -228,6 +231,12 @@ class SearchBarViewModel(
searchTerm
.debounce(400)
.mapLatest { term ->
// NIP-02 petname path (`~/erin/charlie`, `~npub1…/erin`, `~name@domain/erin`),
// walked over the follow lists already in the cache.
PetnamePath.parse(term)?.let { path ->
return@mapLatest resolvePetnamePath(path)
}
// NIP-05 resolution: user@domain or bare .bit domain
val nip05 =
if (term.contains('@')) {
@@ -293,6 +302,22 @@ class SearchBarViewModel(
}
}.flowOn(Dispatchers.IO)
private suspend fun resolvePetnamePath(path: PetnamePath): User? =
runCatching {
PetnameResolver
.resolve(
path = path,
currentUser = account.userProfile().pubkeyHex,
followListOf = { pubKey ->
(account.cache.getAddressableNoteIfExists(ContactListEvent.createAddress(pubKey))?.event as? ContactListEvent)?.tags
},
resolveNip05 = { identifier ->
Nip05Id.parse(identifier)?.let { nip05Client.get(it)?.pubkey }
},
)?.let { account.cache.getOrCreateUser(it) }
}.onFailure { if (it is CancellationException) throw it }
.getOrNull()
/**
* The routes the box opens on its own, which is now **only** an invite link.
*
@@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletViewModel
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
@Composable
fun NIP47SetupScreen(
@@ -38,9 +38,11 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_request_timed_out
import com.vitorpamplona.amethyst.commons.resources.wallet_request_timed_out_spoofed
import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_load_failed
import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_load_more_failed
import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_not_supported
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
@@ -59,6 +61,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessRe
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
@@ -72,6 +75,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
sealed class SendState {
@@ -572,6 +576,17 @@ class WalletViewModel : ViewModel() {
_isLoading.value = true
_error.value = null
_hasMoreTransactions.value = true
// list_transactions lives in the NWC-05 extension. Only a wallet that publishes an
// `extensions` tag without 05 (and without the method in its content) is skipped;
// legacy wallets are still asked. See NwcInfoEvent.mayUseExtensionMethod.
val info = withTimeoutOrNull(NwcSignerState.NIP44_NEGOTIATION_WAIT_MS) { acc.nwcInfoCache.currentOrFetch(walletUri) }
if (info?.mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS) == false) {
allTransactions.value = emptyList()
_hasMoreTransactions.value = false
_error.value = text(Res.string.wallet_transactions_not_supported)
_isLoading.value = false
return@launch
}
var requestId: HexKey? = null
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
@@ -242,7 +242,7 @@ private fun WebBookmarkCard(
onDismiss = { showEditDialog = false },
onSave = { url, title, description, tags ->
accountViewModel.launchSigner {
accountViewModel.account.sendWebBookmark(url, title, description, tags)
accountViewModel.account.sendWebBookmark(url, title, description, tags, editing = event)
}
showEditDialog = false
},
+10 -4
View File
@@ -453,8 +453,12 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy admin RELAY supported-methods` | List the NIP-86 methods the relay implements. |
| `amy admin RELAY ban-pubkey HEX [--reason R]` / `unban-pubkey HEX` / `list-banned-pubkeys` | Pubkey ban list. |
| `amy admin RELAY allow-pubkey HEX [--reason R]` / `unallow-pubkey HEX` / `list-allowed-pubkeys` | Pubkey allow list. |
| `amy admin RELAY ban-event ID [--reason R]` / `allow-event ID` / `list-banned-events` / `list-needing-moderation` | Event moderation. |
| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` | Kind allow list. |
| `amy admin RELAY ban-event ID [--reason R]` / `unban-event ID` / `list-banned-events` | Event ban list. `ban-event` also drops the id from the allow list; `unban-event` does not allow-list it. |
| `amy admin RELAY allow-event ID [--reason R]` / `unallow-event ID` / `list-allowed-events` / `list-needing-moderation` | Event allow list (approve an event: it also lifts any ban) and the moderation queue. |
| `amy admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]` / `edit-role ID …` / `delete-role ID` | NIP-43 member roles (kind 33534); `--color` is a hue 0–360. |
| `amy admin RELAY assign-role HEX ROLE` / `unassign-role HEX ROLE` | Give / take a role. |
| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. The role and claim methods only exist on relays that run NIP-43 (geode: `[membership] enabled = true`, which then publishes the 13534 / 33534 events); elsewhere they fail with `method not supported`. |
| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` / `list-disallowed-kinds` | Kind allow / deny lists. |
| `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. |
| `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. |
@@ -586,14 +590,16 @@ screen speaks.
| `amy relaygroup list` | Your joined groups, from your kind:10009 list (public + private). |
| `amy relaygroup browse RELAY` | Every group a relay hosts (its 39000-39003 directory). |
| `amy relaygroup info RELAY GID` | A group's metadata + admin/member roster. |
| `amy relaygroup create RELAY --name X [--about A] [--private] [--closed]` | Create a group (publishes 9007 + 9002); prints the new `group_id`. |
| `amy relaygroup create RELAY --name X [--about A] [--picture URL] [--banner URL] [--parent GID] [--private] [--closed]` | Create a group (publishes 9007 + 9002); prints the new `group_id`. `--parent` nests it under a subgroup-capable relay's group. |
| `amy relaygroup join RELAY GID [--code CODE]` | Request to join (9021) and add it to your kind:10009 list. |
| `amy relaygroup join naddr1…[?invite=CODE]` | Same, from NIP-29's shareable group identifier; the `?invite=` suffix becomes the join `code`. |
| `amy relaygroup leave RELAY GID` | Leave (9022) and drop it from your kind:10009 list. |
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads current visibility and changes only the axis you pass, so re-asserting one flag never resets the other. |
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. |
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. |
### Buzz workspaces (block/buzz — NIP-29 dialect)
+1 -1
View File
@@ -116,7 +116,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. Event tag shapes were verified byte-for-byte against the ngit reference implementation and the NIP-34 spec (`clone`/`web` as single multi-value tags, issue `p`-tag for maintainer routing, plain patch/PR `r` tags); the quartz readers stay tolerant of the legacy repeated form. See `quartz/…/nip34Git/GitNip34InteropTest`. |
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, NIP-43 roles + invite claims, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
| `serve` | `amy serve` | ✅ | Embeds **geode** (the standalone Ktor relay on quartz's relay-server code) — in-memory by default, `--db FILE` for SQLite, account is admin so `amy admin` works against it. NIP-86 + NIP-77 included. |
| `wallet` (NIP-60 Cashu) | `amy cashu` | ✅ | See the Cashu row above — full NIP-60/61 wallet + nutzaps. |
| `mcp` / `fs` / `spell` | — | 🆕 (niche) | MCP server, FUSE mount, MuSig2/FROST; some pull new deps. |
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import okhttp3.OkHttpClient
@@ -41,10 +42,15 @@ import okhttp3.OkHttpClient
* admin wss://relay ban-pubkey HEX [--reason R] / unban-pubkey HEX
* admin wss://relay allow-pubkey HEX [--reason R] / list-allowed-pubkeys
* admin wss://relay list-banned-pubkeys
* admin wss://relay ban-event ID [--reason R] / allow-event ID / list-banned-events
* admin wss://relay ban-event ID [--reason R] / unban-event ID / list-banned-events
* admin wss://relay allow-event ID [--reason R] / unallow-event ID / list-allowed-events
* admin wss://relay list-needing-moderation
* admin wss://relay create-role ID [--label L] [--description D] [--color HUE] [--order N]
* admin wss://relay edit-role ID [...] / delete-role ID
* admin wss://relay assign-role HEX ROLE / unassign-role HEX ROLE
* admin wss://relay create-claim CODE / delete-claim CODE / list-claims
* admin wss://relay change-name S / change-description S / change-icon URL
* admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds
* admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds / list-disallowed-kinds
* admin wss://relay block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips
*/
object AdminCommand {
@@ -58,16 +64,30 @@ object AdminCommand {
| admin RELAY allow-pubkey HEX [--reason R] allow-list a pubkey
| admin RELAY unallow-pubkey HEX [--reason R] remove a pubkey from the allow-list
| admin RELAY list-allowed-pubkeys list allowed pubkeys
| admin RELAY ban-event ID [--reason R] ban an event id
| admin RELAY allow-event ID [--reason R] allow an event id
| admin RELAY ban-event ID [--reason R] ban an event id (drops it from the allow-list)
| admin RELAY unban-event ID [--reason R] lift an event ban (does not allow-list it)
| admin RELAY list-banned-events list banned events
| admin RELAY allow-event ID [--reason R] allow-list an event id (lifts any ban)
| admin RELAY unallow-event ID [--reason R] remove an event id from the allow-list
| admin RELAY list-allowed-events list allow-listed events
| admin RELAY list-needing-moderation list events flagged for moderation
| admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]
| define a NIP-43 member role (hue 0-360)
| admin RELAY edit-role ID [--label L] [--description D] [--color HUE] [--order N]
| replace a role's definition
| admin RELAY delete-role ID delete a role
| admin RELAY assign-role HEX ROLE give a pubkey a role
| admin RELAY unassign-role HEX ROLE take a role from a pubkey
| admin RELAY list-claims list NIP-43 invite codes the relay accepts
| admin RELAY create-claim CODE create a NIP-43 invite code
| admin RELAY delete-claim CODE revoke a NIP-43 invite code
| admin RELAY change-name NAME set the relay's name
| admin RELAY change-description TEXT set the relay's description
| admin RELAY change-icon URL set the relay's icon
| admin RELAY allow-kind N allow an event kind
| admin RELAY disallow-kind N disallow an event kind
| admin RELAY list-allowed-kinds list allowed kinds
| admin RELAY list-disallowed-kinds list disallowed kinds
| admin RELAY block-ip IP [--reason R] block an IP address
| admin RELAY unblock-ip IP unblock an IP address
| admin RELAY list-blocked-ips list blocked IPs
@@ -86,7 +106,12 @@ object AdminCommand {
val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods")
val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.invalidRelayUrl(relayArg)
val p2 = args.positionalOrNull(2)
val p3 = args.positionalOrNull(3)
val reason = args.flag("reason")
val label = args.flag("label")
val description = args.flag("description")
val color = args.flag("color")
val order = args.flag("order")
args.rejectUnknown()
fun needArg(name: String): String? =
@@ -95,6 +120,19 @@ object AdminCommand {
null
}
fun needSecondArg(name: String): String? =
p3 ?: run {
Output.error("bad_args", "$method requires a $name argument")
null
}
val colorInt =
color?.let {
it.toIntOrNull()?.takeIf { hue -> RelayRole.isValidHue(hue) }
?: return Output.error("bad_args", "--color must be a hue between 0 and 360")
}
val orderInt = order?.let { it.toIntOrNull() ?: return Output.error("bad_args", "--order must be an integer") }
val request: Nip86Request =
when (method) {
"supported-methods" -> Nip86Request.supportedMethods()
@@ -105,15 +143,27 @@ object AdminCommand {
"unallow-pubkey" -> Nip86Request.unallowPubkey(needArg("pubkey") ?: return 2, reason)
"list-allowed-pubkeys" -> Nip86Request.listAllowedPubkeys()
"ban-event" -> Nip86Request.banEvent(needArg("event-id") ?: return 2, reason)
"allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason)
"unban-event" -> Nip86Request.unbanEvent(needArg("event-id") ?: return 2, reason)
"list-banned-events" -> Nip86Request.listBannedEvents()
"allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason)
"unallow-event" -> Nip86Request.unallowEvent(needArg("event-id") ?: return 2, reason)
"list-allowed-events" -> Nip86Request.listAllowedEvents()
"list-needing-moderation" -> Nip86Request.listEventsNeedingModeration()
"create-role" -> Nip86Request.createRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt)
"edit-role" -> Nip86Request.editRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt)
"delete-role" -> Nip86Request.deleteRole(needArg("role-id") ?: return 2)
"assign-role" -> Nip86Request.assignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2)
"unassign-role" -> Nip86Request.unassignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2)
"list-claims" -> Nip86Request.listClaims()
"create-claim" -> Nip86Request.createClaim(needArg("claim") ?: return 2)
"delete-claim" -> Nip86Request.deleteClaim(needArg("claim") ?: return 2)
"change-name" -> Nip86Request.changeRelayName(needArg("name") ?: return 2)
"change-description" -> Nip86Request.changeRelayDescription(needArg("description") ?: return 2)
"change-icon" -> Nip86Request.changeRelayIcon(needArg("icon-url") ?: return 2)
"allow-kind" -> Nip86Request.allowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer"))
"disallow-kind" -> Nip86Request.disallowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer"))
"list-allowed-kinds" -> Nip86Request.listAllowedKinds()
"list-disallowed-kinds" -> Nip86Request.listDisallowedKinds()
"block-ip" -> Nip86Request.blockIp(needArg("ip") ?: return 2, reason)
"unblock-ip" -> Nip86Request.unblockIp(needArg("ip") ?: return 2)
"list-blocked-ips" -> Nip86Request.listBlockedIps()
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
@@ -56,18 +57,24 @@ object RelayGroupCommands {
| relaygroup browse RELAY [--timeout S] every group a relay hosts
| relaygroup info RELAY GID [--timeout S] a group's metadata + roster
| relaygroup create RELAY --name NAME create a group (publishes 9007+9002)
| [--about A] [--private] [--closed]
| [--about A] [--picture URL] [--banner URL]
| [--parent GID] [--private] [--closed]
| relaygroup join RELAY GID [--code CODE] request to join (kind 9021)
| [--reason R]
| relaygroup join naddr1…[?invite=CODE] same, from a shared group identifier
| relaygroup leave RELAY GID leave (kind 9022)
| relaygroup message RELAY GID TEXT post a kind-9 chat to the group
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin);
| [--about A] [--private|--public] reads current visibility and only
| [--closed|--open] changes the axis you specify
| [--about A] [--picture URL] carries every field you don't pass
| [--banner URL] [--parent GID|--root] over from the current kind 39000
| [--private|--public] [--closed|--open]
| relaygroup invite RELAY GID --code CODE mint an invite code (kind 9009)
| relaygroup put-user RELAY GID PUBKEY add/promote a user (kind 9000)
| [--role admin|moderator]
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
| relaygroup pin RELAY GID REF pin an event (kind 9010); REF is a
| note1/nevent1/hex id or naddr1/kind:pk:d
| relaygroup unpin RELAY GID REF unpin it, keeping every other pin
""".trimMargin()
suspend fun dispatch(
@@ -77,7 +84,7 @@ object RelayGroupCommands {
route(
"relaygroup",
tail,
"relaygroup <list|browse|info|create|join|leave|message|edit|invite|put-user|remove-user> …",
"relaygroup <list|browse|info|create|join|leave|message|edit|invite|put-user|remove-user|pin|unpin> …",
help = USAGE,
routes =
mapOf(
@@ -92,10 +99,15 @@ object RelayGroupCommands {
"invite" to { rest -> RelayGroupModerationCommands.invite(dataDir, rest) },
"put-user" to { rest -> RelayGroupModerationCommands.putUser(dataDir, rest) },
"remove-user" to { rest -> RelayGroupModerationCommands.removeUser(dataDir, rest) },
"pin" to { rest -> RelayGroupModerationCommands.pin(dataDir, rest, pin = true) },
"unpin" to { rest -> RelayGroupModerationCommands.pin(dataDir, rest, pin = false) },
),
)
/** `relaygroup create RELAY --name NAME [--about A] [--private] [--closed]` → publishes 9007 + 9002. */
/**
* `relaygroup create RELAY --name NAME [--about A] [--picture URL] [--banner URL] [--parent GID]
* [--private] [--closed]` → publishes 9007 + 9002.
*/
private suspend fun create(
dataDir: DataDir,
rest: Array<String>,
@@ -105,6 +117,9 @@ object RelayGroupCommands {
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val name = args.flag("name") ?: return Output.error("bad_args", "relaygroup create requires --name")
val about = args.flag("about")
val picture = args.flag("picture")
val banner = args.flag("banner")
val parent = args.flag("parent")
val isPrivate = args.bool("private")
val isClosed = args.bool("closed")
args.rejectUnknown()
@@ -116,7 +131,7 @@ object RelayGroupCommands {
val createAck = ctx.publish(ctx.signer.sign(CreateGroupEvent.build(groupId)), target)
val status = groupStatus(isPrivate, isClosed)
val edit = GroupEditMetadataEvent.build(groupId, name = name, about = about, status = status)
val edit = GroupEditMetadataEvent.build(groupId, name = name, about = about, picture = picture, banner = banner, status = status, parent = parent)
val editAck = ctx.publish(ctx.signer.sign(edit), target)
// Track it in our own kind:10009 so `relaygroup list` shows it, matching
// the Android create flow (Account.createRelayGroup → follow).
@@ -127,6 +142,7 @@ object RelayGroupCommands {
"group_id" to groupId,
"relay" to relay.url,
"name" to name,
"parent" to parent,
"private" to isPrivate,
"closed" to isClosed,
"published" to (createAck.values.any { it.accepted } && editAck.values.any { it.accepted }),
@@ -138,30 +154,50 @@ object RelayGroupCommands {
}
/**
* `relaygroup join RELAY GROUP_ID [--code CODE] [--reason R]` — publishes the
* 9021 join request to the host relay AND adds the group to the caller's
* kind-10009 list (private item), so `relaygroup list` reflects it.
* `relaygroup join RELAY GROUP_ID [--code CODE] [--reason R]` or
* `relaygroup join naddr1…[?invite=CODE] [--reason R]` — publishes the 9021 join request to the
* host relay AND adds the group to the caller's kind-10009 list, so `relaygroup list` reflects it.
* The naddr form is NIP-29's shareable group identifier; its `?invite=` suffix becomes the `code`
* tag (an explicit `--code` wins).
*/
private suspend fun join(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "relaygroup join RELAY GROUP_ID [--code CODE]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val usage = "relaygroup join RELAY GROUP_ID [--code CODE] | relaygroup join naddr1…[?invite=CODE]"
val first = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val reference = GroupNAddrInvite.parseReference(first)
val relay: NormalizedRelayUrl
val groupId: String
if (reference != null) {
relay = reference.groupId.relayUrl
groupId = reference.groupId.id
} else {
if (first.trim().removePrefix("nostr:").startsWith("naddr")) {
return Output.error("bad_args", "not a NIP-29 group naddr (kind 39000 with a relay hint): $first")
}
groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
relay = normalizeGroupRelay(first) ?: return Output.error("bad_args", "invalid relay url: $first")
}
val code = args.flag("code") ?: reference?.inviteCode
Context.open(dataDir).use { ctx ->
ctx.prepare()
val join = GroupJoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = args.flag("code"))
val join = GroupJoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = code)
args.rejectUnknown()
val signed = ctx.signer.sign(join)
val ack = ctx.publish(signed, setOf(relay))
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
val listed = updateGroupList(ctx, relay, groupId, add = true)
Output.emit(
mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it.accepted }, "listed" to listed),
mapOf(
"group_id" to groupId,
"relay" to relay.url,
"code" to code,
"published" to ack.values.any { it.accepted },
"listed" to listed,
),
)
return 0
}
@@ -24,12 +24,18 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
/**
* Moderator/admin write verbs for a relay group (the relay is the final authority
@@ -38,20 +44,22 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent
*/
object RelayGroupModerationCommands {
/**
* `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]` → 9002.
* `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--picture URL] [--banner URL]
* [--parent GID|--root] [--private|--public] [--closed|--open]` → 9002.
*
* A kind-9002 edit re-asserts the group's status flags, so sending only one
* axis would silently reset the other (e.g. `--closed` on a private group
* would drop `private` and leak it public). To avoid that we read the group's
* current 39000 metadata and merge: each axis keeps its current value unless
* the caller explicitly changes it with the flag or its counter-flag.
* A kind-9002 edit re-asserts the whole group metadata ("all the fields of group-metadata"), so
* sending only one field would silently reset the rest (e.g. `--closed` on a private group would
* drop `private` and leak it public; a rename would drop the picture, banner, subgroup links and
* any tag we don't model). To avoid that we read the group's current 39000 metadata and merge:
* every field keeps its current value unless the caller explicitly changes it, and unknown tags
* (`livekit`, `supported_kinds`, …) ride along verbatim.
*/
suspend fun edit(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]"
val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--picture URL] [--banner URL] [--parent GID|--root] [--private|--public] [--closed|--open]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
@@ -98,9 +106,16 @@ object RelayGroupModerationCommands {
groupId,
name = args.flag("name") ?: meta?.name(),
about = args.flag("about") ?: meta?.about(),
status = groupStatus(isPrivate, isClosed),
picture = args.flag("picture") ?: meta?.picture(),
banner = args.flag("banner") ?: meta?.banner(),
status = groupStatus(isPrivate, isClosed) + carriedStatus(meta),
hashtags = meta?.hashtags() ?: emptyList(),
geohashes = meta?.geohashes()?.maxByOrNull { it.length }?.let { listOf(it) } ?: emptyList(),
// Subgroups: a 9002 without `parent` re-roots the group and one missing a `child` is
// rejected, so keep both unless the caller re-parents (--parent) or detaches (--root).
parent = if (args.bool("root")) null else args.flag("parent") ?: meta?.parent(),
children = meta?.children() ?: emptyList(),
extraTags = meta?.unmanagedTags() ?: emptyList(),
)
args.rejectUnknown()
val signed = ctx.signer.sign(template)
@@ -120,6 +135,118 @@ object RelayGroupModerationCommands {
}
}
/** The status flags the CLI has no switch for (`hidden`, `restricted`), carried from [meta]. */
private fun carriedStatus(meta: GroupMetadataEvent?): Set<GroupMetadataEvent.GroupStatus> =
buildSet {
if (meta?.isHidden() == true) add(GroupMetadataEvent.GroupStatus.HIDDEN)
if (meta?.isRestricted() == true) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
}
/**
* `relaygroup pin|unpin RELAY GROUP_ID REF` → 9010 update-pin-list. NIP-29 replaces the whole
* list, so this reads the group's current kind-39005 and re-submits it with REF added (at the
* end) or removed — every other pin, `e` or `a`, kept verbatim. REF is an event (`note1`,
* `nevent1`, 64-hex → `e`) or an addressable event (`naddr1`, `kind:pubkey:d` → `a`).
*
* The 39005 is only trusted when signed by the relay's NIP-11 `self` key, and a read that did not
* reach EOSE aborts instead of being taken as "no pins" — publishing a full replacement from a
* failed read would wipe every existing pin.
*/
suspend fun pin(
dataDir: DataDir,
rest: Array<String>,
pin: Boolean,
): Int {
val args = Args(rest)
val verb = if (pin) "pin" else "unpin"
val usage = "relaygroup $verb RELAY GROUP_ID REF"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val ref = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val target = GroupPin.fromReference(ref) ?: return Output.error("bad_args", "not an event id, nevent, naddr or kind:pubkey:d: $ref")
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
ctx.prepare()
// NIP-29: the 39005 is "signed by the relay keypair … as stated by the NIP-11 `self`".
// Without that key we cannot tell the real list from a forged one, so do not rewrite it.
val relayKey =
ctx.relayInfo(relay)?.self
?: return Output.error("no_relay_key", "could not read the NIP-11 self pubkey of ${relay.url}; refusing to rewrite the pin list")
val filter =
Filter(
kinds = listOf(GroupPinnedEvent.KIND),
authors = listOf(relayKey),
tags = mapOf("d" to listOf(groupId)),
limit = 1,
)
val current =
when (val read = readPinList(ctx.drainResult(mapOf(relay to listOf(filter)), 6_000), relay, relayKey)) {
is PinListRead.Found -> read.pins
is PinListRead.Failed -> return Output.error(read.code, read.detail)
}
val updated =
if (pin) {
if (current.any { it.ref == target.ref }) current else current + target
} else {
current.filter { it.ref != target.ref }
}
val signed = ctx.signer.sign(GroupUpdatePinListEvent.build(groupId, updated))
val ack = ctx.publish(signed, setOf(relay))
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"group_id" to groupId,
"relay" to relay.url,
"pins" to updated.map { it.ref },
"changed" to (updated.map { it.ref } != current.map { it.ref }),
"published" to ack.values.any { it.accepted },
),
)
return 0
}
}
/** The outcome of reading a group's current kind-39005 before a read-merge-write. */
internal sealed interface PinListRead {
class Found(
val pins: List<GroupPin>,
) : PinListRead
class Failed(
val code: String,
val detail: String,
) : PinListRead
}
/**
* The latest relay-signed 39005 in [result]; an empty list only when the relay answered (EOSE)
* and had none. A timeout maps to `timeout` (exit 124), any other unanswered read to
* `fetch_failed` (exit 1).
*/
internal fun readPinList(
result: FetchAllResult,
relay: NormalizedRelayUrl,
relayKey: HexKey,
): PinListRead {
val latest =
result.events
.map { it.second }
.filterIsInstance<GroupPinnedEvent>()
.filter { it.pubKey == relayKey }
.maxByOrNull { it.createdAt }
return when {
latest != null -> PinListRead.Found(latest.pins())
result.anyRelayServed -> PinListRead.Found(emptyList())
relay in result.stalled -> PinListRead.Failed("timeout", "${relay.url} did not answer the pin-list read; refusing to overwrite pins")
else -> PinListRead.Failed("fetch_failed", "could not read the pin list from ${relay.url} (${result.doneReasons[relay] ?: "no answer"}); refusing to overwrite pins")
}
}
/** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */
suspend fun invite(
dataDir: DataDir,
@@ -0,0 +1,85 @@
/*
* 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.cli
import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands
import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands.PinListRead
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DONE_REASON_EOSE
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* `relaygroup pin|unpin` re-submits the whole 39005 list, so a failed read must abort instead of
* being read as "no pins" (which would publish an empty replacement and wipe them).
*/
class RelayGroupPinReadTest {
private val relay = RelayUrlNormalizer.normalize("wss://groups.example.com")
private val relayKey = "aa".repeat(32)
private val stranger = "bb".repeat(32)
private val pinnedId = "cc".repeat(32)
private fun pinned(
author: String,
createdAt: Long,
) = GroupPinnedEvent(
id = "dd".repeat(32),
pubKey = author,
createdAt = createdAt,
tags = arrayOf(arrayOf("d", "gid"), arrayOf("e", pinnedId)),
content = "",
sig = "ee".repeat(64),
)
@Test
fun eoseWithoutAListMeansNoPins() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey)
assertEquals(emptyList(), assertIs<PinListRead.Found>(read).pins)
}
@Test
fun aTimedOutReadAbortsWithTimeout() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), emptyMap(), setOf(relay)), relay, relayKey)
assertEquals("timeout", assertIs<PinListRead.Failed>(read).code)
}
@Test
fun aClosedOrUnreachableReadAborts() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to "cannot:refused"), emptySet()), relay, relayKey)
assertEquals("fetch_failed", assertIs<PinListRead.Failed>(read).code)
}
@Test
fun onlyTheRelaySignedListCounts() {
val events = listOf(relay to pinned(stranger, 200), relay to pinned(relayKey, 100))
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(events, mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey)
assertEquals(listOf(pinnedId), assertIs<PinListRead.Found>(read).pins.map { it.ref })
}
@Test
fun aForgedListAloneIsNotAnAnswerWithoutEose() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(listOf(relay to pinned(stranger, 200)), emptyMap(), setOf(relay)), relay, relayKey)
assertEquals("timeout", assertIs<PinListRead.Failed>(read).code)
}
}
@@ -30,8 +30,6 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserCards
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
@@ -43,6 +41,8 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlin.concurrent.Volatile
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.model.backups
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
@@ -53,6 +52,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
/** Which of the account's backed-up events a conflict is about. Drives the dialog's wording. */
@@ -177,7 +177,6 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
@@ -292,6 +291,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
@@ -392,6 +392,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
@@ -3864,6 +3865,7 @@ open class EventCache :
is LiveChessDrawOfferEvent,
is InterestListEvent,
is FavoriteAlgoFeedsListEvent,
is FavoriteFollowSetsListEvent,
is IndexerRelayListEvent,
is InteractiveStoryPrologueEvent,
is InteractiveStorySceneEvent,
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
@@ -36,7 +37,10 @@ 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.GroupRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
@@ -96,11 +100,24 @@ class RelayGroupChannel(
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.
* Relay-signed pin list (kind 39005) in the relay's display order: `e` pins (regular
* events, by id) and `a` pins (addressable events, by address) interleaved. Pinning
* replaces the whole list, so this is always the full current set — and a pin/unpin must
* re-submit it intact, `a` entries and their extra tag values included.
*/
@Volatile
var pins: List<GroupPin> = emptyList()
private set
/** Only the `e`-tagged pinned event ids of [pins], in order. */
@Volatile
var pinnedEventIds: List<HexKey> = emptyList()
private set
/** Only the `a`-tagged pinned addresses of [pins], in order. */
@Volatile
var pinnedAddresses: List<Address> = emptyList()
private set
private var pinnedUpdatedAt: Long = 0
/**
@@ -165,7 +182,7 @@ class RelayGroupChannel(
members.isNotEmpty() ||
admins.isNotEmpty() ||
groupRoles.isNotEmpty() ||
pinnedEventIds.isNotEmpty()
pins.isNotEmpty()
/** A relay group lives on exactly one relay: its host. */
override fun relays() = setOf(groupId.relayUrl)
@@ -176,6 +193,9 @@ class RelayGroupChannel(
fun profilePicture(): String? = event?.picture()
/** The NIP-29 `banner` header image, when the relay's metadata carries one. */
fun bannerPicture(): String? = event?.banner()
fun isPrivate(): Boolean = event?.isPrivate() ?: false
/** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */
@@ -232,7 +252,10 @@ class RelayGroupChannel(
// 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()
val newPins = event.pins()
pins = newPins
pinnedEventIds = newPins.mapNotNull { (it as? EventPin)?.eventId }
pinnedAddresses = newPins.mapNotNull { (it as? AddressPin)?.address }
pinnedUpdatedAt = event.createdAt
updateChannelInfo()
}
@@ -245,7 +268,14 @@ class RelayGroupChannel(
updateChannelInfo()
}
fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds
/** Whether [ref] — an event id hex or a `kind:pubkey:d` address value — is pinned. */
fun isPinned(ref: String): Boolean = pins.any { it.ref == ref }
/** The current pin list with [pin] appended (unchanged if it's already pinned). */
fun pinsWith(pin: GroupPin): List<GroupPin> = if (isPinned(pin.ref)) pins else pins + pin
/** The current pin list without the entry for [ref], every other pin kept verbatim. */
fun pinsWithout(ref: String): List<GroupPin> = pins.filter { it.ref != ref }
/**
* NIP-29 timeline references (`previous` tag) for an event about to be sent to this
@@ -156,6 +156,19 @@ class RelayGroupListState(
)
}
/**
* NIP-29 group migration: replace [from]'s entry with the same group id on [to]'s relay, in one
* signed version of the list (the group keeps its id; only the relay hint changes).
*/
suspend fun move(
from: RelayGroupChannel,
to: RelayGroupChannel,
): SimpleGroupListEvent {
val target = GroupTag(to.groupId.id, to.groupId.relayUrl.url, to.event?.name() ?: from.event?.name())
val relayGroupList = getRelayGroupList() ?: return SimpleGroupListEvent.create(publicGroups = listOf(target), signer = signer)
return SimpleGroupListEvent.replace(relayGroupList, from.toGroupTag(), target, signer)
}
init {
settings.relayGroupList()?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved relay group list")
@@ -0,0 +1,182 @@
/*
* 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.commons.model.nip29RelayGroups
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Another relay that people this group trusts now list for the same group id — a sign the group
* moved there, or forked (NIP-29 §Detecting migrations and forks). [admins] are the group's
* (cached) admins pointing at [relay]; [friends] the user's follows doing so. [stillListsCurrent]
* holds everyone in either set who ALSO still lists the relay we use, which reads more like a
* replica/fork than a move.
*/
@Immutable
data class RelayGroupRelocation(
val relay: NormalizedRelayUrl,
val admins: Set<HexKey>,
val friends: Set<HexKey>,
val stillListsCurrent: Set<HexKey>,
) {
/** How many distinct people point at [relay]. */
val supporters: Int get() = (admins + friends).size
/** Everyone pointing there dropped our relay: most likely a migration rather than a fork. */
val looksLikeMove: Boolean get() = (admins + friends).all { it !in stillListsCurrent }
}
/**
* NIP-29 migration/fork detection. A group's identity is its id plus the relay that enforces it,
* so when the group's admins (or the user's friends) update their kind-10009 list to put the same
* id on another relay, the client should tell the user and offer to follow it there.
*
* Only public `group` tags are readable: other people's private items are encrypted to them.
*/
object RelayGroupMigrationDetector {
/**
* Scans [lists] (kind-10009 events of [admins] and [friends]; others are ignored) for entries of
* [current]'s group id that point at a different relay. Returns one [RelayGroupRelocation] per
* alternative relay, admin-backed ones first, then by number of supporters. [self] is skipped —
* our own list already tells us where we think the group is.
*/
fun detect(
current: GroupId,
lists: Iterable<SimpleGroupListEvent>,
admins: Set<HexKey>,
friends: Set<HexKey>,
self: HexKey? = null,
): List<RelayGroupRelocation> {
val adminsByRelay = HashMap<NormalizedRelayUrl, MutableSet<HexKey>>()
val friendsByRelay = HashMap<NormalizedRelayUrl, MutableSet<HexKey>>()
val stillCurrent = HashSet<HexKey>()
// Newest list per author only: an older version doesn't reflect where they are now.
val newest = HashMap<HexKey, SimpleGroupListEvent>()
lists.forEach { list ->
if (list.pubKey == self) return@forEach
if (list.pubKey !in admins && list.pubKey !in friends) return@forEach
val previous = newest[list.pubKey]
if (previous == null || list.createdAt > previous.createdAt) newest[list.pubKey] = list
}
newest.values.forEach { list ->
val author = list.pubKey
list.publicGroups().forEach { tag ->
if (tag.groupId != current.id) return@forEach
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@forEach
if (relay == current.relayUrl) {
stillCurrent.add(author)
} else if (author in admins) {
adminsByRelay.getOrPut(relay) { HashSet() }.add(author)
} else {
friendsByRelay.getOrPut(relay) { HashSet() }.add(author)
}
}
}
return (adminsByRelay.keys + friendsByRelay.keys)
.map { relay ->
val relayAdmins = adminsByRelay[relay] ?: emptySet()
val relayFriends = friendsByRelay[relay] ?: emptySet()
RelayGroupRelocation(relay, relayAdmins, relayFriends, (relayAdmins + relayFriends).filterTo(HashSet()) { it in stillCurrent })
}.sortedWith(compareByDescending<RelayGroupRelocation> { it.admins.size }.thenByDescending { it.supporters })
}
}
/**
* Last-known admin pubkeys per NIP-29 group, keyed by [GroupId.toKey]. NIP-29 asks clients to cache
* these so the migration check can still read the admins' kind-10009 lists when the host relay (the
* only source of the kind-39001 admin list) is down. A process-wide singleton mirrored to disk by
* [com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore].
*/
object RelayGroupAdminCache {
/**
* Most groups kept. The cache is device-global (shared by every account on the device) and is
* filled for any group whose migration bar is shown, so it is bounded by recency of change instead
* of by one account's kind-10009: past the cap, the entry changed least recently is dropped.
*/
const val MAX_GROUPS = 256
// Insertion order = recency of change (oldest first), so the cap trims from the front.
private val admins = MutableStateFlow<Map<String, Set<HexKey>>>(emptyMap())
val flow: StateFlow<Map<String, Set<HexKey>>> = admins
fun adminsOf(groupId: GroupId): Set<HexKey> = admins.value[groupId.toKey()] ?: emptySet()
/** Records the current admin set of [groupId] (no-op when unchanged or empty). */
fun remember(
groupId: GroupId,
pubkeys: Set<HexKey>,
) {
if (pubkeys.isEmpty()) return
val key = groupId.toKey()
while (true) {
val current = admins.value
if (current[key] == pubkeys) return
val next = LinkedHashMap(current)
next.remove(key)
next[key] = pubkeys
if (admins.compareAndSet(current, next.trimToCap())) return
}
}
/**
* Merges the map read from disk at startup UNDER the in-memory one: the disk read is async, so a
* [remember] that landed before it completed is newer and wins. Returns the merged map.
*/
fun restore(fromDisk: Map<String, Set<HexKey>>): Map<String, Set<HexKey>> {
while (true) {
val current = admins.value
val merged = LinkedHashMap<String, Set<HexKey>>(fromDisk.size + current.size)
merged.putAll(fromDisk)
for ((key, value) in current) {
merged.remove(key)
merged[key] = value
}
val next = merged.trimToCap()
if (admins.compareAndSet(current, next)) return next
}
}
private fun LinkedHashMap<String, Set<HexKey>>.trimToCap(): LinkedHashMap<String, Set<HexKey>> {
if (size > MAX_GROUPS) {
val oldestFirst = entries.iterator()
repeat(size - MAX_GROUPS) {
oldestFirst.next()
oldestFirst.remove()
}
}
return this
}
/** Test-only: clears the cache so unit tests don't leak state into each other. */
fun clearForTesting() {
admins.value = emptyMap()
}
}
@@ -0,0 +1,36 @@
/*
* 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.commons.model.nip51Lists.favoriteFollowSetsLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.favoriteFollowSetsSet
class FavoriteFollowSetsListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<FavoriteFollowSetsListEvent>(signer)
fun cachedFavoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteFollowSetsSet()
suspend fun favoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteFollowSetsSet()
}
@@ -22,12 +22,15 @@ package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnameResolver
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -40,6 +43,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlin.concurrent.Volatile
/**
* The account's own kind:30382 contact cards — one card per target user, signed
@@ -59,6 +63,33 @@ class UserAssertionsState(
) {
private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) }
// The account's own kind-3 follow list, whose `p` tags may carry NIP-02 petnames.
private val followListNote: AddressableNote by lazy { cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)) }
// pubkey -> petname for the latest follow list, rebuilt only when that list changes. An immutable
// snapshot published through a volatile field: the flows rebuild it on an IO thread while
// composition reads it through [cachedFollowListPetname], and a racing rebuild only costs a
// redundant (identical) index, never a torn one.
private class PetnameIndex(
val event: ContactListEvent,
val petnames: Map<HexKey, String>,
)
@Volatile
private var petnameIndex: PetnameIndex? = null
/** Looks [target] up in [followList]'s index, rebuilding it when stale. Parses tags: keep off the main thread. */
private fun followListPetname(
followList: ContactListEvent?,
target: HexKey,
): String? {
if (followList == null) return null
val index =
petnameIndex?.takeIf { it.event === followList }
?: PetnameIndex(followList, PetnameResolver.petnames(followList.tags)).also { petnameIndex = it }
return index.petnames[target]
}
fun createCardAddress(target: HexKey): Address = UserAssertionEvent.createAddress(signer.pubKey, target)
fun getCardNote(target: HexKey): AddressableNote = cache.getOrCreateAddressableNote(createCardAddress(target))
@@ -115,20 +146,64 @@ class UserAssertionsState(
}
/**
* The name to render for [target], per the NIP-81 policy: the nickname the
* account gave them wins over the profile's own display name, falling back
* to the short npub when neither exists.
* The NIP-02 petname the account's own follow list (kind 3) gives [target], if any. It is the
* fallback for display when the account has no NIP-85 nickname for them.
*/
fun followListPetnameFlow(target: User): Flow<String?> =
followListNote
.flow()
.metadata
.stateFlow
.map { followListPetname(it.note.event as? ContactListEvent, target.pubkeyHex) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
/**
* Synchronous counterpart of [followListPetnameFlow], for initial values in composition: it only
* reads the last index the flows built (possibly for the previous version of the follow list —
* the flow corrects it right after) and never parses the follow list on the calling thread.
*/
fun cachedFollowListPetname(target: User): String? = petnameIndex?.petnames?.get(target.pubkeyHex)
/**
* The name the account knows [target] by, for rendering: the NIP-85 nickname when it has a
* petname, otherwise the kind-3 (NIP-02) petname. Use [nicknameFlow] instead when editing
* the nickname, which must not pick up the follow-list fallback.
*/
fun displayNicknameFlow(target: User): Flow<Nickname?> =
combine(nicknameFlow(target), followListPetnameFlow(target), ::withFollowListFallback)
.distinctUntilChanged()
/** Synchronous counterpart of [displayNicknameFlow], from already-decrypted data. */
fun cachedDisplayNickname(target: User): Nickname? = withFollowListFallback(cachedNickname(target), cachedFollowListPetname(target))
private fun withFollowListFallback(
nickname: Nickname?,
followListPetname: String?,
): Nickname? =
if (nickname?.petName != null || followListPetname == null) {
nickname
} else {
// The card's tags can't describe a name that didn't come from the card.
Nickname(followListPetname, nickname?.summary, EmptyTagList)
}
/**
* The name to render for [target]: the NIP-85 nickname the account gave them (NIP-81 policy),
* then the account's NIP-02 follow-list petname — the user's own local name for the contact, so
* it too wins over the profile's self-chosen name — then the profile's display name, falling
* back to the short npub.
*/
fun displayNameFlow(target: User): Flow<String> =
combine(
target.metadata().flow,
nicknameFlow(target),
displayNicknameFlow(target),
) { info, nickname ->
nickname?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex()
}.distinctUntilChanged()
/** Synchronous first value for [displayNameFlow], from already-decrypted data. */
fun cachedDisplayName(target: User): String = cachedNickname(target)?.petName ?: target.toBestDisplayName()
fun cachedDisplayName(target: User): String = cachedDisplayNickname(target)?.petName ?: target.toBestDisplayName()
suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) }
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.model.payments
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
/**
* Picks the NIP-A3 payment targets a sender can hand off to when paying a note's
@@ -37,6 +37,9 @@ package com.vitorpamplona.amethyst.commons.model.payments
* and the installed-app probe (which needs the URI before it has an authority).
*/
object PaymentTargetTypes {
private const val CASHME = "cashme"
private const val DOLLAR = "\$"
/** Lightning-family types Amethyst can pay in-app through the Send Payment screen. */
val LIGHTNING_TYPES = setOf("lightning", "ln", "lnurl")
@@ -62,10 +65,14 @@ object PaymentTargetTypes {
"doge" to "dogecoin",
"sol" to "solana",
"trx" to "tron",
"xno" to "nano",
// NIP-A3 names the Cash App cashtag type `cashme`; `cashapp` is what
// Amethyst (and others) wrote before the spec settled on it.
"cashapp" to "cashme",
)
/** Types whose hand-off is a web page rather than a registered URI scheme. */
private val WEB_TYPES = setOf("cashapp", "venmo", "paypal")
private val WEB_TYPES = setOf("cashme", "venmo", "paypal", "revolut")
/** Types with a dedicated URI scheme, keyed by canonical name. */
private val SCHEMES =
@@ -82,16 +89,31 @@ object PaymentTargetTypes {
"dogecoin" to "dogecoin",
"solana" to "solana",
"tron" to "tron",
"nano" to "nano",
)
/**
* Types whose scheme carries the address in a query parameter instead of the path:
* a BIP-352 silent payment address goes in a BIP-321 `bitcoin:` URI's `sp` field.
*/
private val QUERY_SCHEMES =
mapOf(
"bip352" to "bitcoin:?sp=",
)
private val WEB_HOSTS =
mapOf(
"cashapp" to "https://cash.app/",
"cashme" to "https://cash.app/",
"venmo" to "https://venmo.com/",
"paypal" to "https://paypal.me/",
"revolut" to "https://revolut.me/",
)
/** Trims, lowercases and collapses known aliases onto one family name. */
/**
* Trims, lowercases and collapses known aliases onto one family name. The result is the
* NIP-A3 spelling where the spec lists one (`cashapp` -> `cashme`), so it is also what a
* new `payto` tag should carry.
*/
fun canonical(rawType: String): String {
val trimmed = rawType.trim().lowercase()
return ALIASES[trimmed] ?: trimmed
@@ -130,6 +152,12 @@ object PaymentTargetTypes {
val type = canonical(rawType)
val value = authority.trim()
SCHEMES[type]?.let { return "$it:$value" }
QUERY_SCHEMES[type]?.let { return "$it$value" }
if (type == CASHME) {
// cash.app/$cashtag: NIP-A3 describes the `$`-prefixed tag, but accept a bare one.
val cashtag = if (value.isEmpty() || value.startsWith(DOLLAR)) value else DOLLAR + value
return WEB_HOSTS.getValue(CASHME) + cashtag
}
WEB_HOSTS[type]?.let { return "$it$value" }
return "payto://$type/$value"
}
@@ -0,0 +1,102 @@
/*
* 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.commons.model.preferences
import androidx.compose.runtime.Stable
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupAdminCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for [RelayGroupAdminCache] — the last-known admins of the NIP-29 groups
* the user is in — so the migration/fork check can read those admins' kind-10009 lists even after a
* restart while the group's host relay is down. Mirrors [RelayGroupDeletionStore]: loads the saved
* map into the singleton on construction, then writes every later change back. Construct once.
*/
@Stable
class RelayGroupAdminCacheStore(
private val store: DataStore<Preferences>,
private val scope: CoroutineScope,
) {
init {
scope.launch {
// What the disk holds; only a map that differs from it is written back. Comparing instead
// of dropping the flow's first value also persists a remember() that raced the restore.
var onDisk = restoreFromDisk()
RelayGroupAdminCache.flow.collect {
if (it != onDisk) {
persist(it)
onDisk = it
}
}
}
}
/** Merges the saved map into the cache (in-memory entries win) and returns what the disk held. */
private suspend fun restoreFromDisk(): Map<String, Set<HexKey>> {
val fromDisk =
try {
store.data.first()[KEY]?.let(::decode) ?: emptyMap()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" }
emptyMap()
}
RelayGroupAdminCache.restore(fromDisk)
return fromDisk
}
private suspend fun persist(map: Map<String, Set<HexKey>>) {
try {
store.edit { prefs -> prefs[KEY] = encode(map) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupAdminCache") { "Error writing cached group admins: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("nip29.groupAdmins")
// One entry per group: "<group key>\t<pubkey>,<pubkey>…". Group keys are `id@relay-url`
// and pubkeys are hex, so neither contains a tab or a comma.
private const val SEP = '\t'
fun encode(map: Map<String, Set<HexKey>>): Set<String> = map.entries.mapTo(HashSet()) { (key, admins) -> key + SEP + admins.joinToString(",") }
fun decode(raw: Set<String>): Map<String, Set<HexKey>> =
raw
.mapNotNull { entry ->
val idx = entry.lastIndexOf(SEP)
if (idx <= 0) return@mapNotNull null
val admins = entry.substring(idx + 1).split(',').filterTo(HashSet()) { it.length == 64 }
if (admins.isEmpty()) null else entry.substring(0, idx) to admins
}.toMap()
}
}
@@ -66,5 +66,31 @@ fun filterRelayGroupState(
relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, ids = pinnedIds)) }
}
return directory + pins
// Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author filter per
// (kind, author) pair carrying every pinned `d` of that pair — the union of the per-address
// filters, in as few filters as the pins allow. No `since` either — the pin stays valid however
// old the latest version is.
val pinnedAddresses = channel.pinnedAddresses
val addressPins =
if (pinnedAddresses.isEmpty()) {
emptyList()
} else {
val byKindAndAuthor = pinnedAddresses.groupBy({ it.kind to it.pubKeyHex }, { it.dTag })
relays.flatMap { relay ->
byKindAndAuthor.map { (kindAndAuthor, dTags) ->
RelayBasedFilter(
relay = relay,
filter =
ExplainedFilter(
purpose = SubPurpose.RELAY_GROUPS,
kinds = listOf(kindAndAuthor.first),
authors = listOf(kindAndAuthor.second),
tags = mapOf("d" to dTags.distinct()),
),
)
}
}
}
return directory + pins + addressPins
}
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.relayClient.profile
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
@@ -33,6 +32,7 @@ import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
val UserProfileListKinds =
listOf(
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
@@ -37,6 +36,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -0,0 +1,169 @@
/*
* 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.commons.model.nip29RelayGroups
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** NIP-29 §Detecting migrations and forks, over other people's kind-10009 lists. */
class RelayGroupMigrationDetectorTest {
private val gid = "pizza"
private val current = GroupId(gid, RelayUrlNormalizer.normalize("wss://old.example.com"))
private val newRelay = RelayUrlNormalizer.normalize("wss://new.example.com")
private val forkRelay = RelayUrlNormalizer.normalize("wss://fork.example.com")
private val admin = "aa".repeat(32)
private val friend = "bb".repeat(32)
private val stranger = "cc".repeat(32)
private val me = "dd".repeat(32)
private fun list(
author: String,
createdAt: Long,
vararg entries: Pair<String, String>,
) = SimpleGroupListEvent(
"00".repeat(32),
author,
createdAt,
entries.map { (id, relay) -> arrayOf("group", id, relay) }.toTypedArray(),
"",
"22".repeat(64),
)
@AfterTest
fun cleanup() = RelayGroupAdminCache.clearForTesting()
@Test
fun adminMovedGroupIsReportedAsAMove() {
val found =
RelayGroupMigrationDetector.detect(
current,
listOf(list(admin, 10, gid to "wss://new.example.com/")),
admins = setOf(admin),
friends = emptySet(),
)
assertEquals(1, found.size)
assertEquals(newRelay, found[0].relay)
assertEquals(setOf(admin), found[0].admins)
assertTrue(found[0].looksLikeMove)
}
@Test
fun listingBothRelaysReadsAsAFork() {
val found =
RelayGroupMigrationDetector.detect(
current,
listOf(list(friend, 10, gid to "wss://old.example.com", gid to "wss://fork.example.com")),
admins = emptySet(),
friends = setOf(friend),
)
assertEquals(forkRelay, found.single().relay)
assertEquals(setOf(friend), found.single().friends)
assertFalse(found.single().looksLikeMove)
}
@Test
fun ignoresSameRelayOtherGroupsStrangersSelfAndOlderLists() {
val found =
RelayGroupMigrationDetector.detect(
current,
listOf(
list(admin, 5, gid to "wss://fork.example.com"), // superseded by the newer list below
list(admin, 10, gid to "wss://old.example.com/", "other" to "wss://new.example.com"),
list(stranger, 10, gid to "wss://new.example.com"),
list(me, 10, gid to "wss://new.example.com"),
),
admins = setOf(admin, me),
friends = setOf(friend),
self = me,
)
assertTrue(found.isEmpty(), "got $found")
}
@Test
fun adminBackedRelaysSortFirst() {
val friend2 = "ee".repeat(32)
val found =
RelayGroupMigrationDetector.detect(
current,
listOf(
list(friend, 10, gid to "wss://fork.example.com"),
list(friend2, 10, gid to "wss://fork.example.com"),
list(admin, 10, gid to "wss://new.example.com"),
),
admins = setOf(admin),
friends = setOf(friend, friend2),
)
assertEquals(listOf(newRelay, forkRelay), found.map { it.relay })
assertEquals(2, found[1].supporters)
}
@Test
fun adminCacheRoundTripsThroughItsDiskEncoding() {
RelayGroupAdminCache.remember(current, setOf(admin, friend))
RelayGroupAdminCache.remember(current, emptySet()) // an empty roster never wipes the cache
val restored = RelayGroupAdminCacheStore.decode(RelayGroupAdminCacheStore.encode(RelayGroupAdminCache.flow.value))
assertEquals(mapOf(current.toKey() to setOf(admin, friend)), restored)
assertEquals(setOf(admin, friend), RelayGroupAdminCache.adminsOf(current))
}
@Test
fun restoringFromDiskKeepsRememberCallsThatLandedFirst() {
val other = GroupId("other", newRelay)
// The async disk read completes after the UI already recorded a fresher roster.
RelayGroupAdminCache.remember(current, setOf(friend))
val merged = RelayGroupAdminCache.restore(mapOf(current.toKey() to setOf(admin), other.toKey() to setOf(stranger)))
assertEquals(setOf(friend), RelayGroupAdminCache.adminsOf(current), "the in-memory entry is newer and wins")
assertEquals(setOf(stranger), RelayGroupAdminCache.adminsOf(other), "disk-only entries are restored")
assertEquals(RelayGroupAdminCache.flow.value, merged)
}
@Test
fun adminCacheIsCappedDroppingTheLeastRecentlyChanged() {
val first = GroupId("g0", newRelay)
RelayGroupAdminCache.remember(first, setOf(admin))
repeat(RelayGroupAdminCache.MAX_GROUPS) { i -> RelayGroupAdminCache.remember(GroupId("g${i + 1}", newRelay), setOf(admin)) }
assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size)
assertTrue(RelayGroupAdminCache.adminsOf(first).isEmpty(), "the oldest entry is evicted")
assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay)))
// A restore past the cap trims too, keeping the in-memory (newer) entries.
val fromDisk = (0 until RelayGroupAdminCache.MAX_GROUPS).associate { GroupId("disk$it", newRelay).toKey() to setOf(friend) }
RelayGroupAdminCache.restore(fromDisk)
assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size)
assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay)))
}
}
@@ -0,0 +1,128 @@
/*
* 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.commons.model.nip85TrustedAssertions
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The display-name order: NIP-85 nickname, then the account's NIP-02 follow-list petname (the
* user's own local name for the contact, so it beats the profile's self-chosen name), then the
* profile's name, then the short npub.
*/
class UserAssertionsDisplayNameTest {
private val signer = NostrSignerInternal(KeyPair())
private val target = "aa".repeat(32)
private class MapCache : ICacheProvider {
private val context = UserContext { addr -> AddressableNote(addr) }
val users = HashMap<HexKey, User>()
val addressables = HashMap<Address, AddressableNote>()
override val relayHints = HintIndexer()
override fun getAnyChannel(note: Note): Channel? = null
override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey]
override fun countUsers(predicate: (String, User) -> Boolean): Int = 0
override fun getNoteIfExists(hexKey: HexKey): Note? = null
override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null
override fun getOrCreateAddressableNote(address: Address): AddressableNote = addressables.getOrPut(address) { AddressableNote(address) }
override fun getEventStream(): ICacheEventStream = error("unused")
override fun hasBeenDeleted(event: Any): Boolean = false
override fun getOrCreateUser(pubkey: HexKey): User = users.getOrPut(pubkey) { User(pubkey, context) }
override fun consumeEmbedded(event: Event) = Unit
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
private fun setProfileName(
user: User,
name: String,
) {
user.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, EmptyTagList, emptyList(), 1)
}
private fun loadFollowList(
cache: MapCache,
vararg tags: Array<String>,
) {
val event = ContactListEvent("11".repeat(32), signer.pubKey, 10, arrayOf(*tags), "", "22".repeat(64))
cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)).loadEvent(event, cache.getOrCreateUser(signer.pubKey), emptyList())
}
@Test
fun followListPetnameBeatsTheProfileName() =
runTest {
val cache = MapCache()
val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope))
val user = cache.getOrCreateUser(target)
setProfileName(user, "Profile Name")
assertEquals("Profile Name", state.displayNameFlow(user).first())
loadFollowList(cache, arrayOf("p", target, "", "bestie"))
assertEquals("bestie", state.displayNameFlow(user).first())
// The synchronous initial value reads the index the flow just built.
assertEquals("bestie", state.cachedDisplayName(user))
}
@Test
fun withoutAPetnameTheProfileNameShows() =
runTest {
val cache = MapCache()
val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope))
val user = cache.getOrCreateUser(target)
setProfileName(user, "Profile Name")
loadFollowList(cache, arrayOf("p", target))
assertEquals("Profile Name", state.displayNameFlow(user).first())
assertEquals("Profile Name", state.cachedDisplayName(user))
}
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.model.payments
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -76,6 +76,42 @@ class PaymentTargetTypesTest {
assertFalse(PaymentTargetTypes.isWebTarget("iban"))
}
@Test
fun specCashmeIsCashAppAndCashappStaysAnAlias() {
assertEquals("cashme", PaymentTargetTypes.canonical("cashapp"))
assertEquals("cashme", PaymentTargetTypes.canonical("CashMe"))
assertEquals("https://cash.app/\$vitor", PaymentTargetTypes.uriFor("cashme", "\$vitor"))
// NIP-A3 describes a $-prefixed cashtag; accept the bare name too.
assertEquals("https://cash.app/\$vitor", PaymentTargetTypes.uriFor("cashme", "vitor"))
assertTrue(PaymentTargetTypes.isWebTarget("cashme"))
assertEquals(PaymentTargetTypes.probeKeyFor("cashapp"), PaymentTargetTypes.probeKeyFor("cashme"))
}
@Test
fun specListedTypesUseTheirNativeScheme() {
assertEquals("bitcoin:bc1q", PaymentTargetTypes.uriFor("bitcoin", "bc1q"))
assertEquals("bitcoincash:qq1", PaymentTargetTypes.uriFor("bitcoincash", "qq1"))
assertEquals("ethereum:0xabc", PaymentTargetTypes.uriFor("ethereum", "0xabc"))
assertEquals("lightning:me@ln.tips", PaymentTargetTypes.uriFor("lightning", "me@ln.tips"))
assertEquals("litecoin:ltc1", PaymentTargetTypes.uriFor("litecoin", "ltc1"))
assertEquals("monero:4A", PaymentTargetTypes.uriFor("monero", "4A"))
assertEquals("nano:nano_1dctq", PaymentTargetTypes.uriFor("nano", "nano_1dctq"))
assertEquals("solana:So1", PaymentTargetTypes.uriFor("solana", "So1"))
assertEquals("tron:T9", PaymentTargetTypes.uriFor("tron", "T9"))
assertEquals("zcash:zs1", PaymentTargetTypes.uriFor("zcash", "zs1"))
// BIP-352 silent payment addresses travel in a BIP-321 bitcoin: URI's sp parameter.
assertEquals("bitcoin:?sp=sp1qq", PaymentTargetTypes.uriFor("bip352", "sp1qq"))
assertEquals("https://paypal.me/vitor", PaymentTargetTypes.uriFor("paypal", "vitor"))
assertEquals("https://venmo.com/vitor", PaymentTargetTypes.uriFor("venmo", "vitor"))
assertEquals("https://revolut.me/vitor", PaymentTargetTypes.uriFor("revolut", "vitor"))
}
@Test
fun specListedTypesWithoutASchemeUsePayto() {
// A BIP-353 name has to be resolved over DNS first; there is no URI scheme for it.
assertEquals("payto://bip353/vitor@example.com", PaymentTargetTypes.uriFor("bip353", "vitor@example.com"))
}
@Test
fun unknownTypesFallBackToPayto() {
assertEquals("payto://iban/DE75512108001245126199", PaymentTargetTypes.uriFor("IBAN", "DE75512108001245126199"))
@@ -125,4 +125,63 @@ class FilterRelayGroupStateTest {
assertNull(it.filter.tags!!["h"])
}
}
@Test
fun `address pins add a kind-author-d backfill filter`() {
val channel = RelayGroupChannel(groupId)
val author = "b".repeat(64)
channel.updatePinned(
GroupPinnedEvent(
id = "d".repeat(64),
pubKey = relaySignKey,
createdAt = 100L,
tags = arrayOf(arrayOf("d", "g1"), arrayOf("a", "30023:$author:article")),
content = "",
sig = sig,
),
)
val filters = filterRelayGroupState(channel, since = null)
assertEquals(3, filters.size, "state + pins filters plus one address back-fill, no id filter")
assertTrue(filters.none { it.filter.ids != null })
val addressFilter = filters.first { it.filter.authors != null }
assertEquals(relayA, addressFilter.relay)
assertEquals(listOf(30023), addressFilter.filter.kinds)
assertEquals(listOf(author), addressFilter.filter.authors)
assertEquals(listOf("article"), addressFilter.filter.tags!!["d"])
assertNull(addressFilter.filter.since)
}
@Test
fun `address pins sharing a kind and author collapse into one filter`() {
val channel = RelayGroupChannel(groupId)
val author = "b".repeat(64)
val other = "e".repeat(64)
channel.updatePinned(
GroupPinnedEvent(
id = "d".repeat(64),
pubKey = relaySignKey,
createdAt = 100L,
tags =
arrayOf(
arrayOf("d", "g1"),
arrayOf("a", "30023:$author:one"),
arrayOf("a", "30023:$author:two"),
arrayOf("a", "30023:$other:three"),
arrayOf("a", "30311:$author:live"),
),
content = "",
sig = sig,
),
)
val addressFilters = filterRelayGroupState(channel, since = null).filter { it.filter.authors != null }
assertEquals(3, addressFilters.size, "one filter per (kind, author), not per address")
val byKey = addressFilters.associateBy { it.filter.kinds!!.single() to it.filter.authors!!.single() }
assertEquals(listOf("one", "two"), byKey[30023 to author]!!.filter.tags!!["d"])
assertEquals(listOf("three"), byKey[30023 to other]!!.filter.tags!!["d"])
assertEquals(listOf("live"), byKey[30311 to author]!!.filter.tags!!["d"])
}
}
@@ -29,6 +29,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.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -391,4 +392,29 @@ class RelayGroupChannelTest {
assertEquals(1, c.threadCount())
assertTrue(c.threads.value.none { it.idHex == t1.idHex })
}
/**
* NIP-29 #2416: a pin list mixes `e` and `a` entries. Pinning or unpinning one entry must
* re-submit every OTHER entry intact — another client's `a` pin (with its relay hint) included.
*/
@Test
fun pinEditsPreserveOtherClientsAddressPins() {
val c = channel()
val addr = "30023:$alice:article"
val tags = arrayOf(arrayOf("d", gid), arrayOf("e", msg1), arrayOf("a", addr, "wss://hint.example/"), arrayOf("e", msg2))
c.updatePinned(EventFactory.create("00".repeat(32), relaySelf, 100, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) as GroupPinnedEvent)
assertEquals(listOf(msg1, addr, msg2), c.pins.map { it.ref })
assertEquals(listOf(msg1, msg2), c.pinnedEventIds)
assertEquals(listOf(addr), c.pinnedAddresses.map { it.toValue() })
assertTrue(c.isPinned(addr))
val afterUnpin = c.pinsWithout(msg1)
assertEquals(listOf(listOf("a", addr, "wss://hint.example/"), listOf("e", msg2)), afterUnpin.map { it.toTagArray().toList() })
val afterPin = c.pinsWith(EventPin(msg3))
assertEquals(listOf(msg1, addr, msg2, msg3), afterPin.map { it.ref })
assertEquals(c.pins, c.pinsWith(EventPin(msg2)), "re-pinning an already pinned id is a no-op")
assertTrue(c.hasRelaySignedState())
}
}
@@ -1751,6 +1751,14 @@
<string name="relay_group_field_name">Name</string>
<string name="relay_group_field_about">Description</string>
<string name="relay_group_field_picture">Group picture</string>
<string name="relay_group_field_banner">Banner image URL</string>
<string name="relay_group_migration_moved_title">This group may have moved</string>
<string name="relay_group_migration_fork_title">This group may have a fork</string>
<string name="relay_group_migration_by_admins">Its admins now list it on %1$s</string>
<string name="relay_group_migration_by_friends">People you follow now list it on %1$s</string>
<string name="relay_group_migration_open">Open there</string>
<string name="relay_group_migration_move">Move</string>
<string name="relay_group_migration_dismiss">Dismiss</string>
<string name="relay_group_add_photo">Add photo</string>
<string name="relay_group_change_photo">Change photo</string>
<string name="relay_group_section_discovery">Discovery</string>
@@ -2488,6 +2496,8 @@
<string name="select_list_to_filter">Select an option to filter the feed</string>
<string name="add_dvm_to_favorites">Add feed algorithm to favorites</string>
<string name="remove_dvm_from_favorites">Remove from favorites</string>
<string name="add_follow_set_to_favorites">Add follow set to favorite feeds</string>
<string name="remove_follow_set_from_favorites">Remove follow set from favorite feeds</string>
<string name="favorite_dvms_explainer">Feed algorithms you star appear here and as filter chips on the Home feed.</string>
<string name="favorite_dvms_empty_headline">Pin your favorite algorithms</string>
<string name="favorite_dvms_empty_step1">Tap "%1$s" below to browse algorithms.</string>
@@ -2833,8 +2843,13 @@
<string name="relay_management_no_moderation_events">No events needing moderation</string>
<string name="relay_management_banned_events">Banned Events</string>
<string name="relay_management_no_banned_events">No banned events</string>
<string name="relay_management_allowed_events">Allowed Events</string>
<string name="relay_management_no_allowed_events">No allowed events</string>
<string name="relay_management_allow_event">Allow Event</string>
<string name="relay_management_allowed_kinds">Allowed Kinds</string>
<string name="relay_management_no_allowed_kinds">No allowed kinds</string>
<string name="relay_management_disallowed_kinds">Disallowed Kinds</string>
<string name="relay_management_no_disallowed_kinds">No disallowed kinds</string>
<string name="relay_management_blocked_ips">Blocked IPs</string>
<string name="relay_management_no_blocked_ips">No blocked IPs</string>
<string name="relay_management_add">Add</string>
@@ -2867,6 +2882,9 @@
<string name="relay_members_join_sent">Join request sent</string>
<string name="relay_members_leave_sent">Leave request sent</string>
<string name="relay_members_you_are_member">You are a member</string>
<string name="relay_members_invite_code">Invite code</string>
<string name="relay_members_invite_code_hint">This relay admits members with an invite code from its operator</string>
<string name="relay_members_unverifiable">This relay does not publish its identity key (NIP-11 self), so its member list cannot be verified</string>
<string name="relay_membership_list">Relay membership list</string>
<string name="relay_member_added">Member added to relay</string>
<string name="relay_members_added">%1$d members added to relay</string>
@@ -4642,6 +4660,7 @@
<string name="podcast_trailer_upload_hint">Short audio or video preview</string>
<string name="podcast_value_error_title">Value-for-Value error</string>
<string name="podcast_value_keysend_requires_nwc">Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients.</string>
<string name="podcast_value_keysend_not_supported">Your wallet does not support keysend, so node recipients were skipped.</string>
<string name="podcast_value_no_recipients">This podcast has no payable value recipients.</string>
<string name="podcast_value_stream_requires_wallet">Connect a Nostr Wallet Connect or debit wallet to stream sats while listening.</string>
<string name="podcast_value_user_no_lnaddress">This user has no Lightning address</string>
@@ -5290,6 +5309,7 @@
</plurals>
<string name="wallet_transactions_load_failed">Could not load transactions</string>
<string name="wallet_transactions_load_more_failed">Could not load more transactions</string>
<string name="wallet_transactions_not_supported">Your wallet does not offer transaction history</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts or profiles have reports from your follows</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string>
<string name="web_bookmark_add_title">Add Web Bookmark</string>
@@ -20,18 +20,23 @@
*/
package com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -52,6 +57,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
/** NIP-43 kind 13534: the relay's current member list, shown as a count. */
@Composable
@@ -123,6 +129,52 @@ fun RelayLeaveRequestCard() {
)
}
/**
* The NIP-43 roles (kind 33534) a relay assigned to a member, as small labelled
* chips tinted with each role's hue. Roles without a label show their id; roles
* without a color use the theme's secondary container. Sorted by the roles'
* display `order`.
*/
@Composable
fun RelayRoleChips(
roles: List<RelayRole>,
modifier: Modifier = Modifier,
) {
if (roles.isEmpty()) return
val sorted = remember(roles) { roles.sortedWith(compareBy<RelayRole>({ it.order ?: Int.MAX_VALUE }, { it.label ?: it.id })) }
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
sorted.forEach { RelayRoleChip(it) }
}
}
@Composable
fun RelayRoleChip(role: RelayRole) {
val hue = role.color
val background =
if (hue != null && RelayRole.isValidHue(hue)) {
Color.hsv(hue.toFloat(), 0.45f, 0.85f)
} else {
MaterialTheme.colorScheme.secondaryContainer
}
val content = if (hue != null && RelayRole.isValidHue(hue)) Color.Black else MaterialTheme.colorScheme.onSecondaryContainer
Text(
text = role.label ?: role.id,
style = MaterialTheme.typography.labelSmall,
color = content,
maxLines = 1,
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(background)
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
@Composable
private fun RelayMemberEventCard(
icon: MaterialSymbol,
@@ -175,6 +227,20 @@ private fun RelayMembershipListCardPreview() {
}
}
@Preview
@Composable
private fun RelayRoleChipsPreview() {
ThemeComparisonColumn {
RelayRoleChips(
listOf(
RelayRole("28b7e50f", label = "king", color = 37, order = 1),
RelayRole("mod", label = "moderator", color = 200, order = 2),
RelayRole("plain"),
),
)
}
}
@Preview
@Composable
private fun RelayAddMemberCardPreview() {
+16 -1
View File
@@ -98,7 +98,8 @@ geode --version
Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools),
`[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search),
`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring),
`[http]` (NIP-FE limits) and `[admin]` (NIP-86 management). See the example
`[http]` (NIP-FE limits), `[admin]` (NIP-86 management), `[identity]` (the
relay's own key, NIP-11 `self`) and `[membership]` (NIP-43). See the example
file for every knob.
### Commands over HTTP (NIP-FE)
@@ -126,6 +127,20 @@ Streamed answers are gzipped for clients that send `Accept-Encoding: gzip`
`[http].gzip = false` turns it off. Quartz's `HttpRelayClient` does all of this
for clients, over OkHttp via `OkHttpRelayTransport` or any `HttpRelayTransport`.
## Membership (NIP-43)
With `[membership] enabled = true` geode is a members-only relay: the NIP-86
pubkey allow list is the member list and gates writes. Admins mint invite codes
with `createclaim` (e.g. `amy admin RELAY create-claim CODE`); a user joins by
sending a kind 28934 request carrying one (codes stay valid until
`deleteclaim`) and leaves with a kind 28936. The relay signs — with its
`[identity]` key, advertised as NIP-11 `self` — and serves kind 13534 (members
and their role ids), 33534 (roles from `createrole` / `editrole`; `deleterole`
publishes a NIP-09 deletion) and 8000 / 8001 (member added / removed), keeping
them in step with every join, leave and admin change. Off by default, and while
off the role / claim RPCs aren't offered. Design notes:
[`plans/2026-09-27-nip43-membership.md`](plans/2026-09-27-nip43-membership.md).
## Verbs
```
+29
View File
@@ -221,3 +221,32 @@ retry_after_seconds = 1
# state is in-memory only and forgotten on every restart. Convention
# is to place this next to the SQLite event-store file.
# state_file = "/var/lib/geode/events.db.admin.json"
[identity]
# The relay's own Nostr key — advertised as `self` in the NIP-11 doc and
# used to sign relay-authored events (NIP-43 member lists and roles).
# Either the key itself (nsec1… or 64-char hex; wins if both are set) …
# secret_key = "nsec1..."
# … or a file holding it, created with a fresh key (mode 0600) when
# missing. With neither set, and [membership] enabled, geode keeps a
# generated key at "<[admin].state_file>.relay-key" (in memory only,
# with a warning, when there is no state file).
# secret_key_file = "/var/lib/geode/relay.key"
[membership]
# NIP-43 relay membership. When enabled:
# - writes are members-only; the NIP-86 pubkey allow list IS the member
# list (allowpubkey / unallowpubkey / banpubkey change membership) and
# gates writes even while empty;
# - kind 28934 join requests carrying an invite code (NIP-86
# createclaim; codes stay valid until deleteclaim) add the author,
# kind 28936 leave requests (with a NIP-70 "-" tag) remove them;
# - the relay publishes, signed by [identity]: kind 13534 (members +
# their role ids), 33534 (roles from createrole/editrole; deleterole
# publishes a NIP-09 deletion), 8000 / 8001 (member added / removed);
# - NIP-11 advertises 43, and the NIP-86 role/claim methods are offered
# (they are not advertised at all while this is off).
# See geode/plans/2026-09-27-nip43-membership.md.
# enabled = false
# How far a join/leave request's created_at may be from the relay's clock.
# request_window_seconds = 300
+108
View File
@@ -0,0 +1,108 @@
# NIP-43 relay membership in geode
Status: implemented (2026-09-27).
PR #4236 added the NIP-86 role / invite-code RPCs (`createrole`, `editrole`,
`deleterole`, `assignrole`, `unassignrole`, `listclaims`, `createclaim`,
`deleteclaim`) and persisted their state in `BanStore` / `RuntimeConfig`, but
nothing consumed that state: geode advertised the methods and they were silent
no-ops. This plan makes them do what NIP-43 says.
## Survey
| Piece | Where | Status |
|---|---|---|
| NIP-43 event models (13534, 33534, 8000, 8001, 28934, 28936) | `quartz/nip43RelayMembers/*` | reused as-is |
| Roles, assignments, claims, allow list | `quartz/nip86RelayManagement/server/BanStore` | reused as-is |
| Local ingest bypassing policies | `NostrServer.ingest(event, skipVerify)` | reused (mirror path) |
| Hook for EVENTs addressed to the relay | — | **new**: `EventCommandHandler` in `quartz/nip01Core/relay/server` |
| Join / leave / republish engine | — | **new**: `quartz/nip43RelayMembers/server/RelayMembershipServer` (generic, any Quartz relay can use it) |
| Members-only write gate | `BanListPolicy` | extended: `membersOnly` |
| Role / claim RPC gating + post-RPC hook | `Nip86Server` | extended: `nip43Methods`, `afterMutation` |
| Relay key, config, NIP-11 `self` | geode `RelayEngine`, `StaticConfig`, `RelayIdentity`, `Main` | geode wiring |
## Decisions
**Membership is the NIP-86 pubkey allow list.** NIP-43 defines 13534 as "pubkeys
that have access to a given relay"; NIP-86's allow list is exactly the set of
pubkeys with write access, and NIP-86 already says `banpubkey` removes a pubkey
from it. Keeping one set avoids two lists drifting apart. So:
- join (28934) = `allowpubkey`; leave (28936) = `unallowpubkey`;
- `allowpubkey`, `unallowpubkey` and `banpubkey` over NIP-86 are membership
changes and produce 8000 / 8001 + a fresh 13534;
- `[authorization].pubkey_whitelist` seeds the initial members.
**Members-only means closed even when empty.** Plain NIP-86 treats an empty
allow list as "no restriction". With membership on, that would leave a fresh
relay wide open until its first join, and the first join would suddenly close it.
`BanListPolicy(membersOnly = true)` rejects every non-member write with
`restricted: …` regardless of list size. Relay-authored events use
`NostrServer.ingest` and never see the policy; join / leave requests are
consumed before it. Reads are not gated — use NIP-42 + a read policy for that.
**Invite codes are reusable until revoked.** Neither NIP spells out single-use.
NIP-86 calls `listclaims` "invite codes currently accepted by the relay" and
gives revocation its own method (`deleteclaim`); NIP-43 lets users mint claims
(`createclaim`) to share. Consuming a code on first use would make shared invite
links fail for the second person, so a code keeps working until an admin deletes
it. The allow-list reason records which code admitted each pubkey (audit trail).
**Roles are independent of membership.** Assignments live in `BanStore`
regardless of whether the pubkey is currently a member; 13534 lists only members
(with their roles). Leaving keeps assignments, so a returning member gets them
back — role assignment is an operator decision, not the member's.
**Publishing is a reconcile.** `RelayMembershipServer.sync()` diffs the
`BanStore` against what the relay last published (read back from its own store
on first sync, so restarts don't republish) and signs only the difference:
8000 / 8001 per pubkey entering / leaving, one 13534 when members or roles
changed, one 33534 per new / edited role, one NIP-09 kind 5 (`a` =
`33534:<self>:<id>`, `k` = 33534) per deleted role. Every mutation path — join,
admin RPC, a state file edited while offline — converges on the same events, and
repeated syncs are no-ops. The first sync on a fresh relay emits 8000 for each
seeded member (they were added) plus the initial 13534.
Replaceable events are stamped `max(now, previous + 1)` so two edits within one
second still supersede each other (a `created_at` tie would be won by the lower
id, not the newer event), and a re-created role is stamped after its deletion's
tombstone.
**deleterole → NIP-09.** 33534 is addressable; NIP-43 defines no removal, so the
relay deletes the address with a kind 5 it signs itself — the standard way for an
author to retract an addressable event, and what the store already enforces.
**Join / leave run ahead of the policy chain** (`EventCommandHandler`):
- signature and id verified (parallel verify means nothing upstream checked it);
- `created_at` within `[membership].request_window_seconds` (default 300) of now → else `invalid:`;
- join: `claim` tag required → else `restricted:`; banned → `restricted:`; already a member → `OK true "duplicate: …"`; unknown / revoked code → `restricted: that is an invalid invite code.`; success → `OK true "info: welcome to <url>!"`;
- leave: NIP-70 `-` tag required (the spec's MUST) → else `invalid:`; not a member → `OK true "duplicate: …"`; success → `OK true "info: you have left this relay."`;
- neither request is stored or fanned out — a join carries the invite code, and
both are ephemeral kinds anyway.
They don't require NIP-42 AUTH: the request's signature already proves the
author, and the relay is the recipient rather than a re-publisher (NIP-70's AUTH
rule is about accepting protected events for storage). A captured leave request
could be replayed within the window, which only re-removes the same user.
**Relay identity.** `[identity].secret_key` (nsec or hex) or
`[identity].secret_key_file` (created with a fresh key, mode 0600, if missing).
With membership on and neither set, the key is generated at
`<[admin].state_file>.relay-key`; with no state file either, an in-memory key
is used with a warning. When a key exists, NIP-11 `self` is forced to its pubkey
at boot (overriding a persisted doc). `43` is added to `supported_nips` iff
membership is on — and removed otherwise, since clients only send 28934 to
relays that advertise it.
**Off by default.** `[membership].enabled = false` keeps geode exactly as before
except that the eight role / claim RPCs are no longer advertised or accepted
(`method not supported`) — they were silent no-ops, which is what the review
flagged. Their persisted state is kept untouched in the state file.
## Not done
- Rate limiting join attempts (brute-forcing short invite codes).
- Read gating for members-only relays.
- Kind 28935 (invite request) — deprecated in the current NIP-43.
- Cleaning up 13534 / 33534 signed by a previous relay key after a key change.
@@ -22,6 +22,7 @@ package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.BannedEntry
import com.vitorpamplona.geode.config.MirrorFilterValidator
import com.vitorpamplona.geode.config.RelayIdentity
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.StaticConfig
@@ -83,8 +84,9 @@ import java.io.File
*
* Every section is enforced: `[info]` populates the NIP-11 doc,
* `[network]` controls the bind, `[database]` chooses the SQLite path,
* `[options]` toggles AUTH/verify/future-skew, and `[authorization]`
* seeds the runtime
* `[options]` toggles AUTH/verify/future-skew, `[identity]` holds the
* relay's own key (NIP-11 `self`), `[membership]` turns on NIP-43, and
* `[authorization]` seeds the runtime
* [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore] on
* first boot (see [com.vitorpamplona.geode.config.RuntimeConfig]).
*
@@ -335,6 +337,14 @@ private fun serve(args: Array<String>) {
maxSyncEvents = config.negentropy.max_sync_events,
maxSessionsPerConnection = config.negentropy.max_sessions_per_connection,
)
// The relay's own key (NIP-11 `self`): explicit config, else generated
// next to the admin state file when NIP-43 membership needs one.
val relayKey =
RelayIdentity.resolve(
identity = config.identity,
needed = config.membership.enabled,
stateFile = config.admin.state_file,
)
val relay =
RelayEngine(
advertisedUrl,
@@ -344,6 +354,9 @@ private fun serve(args: Array<String>) {
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
adminPubkeys = config.admin.pubkeys.toSet(),
relayKey = relayKey,
membership = config.membership.enabled,
membershipRequestWindowSeconds = config.membership.request_window_seconds,
)
val server =
KtorRelay(
@@ -497,6 +510,10 @@ private fun serve(args: Array<String>) {
println("geode listening on ${server.url}")
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path")
relay.relaySigner?.let { println("relay identity (NIP-11 self): ${it.pubKey}") }
if (relay.membership) {
println("NIP-43 membership on: members-only writes; join with a kind 28934 request carrying an invite code (NIP-86 createclaim)")
}
if (upstreams.isNotEmpty()) {
val trusted = upstreams.count { it.trusted }
println("mirroring ${upstreams.size} upstream relay(s), $trusted trusted (signature verification skipped)")
@@ -24,19 +24,28 @@ import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.seedInto
import com.vitorpamplona.geode.config.snapshotOf
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.server.RelayMembershipServer
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
/**
@@ -103,9 +112,37 @@ class RelayEngine(
* owns *who* is admin; the transport owns *how* admins authenticate.
*/
adminPubkeys: Set<HexKey> = emptySet(),
/**
* The relay's own identity. When set, the NIP-11 doc advertises its
* pubkey as `self` (overriding whatever the persisted doc says), and
* it signs the relay-authored NIP-43 events. Null (the default) leaves
* `self` as configured and the relay unable to sign anything.
*/
relayKey: KeyPair? = null,
/**
* NIP-43 membership (see `geode/plans/2026-09-27-nip43-membership.md`).
* When on — requires [relayKey] — the NIP-86 pubkey allow list is the
* member list and gates writes even while empty, kind 28934 / 28936
* join and leave requests are answered by [membershipServer], the
* relay publishes kinds 13534 / 33534 / 8000 / 8001 (and NIP-09
* deletions for removed roles) signed by [relayKey], the NIP-86 role
* and invite-code methods are offered, and NIP-11 advertises 43. Off
* (the default): none of that — the role / claim RPCs are not even
* advertised, and NIP-43 is stripped from `supported_nips`.
*/
val membership: Boolean = false,
/** How far a join / leave request's `created_at` may be from now. */
membershipRequestWindowSeconds: Long = RelayMembershipServer.DEFAULT_REQUEST_WINDOW_SECONDS,
) : AutoCloseable {
init {
require(!membership || relayKey != null) { "NIP-43 membership needs the relay's own key (relayKey) to sign its events" }
}
private val boot: RuntimeConfigData = runtimeConfig.effective()
/** Signs as the relay's NIP-11 `self`; null when no [relayKey] was configured. */
val relaySigner: NostrSignerSync? = relayKey?.let { NostrSignerSync(it) }
/**
* Live NIP-11 doc. Mutable via [updateInfo] so NIP-86 admin RPCs
* can swap it atomically; readers (NIP-11 GET) re-read every
@@ -115,9 +152,35 @@ class RelayEngine(
* empty NIP-11.
*/
@Volatile
var info: RelayInfo = RelayInfo(boot.info!!)
var info: RelayInfo = RelayInfo(boot.info!!.advertisingIdentity())
private set
/**
* Stamps the boot-time NIP-11 doc with what this engine actually runs:
* `self` = [relaySigner]'s pubkey, and NIP-43 in `supported_nips` iff
* [membership] is on — a persisted or operator-written doc may say
* otherwise, and clients only send join requests to relays that
* advertise 43.
*/
private fun Nip11RelayInformation.advertisingIdentity(): Nip11RelayInformation {
val nips = supported_nips
val doc =
when {
membership && (nips == null || NIP_43 !in nips) -> {
copy(supported_nips = ((nips ?: emptyList()) + NIP_43).sortedBy { it.toIntOrNull() ?: Int.MAX_VALUE })
}
!membership && nips != null && NIP_43 in nips -> {
copy(supported_nips = nips - NIP_43)
}
else -> {
this
}
}
return relaySigner?.let { doc.copy(self = it.pubKey) } ?: doc
}
/** Mutates the live NIP-11 doc and persists the snapshot. */
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
info = RelayInfo(transform(info.document))
@@ -131,8 +194,14 @@ class RelayEngine(
* seed on first boot) without firing the mutation hook.
*/
val banStore: BanStore =
BanStore(onMutation = ::snapshot)
.apply { boot.seedInto(this) }
BanStore(
onMutation = {
snapshot()
// Covers mutations outside the RPC / join paths (which
// sync synchronously) — e.g. direct BanStore calls.
membershipServer?.requestSync()
},
).apply { boot.seedInto(this) }
/**
* NIP-86 admin RPC dispatcher. Transport-agnostic — `KtorRelay`
@@ -150,6 +219,12 @@ class RelayEngine(
},
onBan = { filter -> store.delete(filter) },
allowList = adminPubkeys,
// Without a NIP-43 engine the role / claim methods would be
// silent no-ops, so they're only offered with membership on.
nip43Methods = membership,
// Republish before the RPC answers, so a client that reads the
// relay right after an admin change sees the new events.
afterMutation = { membershipServer?.sync() },
)
/**
@@ -174,12 +249,73 @@ class RelayEngine(
// BanListPolicy alone; otherwise stack so both must accept.
policyBuilder = {
val user = policyBuilder()
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
val banList = BanListPolicy(banStore, membersOnly = membership)
if (user === EmptyPolicy) banList else user + banList
},
parentContext = parentContext,
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
)
override fun close() = server.close()
/** Background scope for [RelayMembershipServer.requestSync]; cancelled on [close]. */
private val membershipScope = CoroutineScope(parentContext + SupervisorJob(parentContext[Job]))
/**
* The NIP-43 engine, when [membership] is on: answers join / leave
* requests on every connection and republishes the relay-signed
* membership events whenever the [banStore] changes.
*/
val membershipServer: RelayMembershipServer? =
if (membership) {
RelayMembershipServer(
signer = relaySigner!!,
banStore = banStore,
publish = ::publishOwn,
load = { filter -> store.query<Event>(filter) },
scope = membershipScope,
relayName = url.url,
requestWindowSeconds = membershipRequestWindowSeconds,
)
} else {
null
}
init {
membershipServer?.let {
server.eventCommandHandler = it
// Bring the stored 13534 / 33534 in line with the boot state
// (first boot, a key change, a hand-edited state file).
it.requestSync()
}
}
/**
* Stores a relay-authored event: through the group-commit writer and
* live fan-out like any publish, but skipping the policy chain (the
* relay's own events aren't subject to its write rules) and signature
* verification (we just signed it).
*/
private suspend fun publishOwn(event: Event): Boolean {
val outcome = CompletableDeferred<IEventStore.InsertOutcome>()
server.ingest(event, skipVerify = true) { outcome.complete(it) }
return when (val result = outcome.await()) {
IEventStore.InsertOutcome.Accepted -> {
true
}
else -> {
Log.w("RelayEngine") { "relay-signed kind ${event.kind} not stored: $result" }
false
}
}
}
override fun close() {
membershipScope.cancel()
server.close()
}
companion object {
private const val NIP_43 = "43"
}
}
@@ -0,0 +1,93 @@
/*
* 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.geode.config
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermissions
/**
* Resolves the relay's own key pair (NIP-11 `self`) from
* [StaticConfig.IdentitySection] — see its docs for the precedence.
*/
object RelayIdentity {
/**
* @param needed whether a feature that signs as the relay (NIP-43
* membership) is on; without it and without explicit config there
* is no identity (`null`).
* @param stateFile `[admin].state_file`, next to which a generated key
* is kept when [needed] and nothing else is configured.
* @param warn where to report falling back to an in-memory key.
*/
fun resolve(
identity: StaticConfig.IdentitySection,
needed: Boolean,
stateFile: String?,
warn: (String) -> Unit = { System.err.println("warning: $it") },
): KeyPair? {
identity.secret_key?.let { return parse(it, "[identity].secret_key") }
identity.secret_key_file?.let { return loadOrCreate(File(it)) }
if (!needed) return null
stateFile?.let { return loadOrCreate(File("$it$KEY_FILE_SUFFIX")) }
warn(
"no relay key configured ([identity].secret_key / secret_key_file, or [admin].state_file); " +
"using a throwaway identity — the NIP-11 self pubkey will change on every restart",
)
return KeyPair()
}
/** Reads the key in [file], or writes a newly generated one there (owner-only) when it's missing. */
fun loadOrCreate(file: File): KeyPair {
if (file.exists()) return parse(file.readText(), file.path)
val key = KeyPair()
file.absoluteFile.parentFile?.mkdirs()
val tmp = File(file.absoluteFile.parentFile, "${file.name}.tmp")
tmp.delete()
try {
Files.createFile(tmp.toPath(), PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")))
} catch (_: UnsupportedOperationException) {
// Not a POSIX filesystem (Windows): fall back to the default ACLs.
tmp.createNewFile()
}
tmp.writeText(key.privKey!!.toHexKey() + "\n")
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE)
return key
}
private fun parse(
value: String,
source: String,
): KeyPair {
val hex =
decodePrivateKeyAsHexOrNull(value.trim())?.takeIf { it.length == 64 }
?: throw IllegalArgumentException("$source is not a valid secret key (expected nsec1… or 64-char hex)")
return KeyPair(hex.hexToByteArray())
}
/** Suffix appended to `[admin].state_file` for the generated key file. */
const val KEY_FILE_SUFFIX = ".relay-key"
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.geode.config
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -36,7 +37,9 @@ import java.nio.file.StandardCopyOption
* - the live NIP-11 info doc (so `changerelayname/description/icon`
* survive a restart), and
* - the NIP-86 ban / allow / kind lists for pubkeys, events, and
* kinds (NIP-86 admin RPC mutates these directly).
* kinds (NIP-86 admin RPC mutates these directly), and
* - the NIP-43 role definitions, role assignments and invite codes
* managed through the NIP-86 role / claim methods.
*
* One JSON file per relay. Lives next to the SQLite event store by
* convention, but the path is configurable independently via
@@ -133,6 +136,33 @@ data class RuntimeConfigData(
val bannedEvents: List<BannedEntry> = emptyList(),
val allowedKinds: List<Int> = emptyList(),
val disallowedKinds: List<Int> = emptyList(),
val allowedEvents: List<BannedEntry> = emptyList(),
val roles: List<RoleEntry> = emptyList(),
val roleAssignments: List<RoleAssignmentEntry> = emptyList(),
val claims: List<String> = emptyList(),
)
/** A NIP-43 role definition (NIP-86 `createrole` params). */
@Serializable
data class RoleEntry(
val id: String,
val label: String? = null,
val description: String? = null,
val color: Int? = null,
val order: Int? = null,
) {
fun toRole() = RelayRole(id, label, description, color, order)
companion object {
fun of(role: RelayRole) = RoleEntry(role.id, role.label, role.description, role.color, role.order)
}
}
/** The NIP-43 role ids assigned to one pubkey. */
@Serializable
data class RoleAssignmentEntry(
val pubkey: String,
val roles: List<String>,
)
@Serializable
@@ -149,6 +179,10 @@ fun RuntimeConfigData.seedInto(banStore: BanStore) {
bannedEvents = bannedEvents.map { it.key to it.reason },
allowedKinds = allowedKinds,
disallowedKinds = disallowedKinds,
allowedEvents = allowedEvents.map { it.key to it.reason },
roles = roles.map { it.toRole() },
roleAssignments = roleAssignments.map { it.pubkey to it.roles },
claims = claims,
)
}
@@ -164,4 +198,8 @@ fun snapshotOf(
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
allowedKinds = banStore.listAllowedKinds(),
disallowedKinds = banStore.listDisallowedKinds(),
allowedEvents = banStore.listAllowedEvents().map { (k, r) -> BannedEntry(k, r) },
roles = banStore.listRoles().map { RoleEntry.of(it) },
roleAssignments = banStore.listRoleAssignments().map { (pk, ids) -> RoleAssignmentEntry(pk, ids) },
claims = banStore.listClaims(),
)
@@ -47,6 +47,8 @@ data class StaticConfig(
val options: OptionsSection = OptionsSection(),
val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
val identity: IdentitySection = IdentitySection(),
val membership: MembershipSection = MembershipSection(),
val negentropy: NegentropySection = NegentropySection(),
val http: HttpSection = HttpSection(),
/** `[[mirror]]` entries — upstream relays this relay streams from. */
@@ -349,6 +351,42 @@ data class StaticConfig(
val state_file: String? = null,
)
/**
* The relay's own Nostr identity — the NIP-11 `self` key that signs
* relay-authored events (NIP-43 membership lists, roles, add/remove).
* Resolved by [RelayIdentity.resolve]:
*
* - [secret_key]: the key itself, `nsec1…` or 64-char hex. Wins over
* [secret_key_file].
* - [secret_key_file]: a file holding the key (nsec or hex). Created
* with a freshly generated key (owner-only permissions) when it
* doesn't exist yet.
*
* Neither set: no identity, unless [MembershipSection.enabled] needs
* one — then the key lives in `<[AdminSection.state_file]>.relay-key`
* (generated on first boot), or, with no state file either, in memory
* only (a new identity every restart, with a warning).
*/
data class IdentitySection(
val secret_key: String? = null,
val secret_key_file: String? = null,
)
/**
* NIP-43 relay membership. [enabled] turns the relay members-only: the
* NIP-86 pubkey allow list becomes the member list (and gates writes
* even while empty), kind 28934 join requests carrying an invite code
* from NIP-86 `createclaim` add members, kind 28936 leave requests
* remove them, and the relay publishes signed kind 13534 / 33534 /
* 8000 / 8001 events. Needs the relay identity ([IdentitySection]).
* [request_window_seconds] bounds how far a join/leave request's
* `created_at` may drift from the relay's clock.
*/
data class MembershipSection(
val enabled: Boolean = false,
val request_window_seconds: Long = 300,
)
/**
* Boot-time sanity check for values the TOML types can't constrain.
* Throws [IllegalArgumentException] (fail-loud at startup) rather
@@ -367,6 +405,9 @@ data class StaticConfig(
database.readers?.let {
require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" }
}
require(membership.request_window_seconds > 0) {
"[membership].request_window_seconds must be > 0 (got ${membership.request_window_seconds})"
}
database.optimize_interval_seconds?.let {
require(it > 0) {
"[database].optimize_interval_seconds must be > 0 (got $it); a non-positive interval busy-loops PRAGMA optimize under the writer mutex"
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -39,6 +40,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonPrimitive
@@ -125,7 +127,7 @@ class Nip86EndToEndTest {
fun supportedMethodsListsTheServersMethods() {
rpc(Nip86Request.supportedMethods(), admin).use {
assertEquals(200, it.code)
val json = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
val json = JsonMapper.fromJson<Nip86Response>(it.body.string())
val arr = json.result as JsonArray
val names = arr.map { e -> e.jsonPrimitive.content }
assertTrue(names.contains("supportedmethods"))
@@ -169,7 +171,7 @@ class Nip86EndToEndTest {
// Admin bans them.
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use {
assertEquals(200, it.code)
val resp = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
val resp = JsonMapper.fromJson<Nip86Response>(it.body.string())
assertEquals(true, (resp.result as JsonPrimitive).boolean)
}
@@ -178,6 +180,33 @@ class Nip86EndToEndTest {
assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys")
}
@Test
fun allowEventLetsOneEventPastABanUntilUnallowed() =
runBlocking {
val relayUrl = server.url.normalizeRelayUrl()
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use { assertEquals(200, it.code) }
// Admin approves one specific event from the banned author.
val approved = targetUser.sign(TextNoteEvent.build("approved"))
rpc(Nip86Request.allowEvent(approved.id, "reviewed"), admin).use { assertEquals(200, it.code) }
assertEquals(true, nostrClient.publishAndConfirm(approved, setOf(relayUrl)), "allow-listed event bypasses the pubkey ban")
// Any other event from that author is still blocked.
val other = targetUser.sign(TextNoteEvent.build("other"))
assertEquals(false, nostrClient.publishAndConfirm(other, setOf(relayUrl)))
rpc(Nip86Request.listAllowedEvents(), admin).use {
val resp = JsonMapper.fromJson<Nip86Response>(it.body.string())
val ids = (resp.result as JsonArray).map { e -> (e as JsonObject)["id"]!!.jsonPrimitive.content }
assertEquals(listOf(approved.id), ids)
}
// unallowevent drops the exemption without banning the event.
rpc(Nip86Request.unallowEvent(approved.id), admin).use { assertEquals(200, it.code) }
assertTrue(!relay.banStore.isAllowedEvent(approved.id))
assertTrue(!relay.banStore.isBannedEvent(approved.id))
}
@Test
fun changeRelayNameFlowsToNip11Endpoint() {
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
@@ -24,6 +24,7 @@ import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
@@ -124,6 +125,47 @@ class RuntimeConfigTest {
}
}
@Test
fun eventAllowListRolesAndClaimsSurviveRestart() {
val pk = "b".repeat(64)
val allowedId = "c".repeat(64)
val r1 = relayPersisted()
try {
r1.banStore.allowEvent(allowedId, "approved")
r1.banStore.createRole(RelayRole("mod", label = "Moderator", description = "keeps order", color = 120, order = 2))
r1.banStore.assignRole(pk, "mod")
r1.banStore.createClaim("invite-123")
} finally {
r1.close()
}
val r2 = relayPersisted()
try {
assertEquals(listOf(allowedId to "approved"), r2.banStore.listAllowedEvents())
assertTrue(r2.banStore.isAllowedEvent(allowedId))
assertEquals(RelayRole("mod", "Moderator", "keeps order", 120, 2), r2.banStore.getRole("mod"))
assertEquals(listOf("mod"), r2.banStore.rolesOf(pk))
assertEquals(listOf("invite-123"), r2.banStore.listClaims())
} finally {
r2.close()
}
}
@Test
fun oldStateFileWithoutNewSectionsStillLoads() {
// A snapshot written before the event allow list / roles / claims existed.
stateFile.writeText("""{"info":{"name":"old"},"bannedEvents":[{"key":"${"d".repeat(64)}","reason":"x"}]}""")
val r = relayPersisted()
try {
assertTrue(r.banStore.isBannedEvent("d".repeat(64)))
assertEquals(emptyList(), r.banStore.listAllowedEvents())
assertEquals(emptyList(), r.banStore.listRoles())
assertEquals(emptyList(), r.banStore.listClaims())
} finally {
r.close()
}
}
@Test
fun corruptStateFileIsTolerated() {
stateFile.writeText("not valid json {")
@@ -0,0 +1,440 @@
/*
* 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.geode.membership
import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.RelayIndexingStrategy
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.geode.config.RelayIdentity
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.StaticConfig
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import com.vitorpamplona.quartz.nip70ProtectedEvts.isProtected
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* NIP-43 on geode: join / leave requests answered over the wire, NIP-86
* admin changes republished as relay-signed 13534 / 33534 / 8000 / 8001
* events, all signed by the NIP-11 `self` key, and the state surviving a
* restart.
*/
class Nip43MembershipTest {
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
private val relayKey = KeyPair()
private val admin = NostrSignerSync(KeyPair())
private val alice = NostrSignerSync(KeyPair())
private val bob = NostrSignerSync(KeyPair())
private lateinit var dir: File
private val engines = mutableListOf<RelayEngine>()
@BeforeTest
fun setup() {
dir = Files.createTempDirectory("geode-nip43-").toFile()
}
@AfterTest
fun teardown() {
engines.forEach { runCatching { it.close() } }
dir.deleteRecursively()
}
private fun relay(
membership: Boolean = true,
stateFile: File? = null,
store: EventStore? = null,
key: KeyPair = relayKey,
): RelayEngine =
RelayEngine(
url = url,
store = store ?: EventStore(dbName = null, relay = url, indexStrategy = RelayIndexingStrategy),
runtimeConfig = RuntimeConfig(stateFile, RuntimeConfigData(info = RelayInfo.default(url).document)),
adminPubkeys = setOf(admin.pubKey),
relayKey = key,
membership = membership,
).also { engines += it }
/** One client connection with a queue of the frames the relay sent it. */
private class Client(
relay: RelayEngine,
) {
private val inbox = Channel<String>(Channel.UNLIMITED)
private val session: RelaySession = relay.server.connect { inbox.trySend(it) }
/** Sends [event] and returns the relay's `OK` as (accepted, message). */
suspend fun publish(event: Event): Pair<Boolean, String> {
session.receive("[\"EVENT\",${event.toJson()}]")
return withTimeout(10_000) {
while (true) {
val frame = Json.parseToJsonElement(inbox.receive()).jsonArray
if (frame[0].jsonPrimitive.content == "OK" && frame[1].jsonPrimitive.content == event.id) {
return@withTimeout frame[2].jsonPrimitive.boolean to frame[3].jsonPrimitive.content
}
}
@Suppress("UNREACHABLE_CODE")
error("unreachable")
}
}
}
private suspend fun RelayEngine.rpc(req: Nip86Request): Nip86Response = nip86Server.dispatch(admin.pubKey, req)
private suspend fun RelayEngine.memberList(): RelayMembershipListEvent? =
store
.query<RelayMembershipListEvent>(Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = listOf(relaySigner!!.pubKey)))
.singleOrNull()
private suspend fun RelayEngine.roles(): List<RelayRoleEvent> = store.query(Filter(kinds = listOf(RelayRoleEvent.KIND)))
private suspend fun RelayEngine.added(): List<Event> = store.query(Filter(kinds = listOf(RelayAddMemberEvent.KIND)))
private suspend fun RelayEngine.removed(): List<Event> = store.query(Filter(kinds = listOf(RelayRemoveMemberEvent.KIND)))
private fun join(
signer: NostrSignerSync,
claim: String,
createdAt: Long = TimeUtils.now(),
) = signer.sign(RelayJoinRequestEvent.build(claim, createdAt))
private fun leave(
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
) = signer.sign(RelayLeaveRequestEvent.build(createdAt))
@Test
fun nip11SelfIsTheSigningKeyAndAdvertisesNip43() =
runBlocking {
val relay = relay()
val doc = relay.info.document
assertEquals(relayKey.pubKey.toHexKey(), doc.self)
assertEquals(relay.relaySigner!!.pubKey, doc.self)
assertTrue("43" in doc.supported_nips!!)
// Every relay-authored event is signed by that same key.
relay.membershipServer!!.sync()
val list = assertNotNull(relay.memberList())
assertEquals(doc.self, list.pubKey)
assertTrue(list.tags.isProtected(), "13534 must carry the NIP-70 - tag")
}
@Test
fun membershipOffKeepsTodaysBehaviour() =
runBlocking {
val relay = relay(membership = false)
assertFalse("43" in relay.info.document.supported_nips!!)
assertNull(relay.membershipServer)
val methods = (relay.rpc(Nip86Request.supportedMethods()).result as JsonArray).map { it.jsonPrimitive.content }
assertFalse(Nip86Method.CREATE_ROLE in methods, "role RPCs must not be advertised without a NIP-43 engine")
assertFalse(Nip86Method.CREATE_CLAIM in methods)
assertNotNull(relay.rpc(Nip86Request.createClaim("code")).error)
// Open relay: anyone writes; a 28934 is just an ephemeral event.
val client = Client(relay)
assertTrue(client.publish(alice.sign(TextNoteEvent.build("hi"))).first)
assertTrue(client.publish(join(alice, "code")).first)
assertNull(relay.memberList())
}
@Test
fun joinWithValidClaimAddsMemberAndPublishes() =
runBlocking {
val relay = relay()
val client = Client(relay)
assertNull(relay.rpc(Nip86Request.createClaim("invite-1")).error)
// Members-only: an outsider can't write before joining.
val (preOk, preMsg) = client.publish(alice.sign(TextNoteEvent.build("before")))
assertFalse(preOk)
assertTrue(preMsg.startsWith("restricted:"), preMsg)
val request = join(alice, "invite-1")
val (ok, msg) = client.publish(request)
assertTrue(ok, msg)
assertTrue(msg.startsWith("info: welcome"), msg)
assertTrue(relay.banStore.isAllowedPubkey(alice.pubKey))
assertEquals(listOf(RelayMember(alice.pubKey)), relay.memberList()!!.membersWithRoles())
val added = relay.added().single()
assertEquals(relay.relaySigner!!.pubKey, added.pubKey)
assertEquals(listOf(alice.pubKey), (added as RelayAddMemberEvent).memberPubKeys())
// The join request (it carries the invite code) is never stored.
assertTrue(relay.store.query<Event>(Filter(ids = listOf(request.id))).isEmpty())
// Now a member: writes go through.
assertTrue(client.publish(alice.sign(TextNoteEvent.build("after"))).first)
// Invite codes are reusable until revoked.
assertTrue(client.publish(join(bob, "invite-1")).first)
assertEquals(setOf(alice.pubKey, bob.pubKey), relay.memberList()!!.members().toSet())
// Joining again is a no-op answered as a duplicate.
val (dupOk, dupMsg) = client.publish(join(alice, "invite-1"))
assertTrue(dupOk)
assertTrue(dupMsg.startsWith("duplicate:"), dupMsg)
assertEquals(2, relay.added().size)
}
@Test
fun joinWithInvalidRevokedOrStaleClaimIsRejected() =
runBlocking {
val relay = relay()
val client = Client(relay)
relay.rpc(Nip86Request.createClaim("good"))
val (badOk, badMsg) = client.publish(join(alice, "nope"))
assertFalse(badOk)
assertEquals("restricted: that is an invalid invite code.", badMsg)
// Outside the created_at window ("now, plus or minus a few minutes").
val (oldOk, oldMsg) = client.publish(join(alice, "good", createdAt = TimeUtils.now() - 3600))
assertFalse(oldOk)
assertTrue(oldMsg.startsWith("invalid:"), oldMsg)
val (futureOk, _) = client.publish(join(alice, "good", createdAt = TimeUtils.now() + 3600))
assertFalse(futureOk)
// A revoked (deleteclaim) code no longer admits anyone.
relay.rpc(Nip86Request.deleteClaim("good"))
val (revokedOk, revokedMsg) = client.publish(join(alice, "good"))
assertFalse(revokedOk)
assertTrue(revokedMsg.startsWith("restricted:"), revokedMsg)
// A banned pubkey can't join even with a valid code.
relay.rpc(Nip86Request.createClaim("fresh"))
relay.rpc(Nip86Request.banPubkey(bob.pubKey, "spam"))
val (bannedOk, bannedMsg) = client.publish(join(bob, "fresh"))
assertFalse(bannedOk)
assertTrue(bannedMsg.startsWith("restricted:"), bannedMsg)
// A forged signature is refused before anything else.
val forged = join(alice, "fresh").let { Event(it.id, it.pubKey, it.createdAt, it.kind, it.tags, it.content, "0".repeat(128)) }
val (forgedOk, forgedMsg) = client.publish(forged)
assertFalse(forgedOk)
assertTrue(forgedMsg.startsWith("invalid:"), forgedMsg)
assertFalse(relay.banStore.isAllowedPubkey(alice.pubKey))
assertFalse(relay.banStore.isAllowedPubkey(bob.pubKey))
assertTrue(relay.added().isEmpty())
}
@Test
fun leaveRemovesMemberAndPublishes() =
runBlocking {
val relay = relay()
val client = Client(relay)
relay.rpc(Nip86Request.createClaim("c"))
assertTrue(client.publish(join(alice, "c")).first)
assertTrue(client.publish(join(bob, "c")).first)
// The NIP-70 "-" tag is mandatory on leave requests.
val unprotected = alice.sign<Event>(TimeUtils.now(), RelayLeaveRequestEvent.KIND, emptyArray(), "")
val (noTagOk, noTagMsg) = client.publish(unprotected)
assertFalse(noTagOk)
assertTrue(noTagMsg.startsWith("invalid:"), noTagMsg)
val (staleOk, _) = client.publish(leave(alice, createdAt = TimeUtils.now() - 3600))
assertFalse(staleOk)
assertTrue(relay.banStore.isAllowedPubkey(alice.pubKey))
val (ok, msg) = client.publish(leave(alice))
assertTrue(ok, msg)
assertFalse(relay.banStore.isAllowedPubkey(alice.pubKey))
assertEquals(listOf(bob.pubKey), relay.memberList()!!.members())
val removed = relay.removed().single() as RelayRemoveMemberEvent
assertEquals(listOf(alice.pubKey), removed.memberPubKeys())
assertEquals(relay.relaySigner!!.pubKey, removed.pubKey)
// Leaving twice is a duplicate, and a non-member can't write any more.
assertTrue(client.publish(leave(alice)).second.startsWith("duplicate:"))
assertFalse(client.publish(alice.sign(TextNoteEvent.build("still here?"))).first)
}
@Test
fun adminMembershipChangesRepublish() =
runBlocking {
val relay = relay()
assertNull(relay.rpc(Nip86Request.allowPubkey(alice.pubKey)).error)
assertEquals(listOf(alice.pubKey), relay.memberList()!!.members())
assertEquals(1, relay.added().size)
relay.rpc(Nip86Request.allowPubkey(bob.pubKey))
relay.rpc(Nip86Request.banPubkey(alice.pubKey, "spam"))
assertEquals(listOf(bob.pubKey), relay.memberList()!!.members())
assertEquals(listOf(alice.pubKey), (relay.removed().single() as RelayRemoveMemberEvent).memberPubKeys())
relay.rpc(Nip86Request.unallowPubkey(bob.pubKey))
assertEquals(emptyList(), relay.memberList()!!.members())
assertEquals(2, relay.removed().size)
}
@Test
fun rolesArePublishedAndCarriedByTheMemberList() =
runBlocking {
val relay = relay()
relay.rpc(Nip86Request.allowPubkey(alice.pubKey))
assertNull(relay.rpc(Nip86Request.createRole("mod", "Moderator", "keeps order", 200, 2)).error)
val role = relay.roles().single()
assertEquals(relay.relaySigner!!.pubKey, role.pubKey)
assertTrue(role.tags.isProtected())
assertEquals(RelayRole("mod", "Moderator", "keeps order", 200, 2), role.role())
assertNull(relay.rpc(Nip86Request.assignRole(alice.pubKey, "mod")).error)
assertEquals(listOf(RelayMember(alice.pubKey, listOf("mod"))), relay.memberList()!!.membersWithRoles())
// Edits within the same second still supersede (created_at is kept monotonic).
relay.rpc(Nip86Request.editRole("mod", "Mod", null, 10, 1))
relay.rpc(Nip86Request.editRole("mod", "Moderators", null, 11, 1))
assertEquals(RelayRole("mod", "Moderators", null, 11, 1), relay.roles().single().role())
relay.rpc(Nip86Request.createRole("king"))
relay.rpc(Nip86Request.assignRole(alice.pubKey, "king"))
assertEquals(
listOf("mod", "king"),
relay
.memberList()!!
.membersWithRoles()
.single()
.roles,
)
relay.rpc(Nip86Request.unassignRole(alice.pubKey, "king"))
assertEquals(
listOf("mod"),
relay
.memberList()!!
.membersWithRoles()
.single()
.roles,
)
// deleterole: NIP-09 deletion of the 33534, and the id leaves every member.
relay.rpc(Nip86Request.deleteRole("mod"))
assertEquals(listOf("king"), relay.roles().map { it.roleId() })
assertEquals(listOf(RelayMember(alice.pubKey)), relay.memberList()!!.membersWithRoles())
val deletion = relay.store.query<Event>(Filter(kinds = listOf(5), authors = listOf(relay.relaySigner!!.pubKey))).single()
assertTrue(deletion.tags.any { it[0] == "a" && it[1] == "33534:${relay.relaySigner!!.pubKey}:mod" })
// A role re-created under a deleted id is published again, after the tombstone.
relay.rpc(Nip86Request.createRole("mod", "Back"))
assertEquals(setOf("king", "mod"), relay.roles().map { it.roleId() }.toSet())
}
@Test
fun membershipSurvivesARestartWithoutRepublishing() =
runBlocking {
val stateFile = File(dir, "admin.json")
val dbFile = File(dir, "events.db").path
val key = RelayIdentity.loadOrCreate(File(dir, "relay.key"))
val r1 = relay(stateFile = stateFile, store = EventStore(dbName = dbFile, relay = url), key = key)
r1.rpc(Nip86Request.createClaim("c"))
r1.rpc(Nip86Request.createRole("mod", "Moderator"))
assertTrue(Client(r1).publish(join(alice, "c")).first)
r1.rpc(Nip86Request.assignRole(alice.pubKey, "mod"))
val list1 = r1.memberList()!!
r1.close()
engines.remove(r1)
// Same key from disk, same state file, same event store.
val key2 = RelayIdentity.loadOrCreate(File(dir, "relay.key"))
assertEquals(key.pubKey.toHexKey(), key2.pubKey.toHexKey())
val r2 = relay(stateFile = stateFile, store = EventStore(dbName = dbFile, relay = url), key = key2)
assertEquals(key.pubKey.toHexKey(), r2.info.document.self)
assertTrue(r2.banStore.isAllowedPubkey(alice.pubKey))
assertEquals(listOf("mod"), r2.banStore.rolesOf(alice.pubKey))
assertEquals(listOf("c"), r2.banStore.listClaims())
r2.membershipServer!!.sync()
assertEquals(list1.id, r2.memberList()!!.id, "an unchanged member list is not re-signed on boot")
assertEquals(1, r2.added().size)
assertEquals(1, r2.roles().size)
// Still a member after the restart.
assertTrue(Client(r2).publish(alice.sign(TextNoteEvent.build("back"))).first)
}
@Test
fun relayIdentityResolution() {
val nsecKey = KeyPair()
val fromNsec =
RelayIdentity.resolve(StaticConfig.IdentitySection(secret_key = nsecKey.privKey!!.toNsec()), needed = false, stateFile = null)
assertEquals(nsecKey.pubKey.toHexKey(), fromNsec!!.pubKey.toHexKey())
val fromHex =
RelayIdentity.resolve(StaticConfig.IdentitySection(secret_key = nsecKey.privKey!!.toHexKey()), needed = false, stateFile = null)
assertEquals(nsecKey.pubKey.toHexKey(), fromHex!!.pubKey.toHexKey())
// Nothing configured and nothing needs it: no identity.
assertNull(RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = false, stateFile = null))
// Membership needs one: generated next to the state file, stable across boots.
val state = File(dir, "state.json").path
val first = RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = state)!!
val second = RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = state)!!
assertEquals(first.pubKey.toHexKey(), second.pubKey.toHexKey())
assertTrue(File(state + RelayIdentity.KEY_FILE_SUFFIX).exists())
// No state file either: an in-memory key, with a warning.
val warnings = mutableListOf<String>()
assertNotNull(RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = null, warn = { warnings += it }))
assertEquals(1, warnings.size)
}
}
@@ -526,6 +526,30 @@
"repeat": 20000000,
"plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5",
"payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b"
},
{
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"pattern": "a",
"repeat": 65535,
"plaintext_sha256": "6e1bebca6a8229364a162a72ef064826c4cd7457bf54f190ef782bd9deff3e42",
"payload_sha256": "6d8c2810d1e870fbaa1f0a0937126cca837a15f9260e27060c331d70a3c0bc84"
},
{
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"pattern": "a",
"repeat": 65536,
"plaintext_sha256": "bf718b6f653bebc184e1479f1935b8da974d701b893afcf49e701f3e2f9f9c5a",
"payload_sha256": "b7b4edb36ba92e267d322d56d9aebc22e7fa96ff52e3c12adc07f07a43cbc616"
},
{
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"pattern": "a",
"repeat": 65537,
"plaintext_sha256": "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430",
"payload_sha256": "eeb7c7c5373894ea2c1547cfd3ccb15d5a0b2d619da852e5c79df792dcc9e435"
}
]
},
@@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
@@ -150,6 +149,7 @@ import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEv
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
@@ -163,6 +163,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
@@ -308,6 +309,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
@@ -606,6 +608,7 @@ object KindNames {
EncryptionKeyListEvent.KIND to KindName("Encryption Keys", null),
KeyPackageRelayListEvent.KIND to KindName("MLS KeyPackage Relays", null),
FavoriteAlgoFeedsListEvent.KIND to KindName("Favorite Feeds", "51"),
FavoriteFollowSetsListEvent.KIND to KindName("Favorite Follow Sets", "51"),
GoodWikiAuthorListEvent.KIND to KindName("Wiki Authors", "51"),
GoodWikiRelayListEvent.KIND to KindName("Wiki Relays", "51"),
UserGraspListEvent.KIND to KindName("GRASP Servers", "34"),
@@ -614,6 +617,7 @@ object KindNames {
Ps1SaveEvent.KIND to KindName("PS1 Save", null),
NwcInfoEvent.KIND to KindName("NWC Info", "47"),
RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"),
RelayRoleEvent.KIND to KindName("Relay Role", "43"),
RootSiteEvent.KIND to KindName("Website Root", "5A"),
RootNappletEvent.KIND to KindName("Napplet Root", "5D"),
CashuWalletEvent.KIND to KindName("Cashu Wallet", "60"),
@@ -37,6 +37,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonObject
@@ -98,6 +99,10 @@ object MessageKSerializer : KSerializer<Message> {
is EoseMessage -> {
add(JsonPrimitive(value.subId))
// NIP-67: optional third element, the completeness hints.
value.hints?.let { hints ->
add(buildJsonArray { hints.forEach { add(JsonPrimitive(it)) } })
}
}
is LimitsMessage -> {
@@ -136,7 +141,12 @@ object MessageKSerializer : KSerializer<Message> {
}
EoseMessage.LABEL -> {
EoseMessage(array[1].jsonPrimitive.content)
// NIP-67: an optional array of hint strings; anything else there is ignored.
val hints =
(array.getOrNull(2) as? JsonArray)?.mapNotNull { hint ->
(hint as? JsonPrimitive)?.takeIf { it.isString }?.content
}
EoseMessage(array[1].jsonPrimitive.content, hints)
}
NoticeMessage.LABEL -> {
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.awaitAuthOutcome
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.hasAuthResponder
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -190,6 +191,23 @@ data class PagedFetchResult(
* it a `limit` to bound that single page; without one you get the relay's default page
* of top hits.
*
* **NIP-67 completeness hints.** A relay may append hints to a page's `EOSE`:
*
* - `"finish"` — every stored match was sent, so the walk stops right there instead of
* spending one more REQ just to observe an empty page. It ends
* [PagedFetchResult.End.DRAINED] (or LIMIT_REACHED / UNPAGEABLE when a filter had
* already dropped out of the page, since `finish` can only speak for what was asked).
* - `"more"` — the relay holds more; paging continues, which is what the walk does
* anyway until it sees an empty page, so this needs no special handling.
* - `"auth"` — more may be available after NIP-42. Handled like an `auth-required:`
* CLOSED: when a responder is attached the walk waits (once) for the AUTH verdict and,
* on success, reads the page the relay re-serves after the AUTH's re-REQ, dropping the
* events it already delivered. If the AUTH does not happen, the page's events still
* count but the walk can no longer claim DRAINED: it ends
* [PagedFetchResult.End.AUTH_REQUIRED] wherever it would have ended DRAINED.
*
* Hints are only ever a shortcut; their absence changes nothing (the heuristic above).
*
* @param relay The relay to query.
* @param filters Filters to apply on every page (the `until` field is overwritten per page).
* @param idleTimeoutMs Idle window per page — like every accessory timeout, it is measured
@@ -336,6 +354,19 @@ suspend fun INostrClient.fetchAllPages(
// declining to give one.
var pageEnd: PageSignal? = null
// NIP-67 hints of the EOSE that ended this page (null: none sent). Written on the
// relay's reader thread before the EOSE signal is sent; the channel orders it.
var eoseHints: List<String>? = null
// Ids delivered on this page, kept only while an EOSE `"auth"` hint could still make
// the relay re-serve the page after AUTH (at most once per walk), so the re-served
// copies of events already handed to [onEvent] are dropped. The hint is rare, so the
// page only appends to a plain list (no hashing, no per-entry node); the lookup set
// is built from it once, on the first re-served event. Both are reader-thread only.
val pageIds: ArrayList<HexKey>? = if (pendingOnAuthRequired && !authRetried) ArrayList() else null
var reServedIds: HashSet<HexKey>? = null
var reServing = false
try {
val listener =
object : SubscriptionListener {
@@ -363,6 +394,12 @@ suspend fun INostrClient.fetchAllPages(
// Drop a boundary-second event we already delivered on an
// earlier page (the inclusive re-fetch returns it again).
if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return
// The relay re-serving this page after an EOSE "auth" hint: skip what
// this page already delivered.
if (reServing && pageIds != null) {
val seenOnPage = reServedIds ?: HashSet(pageIds).also { reServedIds = it }
if (event.id in seenOnPage) return
}
// Count this event against every active filter it satisfies
// (one event can match more than one). Only a non-search filter
@@ -390,6 +427,10 @@ suspend fun INostrClient.fetchAllPages(
if (atLeastOne) {
onEvent(event)
delivered++
if (pageIds != null) {
val seenOnPage = reServedIds
if (seenOnPage != null) seenOnPage.add(event.id) else pageIds.add(event.id)
}
// Track the oldest advancing second and the ids delivered
// in it — that becomes the next boundary and its dedup set.
if (advancesCursor) {
@@ -414,6 +455,15 @@ suspend fun INostrClient.fetchAllPages(
doneChannel.trySend(PageSignal.EOSE)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
hints: List<String>?,
) {
eoseHints = hints
doneChannel.trySend(PageSignal.EOSE)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
@@ -454,6 +504,18 @@ suspend fun INostrClient.fetchAllPages(
clock.bump()
pageEnd = doneChannel.receiveWithinIdle(clock, idleTimeoutMs)
}
} else if (pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_AUTH) && pendingOnAuthRequired && !authRetried) {
// NIP-67 "auth": the page was answered, but the relay says it held some back.
// Same wait as the CLOSED case — the relay sent its challenge before this EOSE,
// and the AUTH's OK re-sends this very REQ — except the page already delivered
// events, so the re-served copies are dropped via [pageIds].
authRetried = true
reServing = true
if (awaitAuthOutcome(relay, authMark, DEFAULT_AUTH_GRACE_MS, idleTimeoutMs) == AuthOutcome.AUTHENTICATED) {
eoseHints = null
clock.bump()
pageEnd = doneChannel.receiveWithinIdle(clock, idleTimeoutMs)
}
}
unsubscribe(subId)
@@ -465,6 +527,10 @@ suspend fun INostrClient.fetchAllPages(
totalEvents += delivered
// The page ended on an EOSE saying more is visible only after AUTH, and no AUTH
// took the wall down: whatever it did deliver stands, but it cannot prove absence.
val authBlocked = pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_AUTH)
// The relay sent nothing at-or-below `until`. Whether that DRAINS the set
// depends on why the page ended and on what was asked:
//
@@ -490,6 +556,23 @@ suspend fun INostrClient.fetchAllPages(
pageEnd == null -> PagedFetchResult.End.IDLE
cappedByLimit -> PagedFetchResult.End.LIMIT_REACHED
filters.any { it.search != null } -> PagedFetchResult.End.UNPAGEABLE
authBlocked -> PagedFetchResult.End.AUTH_REQUIRED
else -> PagedFetchResult.End.DRAINED
}
break
}
// NIP-67 "finish": the relay says it sent every stored match for this page's
// filters, so there is nothing below the cursor to ask for — stop now rather than
// spend a REQ to watch an empty page come back. It only speaks for the filters this
// page actually carried; one that already dropped out (limit met, or a search after
// its single page) keeps the reading it would have had.
if (pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_FINISH)) {
end =
when {
authBlocked -> PagedFetchResult.End.AUTH_REQUIRED
filters.indices.any { i -> filters[i].limit.let { it != null && matchCountPerFilter[i] >= it } } -> PagedFetchResult.End.LIMIT_REACHED
filters.any { it.search != null } -> PagedFetchResult.End.UNPAGEABLE
else -> PagedFetchResult.End.DRAINED
}
break
@@ -587,3 +670,5 @@ suspend fun INostrClient.fetchAllPages(
onNewPage = onNewPage,
onEvent = onEvent,
)
private fun List<String>?.hasHint(hint: String) = this != null && contains(hint)
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnection
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
@@ -113,6 +114,9 @@ class RelayAuthenticator(
// from RelayAuthStatus.snapshot().
private val authStatus = LargeCache<NormalizedRelayUrl, RelayAuthStatus>()
/** The challenge each relay was last re-authenticated on because of an EOSE `"auth"` hint. */
private val authHintRetried = LargeCache<NormalizedRelayUrl, String>()
private val _authStateFlow = MutableStateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>(persistentMapOf())
/**
@@ -143,6 +147,7 @@ class RelayAuthenticator(
is AuthMessage -> authenticate(relay, msg.challenge, interactive = true)
is OkMessage -> checkAuthResults(relay, msg)
is ClosedMessage -> reauthenticateIfAuthRequired(relay, msg)
is EoseMessage -> reauthenticateIfAuthHinted(relay, msg)
}
}
@@ -153,6 +158,7 @@ class RelayAuthenticator(
override fun onDisconnected(relay: IRelayClient) {
authStatus.remove(relay.url)
authHintRetried.remove(relay.url)
publishSnapshot(relay.url)
}
}
@@ -222,6 +228,34 @@ class RelayAuthenticator(
msg: ClosedMessage,
) {
if (MachineReadablePrefix.parse(msg.message) != MachineReadablePrefix.AUTH_REQUIRED) return
reauthenticateWithStoredChallenge(relay)
}
/**
* NIP-67 / NIP-42: an `EOSE` carrying the `"auth"` hint says the relay may hold more
* matches for this subscription if we authenticate. The relay MUST have sent its
* `AUTH` challenge before that EOSE, so the challenge is already stored and the normal
* [authenticate] pass has usually run on it. This takes the same path as an
* `auth-required:` CLOSED: re-attach any approved identity not yet sent on that
* challenge (never prompting), and let the AUTH's `OK` → [INostrClient.syncFilters]
* re-send the REQ so the relay can serve what it held back. Deduped per
* (pubkey, challenge) and skipped while an AUTH is in flight, so it cannot loop.
*/
private fun reauthenticateIfAuthHinted(
relay: IRelayClient,
msg: EoseMessage,
) {
if (!msg.needsAuth()) return
// A relay that keeps refusing us may tag EVERY EOSE with "auth". Unlike a CLOSED, the
// subscription is still answered, so there is no refusal to recover from — one retry
// per challenge is enough, and it spares an external signer a pass per subscription.
val challenge = authStatus.get(relay.url)?.lastChallenge() ?: return
if (authHintRetried.get(relay.url) == challenge) return
authHintRetried.put(relay.url, challenge)
reauthenticateWithStoredChallenge(relay)
}
private fun reauthenticateWithStoredChallenge(relay: IRelayClient) {
val status = authStatus.get(relay.url) ?: return
// Coalesce the burst: a relay refuses EVERY currently-open sub with its own `auth-required`
// CLOSED, so a single missing identity yields many CLOSEDs at once. Re-signing on each would
@@ -302,6 +302,7 @@ class PoolRequests(
desiredSubListeners.get(msg.subId)?.onEose(
relay = relay.url,
forFilters = forFilters,
hints = msg.hints,
)
// send a newer version when done
@@ -30,6 +30,18 @@ interface SubscriptionListener {
forFilters: List<Filter>?,
) {}
/**
* EOSE together with its NIP-67 completeness [hints] (`finish`, `more`, `auth`, …;
* null when the relay sent the plain two-element EOSE). The pool calls this one;
* the default forwards to the two-argument [onEose], so listeners that don't care
* about hints keep overriding that.
*/
fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
hints: List<String>?,
) = onEose(relay, forFilters)
suspend fun onEvent(
event: Event,
isLive: Boolean,
@@ -20,11 +20,29 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient
/**
* `["EOSE", <subId>]`, optionally with NIP-67 completeness hints:
* `["EOSE", <subId>, [<hint>, ...]]`.
*
* [hints] is null when the relay sent the two-element form. Hints are only
* about stored events; their presence is definitive, their absence is not
* (see [isFinished] / [hasMore]). Unknown hint values are kept but ignored.
*/
class EoseMessage(
val subId: String,
val hints: List<String>? = null,
) : Message {
override fun label() = LABEL
/** NIP-67 `finish`: every stored match was sent; do not paginate further. */
fun isFinished() = hints?.contains(HINT_FINISH) == true
/** NIP-67 `more`: the relay holds more stored matches than it sent; paginate. */
fun hasMore() = hints?.contains(HINT_MORE) == true
/** NIP-67 `auth`: more stored matches may be available after NIP-42 AUTH. */
fun needsAuth() = hints?.contains(HINT_AUTH) == true
/**
* Wire form is `["EOSE","<subId>"]` — sent once per REQ, so it is on
* the per-subscription floor. Splice it directly when [subId] needs no
@@ -33,7 +51,7 @@ class EoseMessage(
* any exotic subId falls back.
*/
override fun toJson(): String {
if (!isEscapeFreeAscii(subId)) return super.toJson()
if (hints != null || !isEscapeFreeAscii(subId)) return super.toJson()
return buildString(subId.length + 12) {
append("[\"EOSE\",\"")
append(subId)
@@ -43,5 +61,10 @@ class EoseMessage(
companion object {
const val LABEL = "EOSE"
// NIP-67 hint values.
const val HINT_FINISH = "finish"
const val HINT_MORE = "more"
const val HINT_AUTH = "auth"
}
}
@@ -0,0 +1,99 @@
/*
* 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.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* Works out the NIP-67 completeness hint for one REQ's stored replay, only ever
* claiming what the store's answer actually proves:
*
* - **One filter with `limit = L > 0`**: the store is asked for `L + 1` rows and the
* extra (oldest) row is withheld. Seeing it proves the relay holds more (`"more"`);
* not seeing it proves the replay was complete (`"finish"`). The client receives
* exactly the `L` events it would have without the probe.
* - **Every filter unbounded (`limit = null`)**: the store returns every match
* (STORE-F12), so the replay is complete (`"finish"`).
* - **Several filters, some limited**: limits are per filter and the replay is their
* deduped union, so rows cannot be attributed back to a filter without matching
* each one. Only the cheap, sound case is claimed: fewer rows than the smallest
* limit means no filter reached its limit (`"finish"`). Anything else sends no hint,
* which NIP-67 allows (absence is not definitive).
*
* `limit = 0` never gets a hint and is never probed: NIP-01 forbids returning stored
* events for it, so it keeps its exact query.
*
* This assumes the backing store honours `limit` exactly and treats `null` as
* unbounded, which is why [RelaySession] only uses it when the server opts in.
*/
internal class EoseCompletenessProbe private constructor(
/** The filters to actually query (the limit may be raised by one). */
val queryFilters: List<Filter>,
/** Max stored events to forward; the rest are only counted. Null: forward all. */
private val forwardCap: Int?,
private val rule: Rule,
) {
private enum class Rule { PROBE, ALL_UNBOUNDED, UNDER_MIN_LIMIT }
private val minLimit: Int = if (rule == Rule.UNDER_MIN_LIMIT) queryFilters.minOf { it.limit ?: Int.MAX_VALUE } else 0
/** Stored events the store produced for this REQ, forwarded or not. */
private var seen = 0
/**
* Counts one stored event and says whether it should be forwarded to the client.
* Called from the single replay coroutine, before EOSE.
*/
fun onStored(): Boolean {
seen++
return forwardCap == null || seen <= forwardCap
}
/** The hints for this replay's EOSE, or null for none. */
fun hints(): List<String>? =
when (rule) {
Rule.PROBE -> if (seen > forwardCap!!) MORE else FINISH
Rule.ALL_UNBOUNDED -> FINISH
Rule.UNDER_MIN_LIMIT -> if (seen < minLimit) FINISH else null
}
companion object {
private val FINISH = listOf(EoseMessage.HINT_FINISH)
private val MORE = listOf(EoseMessage.HINT_MORE)
fun of(filters: List<Filter>): EoseCompletenessProbe? {
if (filters.isEmpty()) return null
if (filters.any { it.limit == 0 }) return null
if (filters.size == 1) {
val limit = filters[0].limit
if (limit != null && limit < Int.MAX_VALUE) {
return EoseCompletenessProbe(listOf(filters[0].copy(limit = limit + 1)), limit, Rule.PROBE)
}
}
if (filters.all { it.limit == null }) return EoseCompletenessProbe(filters, null, Rule.ALL_UNBOUNDED)
return EoseCompletenessProbe(filters, null, Rule.UNDER_MIN_LIMIT)
}
}
}
@@ -0,0 +1,50 @@
/*
* 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.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
/**
* Answers EVENT commands that are requests *to the relay* rather than content
* *for* it — e.g. a NIP-43 join (kind 28934) or leave (kind 28936) request.
*
* [RelaySession] offers every inbound EVENT to the handler **before** the
* connection's policy chain and the store. Returning `null` means "not mine":
* the event continues down the normal path (policies, then the store).
* Returning an [OkMessage] consumes the event — the session sends that `OK`
* and the event is neither stored nor fanned out to subscribers.
*
* Because it runs ahead of the policy chain, a handler owns the whole
* validation of the events it consumes: signature (with parallel verify on,
* nothing upstream of the store has checked it), timestamps, and any
* allow/deny decision. A throw (other than cancellation) is answered with
* `OK false "error: …"`.
*
* Install one on a server with [RelayServerBase.eventCommandHandler].
*/
fun interface EventCommandHandler {
suspend fun handle(
event: Event,
ctx: RequestContext,
): OkMessage?
}
@@ -63,6 +63,24 @@ abstract class RelayServerBase(
/** Number of connections currently registered with this server. */
val activeConnections: Long get() = connections.active
/**
* NIP-67 opt-in: when true, connections opened from now on append `"finish"` /
* `"more"` to their EOSEs where the stored replay proves it (see
* [RelaySession.completenessHints]). Enable it only for a backend that honours
* `limit` exactly and returns every match for an unbounded filter, and advertise
* `67` in the NIP-11 `supported_nips` when you do.
*/
var completenessHints: Boolean = false
/**
* Consumes EVENTs addressed to the relay itself (e.g. NIP-43 join/leave
* requests) before the policy chain runs — see [EventCommandHandler].
* Applies to connections opened after it is set, so install it before
* the server starts accepting traffic. Null (the default) leaves every
* EVENT on the normal policy + store path.
*/
var eventCommandHandler: EventCommandHandler? = null
/**
* Builds the per-connection policy, prepending a [LimitsPolicy] when
* [limits] is set so requests are clamped/rejected before the application
@@ -91,6 +109,8 @@ abstract class RelayServerBase(
sink = sink,
onClose = { connections.unregister(it.id) },
negentropySettings = negentropySettings,
completenessHints = completenessHints,
commandHandler = eventCommandHandler,
),
)
@@ -79,6 +79,19 @@ class RelaySession(
* open/close of the same connection. Defaults to a fresh monotonic id.
*/
val id: Long = nextConnectionId(),
/**
* NIP-67: append a completeness hint (`"finish"` / `"more"`) to each REQ's `EOSE`
* when the stored replay proves one — see [EoseCompletenessProbe]. Off by default:
* the proof assumes the [store] honours `limit` exactly and returns every match
* for an unbounded filter, which an arbitrary backend need not do.
*/
val completenessHints: Boolean = false,
/**
* Consumes EVENTs addressed to the relay itself (e.g. NIP-43 join/leave
* requests) ahead of the [policy] chain; see [EventCommandHandler].
* Null (the default) sends every EVENT down the normal path.
*/
private val commandHandler: EventCommandHandler? = null,
) : AutoCloseable {
/** The original, string-only constructor; every frame goes to [onSend] as wire JSON. */
constructor(
@@ -229,6 +242,21 @@ class RelaySession(
}
private suspend fun handleEvent(cmd: EventCmd) {
if (commandHandler != null) {
val handled =
try {
commandHandler.handle(cmd.event, requestContext)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
OkMessage.rejected(cmd.event.id, MachineReadablePrefix.ERROR, e.message ?: "request failed")
}
if (handled != null) {
send(handled)
return
}
}
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(OkMessage(cmd.event.id, false, result.reason))
@@ -368,7 +396,15 @@ class RelaySession(
}
// Policy may rewrite filters to match the user's access level.
val filters = (result as PolicyResult.Accepted).cmd.filters
val acceptedFilters = (result as PolicyResult.Accepted).cmd.filters
// NIP-67: may raise a single filter's limit by one to detect "more"; the extra
// stored row is counted but never sent. Zero-decode path only: the screened path's
// single `onEach` also carries live events accepted mid-replay, so stored rows can't
// be told apart there, and a policy that vetoes rows could not honestly say "finish".
val probe = if (completenessHints && !policy.filtersOutgoingEvents) EoseCompletenessProbe.of(acceptedFilters) else null
val filters = probe?.queryFilters ?: acceptedFilters
val eose = { send(EoseMessage(cmd.subId, probe?.hints())) }
// UNDISPATCHED: the stored replay runs inline on this coroutine —
// the reader-pool acquire doesn't suspend when a connection is
@@ -392,7 +428,7 @@ class RelaySession(
send(EventMessage(cmd.subId, event))
}
},
onEose = { send(EoseMessage(cmd.subId)) },
onEose = { eose() },
)
} else {
// Zero-decode path: the stored replay splices raw
@@ -410,13 +446,16 @@ class RelaySession(
ctx = requestContext,
filters = filters,
onEachStored = { raw ->
sendRaw(
buildString(framePrefix.length + raw.jsonTags.length + raw.content.length + 256) {
append(framePrefix)
raw.appendJsonObjectTo(this)
append(']')
},
)
// NIP-67 probe: the one extra row it asked for is counted, not sent.
if (probe == null || probe.onStored()) {
sendRaw(
buildString(framePrefix.length + raw.jsonTags.length + raw.content.length + 256) {
append(framePrefix)
raw.appendJsonObjectTo(this)
append(']')
},
)
}
},
// Live events arrive with their wire body already
// serialized (once per event, shared across every
@@ -432,7 +471,7 @@ class RelaySession(
},
)
},
onEose = { send(EoseMessage(cmd.subId)) },
onEose = { eose() },
)
}
} catch (e: CancellationException) {
@@ -0,0 +1,212 @@
/*
* 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.nip02FollowList.petnames
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.coroutines.CancellationException
/** Where a NIP-02 petname path starts. */
@Immutable
sealed interface PetnameRoot {
/** `~/…`: the logged-in user's own follow list. */
data object CurrentUser : PetnameRoot
/** `~npub1…/…`: an absolute root by key. */
data class PubKey(
val pubKey: HexKey,
) : PetnameRoot
/** `~name@domain/…`: an absolute root by NIP-05 identifier. */
data class Nip05(
val identifier: String,
) : PetnameRoot
}
/**
* A NIP-02 petname path such as `~/erin/charlie`, `~npub1…/erin/charlie` or
* `~carol@names.com/erin/charlie`.
*
* Each component is looked up in the follow list (kind 3) of the profile the previous
* component resolved to: `~/erin/charlie` is whoever Erin calls `charlie`, where Erin is
* whoever the current user calls `erin`. Only petnames made of ASCII letters, digits and
* `_` take part in path resolution.
*/
@Immutable
data class PetnamePath(
val root: PetnameRoot,
val names: List<String>,
) {
fun encode(): String {
val prefix =
when (root) {
PetnameRoot.CurrentUser -> "$PREFIX"
is PetnameRoot.PubKey -> PREFIX + NPub.create(root.pubKey)
is PetnameRoot.Nip05 -> PREFIX + root.identifier
}
return if (names.isEmpty()) prefix else prefix + SEPARATOR + names.joinToString(SEPARATOR.toString())
}
companion object {
const val PREFIX = '~'
const val SEPARATOR = '/'
/** NIP-02: only ASCII letters, numbers or `_` qualify for petname resolution. */
fun isEligible(petname: String): Boolean =
petname.isNotEmpty() &&
petname.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' }
/** How a contact of a contact is shown in the current user's context: `~/erin/charlie`. */
fun display(names: List<String>): String = PREFIX + SEPARATOR.toString() + names.joinToString(SEPARATOR.toString())
/** Parses [input] into a path, or null when it is not a well-formed petname path. */
fun parse(input: String): PetnamePath? {
val text = input.trim()
if (text.length < 2 || text[0] != PREFIX) return null
val parts = text.substring(1).split(SEPARATOR)
val rootToken = parts[0]
val names = parts.drop(1)
if (!names.all(::isEligible)) return null
val root =
when {
rootToken.isEmpty() -> PetnameRoot.CurrentUser
rootToken.startsWith("npub1") || rootToken.startsWith("nprofile1") -> PetnameRoot.PubKey(decodeKey(rootToken) ?: return null)
looksLikeNip05(rootToken) -> PetnameRoot.Nip05(rootToken)
else -> return null
}
// `~/` alone names nobody; an absolute root on its own names the root.
if (root == PetnameRoot.CurrentUser && names.isEmpty()) return null
return PetnamePath(root, names)
}
private fun decodeKey(token: String): HexKey? =
try {
when (val entity = Nip19Parser.uriToRoute(token)?.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
private fun looksLikeNip05(token: String): Boolean {
val at = token.indexOf('@')
if (at < 0 || at == token.length - 1) return false
val domain = token.substring(at + 1)
return domain.contains('.') && !domain.contains('@')
}
}
}
/**
* Pure NIP-02 petname lookups over follow-list tags. Knows nothing about caches or relays:
* callers hand in how to get a follow list and how to resolve a NIP-05 identifier.
*/
object PetnameResolver {
/**
* The petname [followList] gives [pubKey], if any. Any non-blank petname counts here:
* the character restriction only applies to resolving paths.
*/
fun petnameOf(
followList: TagArray,
pubKey: HexKey,
): String? =
followList.fastFirstNotNullOfOrNull { tag ->
if (tag.size > 3 && ContactTag.isTagged(tag, pubKey) && tag[3].isNotBlank()) tag[3] else null
}
/**
* Every petname in [followList], by pubkey (the first one wins when a key repeats). Meant to be
* built once per follow-list version and reused for every name rendered against it.
*/
fun petnames(followList: TagArray): Map<HexKey, String> {
val result = HashMap<HexKey, String>()
followList.fastForEach { tag ->
if (tag.size > 3 && ContactTag.isTagged(tag) && tag[3].isNotBlank() && tag[1] !in result) {
result[tag[1]] = tag[3]
}
}
return result
}
/** The pubkey [followList] calls [petname] (exact, case-sensitive match; eligible names only). */
fun findByPetname(
followList: TagArray,
petname: String,
): HexKey? {
if (!PetnamePath.isEligible(petname)) return null
return followList.fastFirstNotNullOfOrNull { tag ->
if (tag.size > 3 && tag[3] == petname && ContactTag.isTagged(tag)) tag[1] else null
}
}
/**
* Resolves [path] one component at a time.
*
* @param currentUser the logged-in user, required for `~/…` paths
* @param followListOf the kind-3 tags of a user, or null when unknown
* @param resolveNip05 resolves a `name@domain` root; the default resolves nothing
* @return the pubkey the path points to, or null when any step cannot be resolved
*/
suspend fun resolve(
path: PetnamePath,
currentUser: HexKey?,
followListOf: suspend (HexKey) -> TagArray?,
resolveNip05: suspend (String) -> HexKey? = { null },
): HexKey? {
var current: HexKey =
when (val root = path.root) {
PetnameRoot.CurrentUser -> currentUser
is PetnameRoot.PubKey -> root.pubKey
is PetnameRoot.Nip05 -> resolveNip05(root.identifier)
} ?: return null
for (name in path.names) {
val followList = followListOf(current) ?: return null
current = findByPetname(followList, name) ?: return null
}
return current
}
/** Parses and resolves [input]; null when it is not a petname path or does not resolve. */
suspend fun resolve(
input: String,
currentUser: HexKey?,
followListOf: suspend (HexKey) -> TagArray?,
resolveNip05: suspend (String) -> HexKey? = { null },
): HexKey? {
val path = PetnamePath.parse(input) ?: return null
return resolve(path, currentUser, followListOf, resolveNip05)
}
}
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.quartz.nip29RelayGroups
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* Reads the optional invite code appended to a NIP-29 group identifier.
*
@@ -37,6 +40,28 @@ package com.vitorpamplona.quartz.nip29RelayGroups
object GroupNAddrInvite {
private const val PARAM = "invite"
/** A group referenced by its `kind:39000` `naddr`, plus the invite code suffix if any. */
data class Reference(
val groupId: GroupId,
val inviteCode: String?,
)
/**
* Parses a whole group identifier — `naddr1…` or `naddr1…?invite=<code>`, optionally
* with a `nostr:` prefix. Returns null unless it is a `kind:39000` naddr carrying a
* relay hint, since a group only exists on its host relay.
*/
fun parseReference(input: String): Reference? {
val trimmed = input.trim().removePrefix("nostr:")
val queryIdx = trimmed.indexOf('?')
val bech32 = if (queryIdx >= 0) trimmed.substring(0, queryIdx) else trimmed
val naddr = NAddress.parse(bech32) ?: return null
if (naddr.kind != GroupMetadataEvent.KIND) return null
val relay = naddr.relay.firstOrNull() ?: return null
val code = if (queryIdx >= 0) parse(trimmed.substring(queryIdx)) else null
return Reference(GroupId(naddr.dTag, relay), code)
}
/**
* Extracts the invite code from the [suffix] that trails a group `naddr` (e.g.
* `?invite=abc123` or `?foo=bar&invite=abc123`), or null when there is none.
@@ -52,6 +77,33 @@ object GroupNAddrInvite {
.split('&')
.firstOrNull { it.startsWith("$PARAM=") }
?.removePrefix("$PARAM=")
?.let(::percentDecode)
?.takeIf { it.isNotEmpty() }
}
/**
* Decodes `%XX` escapes (UTF-8, byte by byte) so a code shared as `?invite=a%2Fb` reaches
* the relay as `a/b`. `+` is left alone (it's a literal plus in a URI query, not a space
* here), and a malformed escape passes through verbatim.
*/
private fun percentDecode(input: String): String {
if ('%' !in input) return input
val bytes = ArrayList<Byte>(input.length)
var i = 0
while (i < input.length) {
val c = input[i]
if (c == '%' && i + 2 <= input.lastIndex) {
val hi = input[i + 1].digitToIntOrNull(16)
val lo = input[i + 2].digitToIntOrNull(16)
if (hi != null && lo != null) {
bytes.add(((hi shl 4) or lo).toByte())
i += 3
continue
}
}
c.toString().encodeToByteArray().forEach { bytes.add(it) }
i++
}
return bytes.toByteArray().decodeToString()
}
}
@@ -65,6 +65,18 @@ class GroupMetadataEvent(
fun picture() = tags.firstTagValue("picture")
/** Wide header image for the group (NIP-29 `banner`), shown behind/above the picture. */
fun banner() = tags.firstTagValue("banner")
/**
* The tags of this metadata that a client's edit form doesn't manage — e.g. `livekit`,
* `supported_kinds`, or a field from a newer spec revision. A kind-9002 re-asserts the
* whole metadata ("all the fields of group-metadata"), so an edit should carry these over
* verbatim; otherwise saving a rename would silently erase them on relays that replace
* the metadata wholesale.
*/
fun unmanagedTags(): List<Array<String>> = tags.filter { it.isNotEmpty() && it[0] !in EDIT_MANAGED_TAG_NAMES }
/**
* Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar
* state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an
@@ -176,11 +188,41 @@ class GroupMetadataEvent(
const val KIND = 39000
/**
* Tag names a metadata edit sets explicitly from its own inputs, so [unmanagedTags]
* must not carry them over (they'd be duplicated or resurrect a cleared value).
* Includes both polarities of each status flag and the Buzz `visibility` knob.
*/
val EDIT_MANAGED_TAG_NAMES =
setOf(
"d",
"h",
"name",
"about",
"picture",
"banner",
"t",
"g",
ParentTag.TAG_NAME,
ChildTag.TAG_NAME,
"previous",
"private",
"public",
"restricted",
"unrestricted",
"hidden",
"visible",
"closed",
"open",
"visibility",
)
fun build(
groupId: String,
name: String? = null,
about: String? = null,
picture: String? = null,
banner: String? = null,
status: Set<GroupStatus> = emptySet(),
supportedKinds: List<Int>? = null,
hashtags: List<String> = emptyList(),
@@ -194,6 +236,7 @@ class GroupMetadataEvent(
name?.let { add(arrayOf("name", it)) }
about?.let { add(arrayOf("about", it)) }
picture?.let { add(arrayOf("picture", it)) }
banner?.let { add(arrayOf("banner", it)) }
status.forEach { add(arrayOf(it.code)) }
supportedKinds?.let { kinds ->
add((listOf("supported_kinds") + kinds.map { it.toString() }).toTypedArray())
@@ -21,12 +21,16 @@
package com.vitorpamplona.quartz.nip29RelayGroups.metadata
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
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.nip29RelayGroups.moderation.groupPins
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.pinnedAddresses
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.pinnedEventIds
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -35,8 +39,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* (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.
* Addressed by the group id (`d` tag). The pins are carried as `e` tags (regular
* events, by id) and `a` tags (addressable events, by `kind:pubkey:d`), interleaved in
* display order.
*/
@Immutable
class GroupPinnedEvent(
@@ -49,20 +54,26 @@ class GroupPinnedEvent(
) : 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 }
/** The full ordered pin list — `e` and `a` references — in the relay's display order. */
fun pins(): List<GroupPin> = tags.groupPins()
/** Only the `e`-tagged pinned event ids, in order. Prefer [pins], which also carries `a` pins. */
fun pinnedEventIds(): List<HexKey> = tags.pinnedEventIds()
/** Only the `a`-tagged pinned addresses, in order. */
fun pinnedAddresses(): List<Address> = tags.pinnedAddresses()
companion object {
const val KIND = 39005
fun build(
groupId: String,
pinnedEventIds: List<HexKey>,
pins: List<GroupPin>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GroupPinnedEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(groupId)
pinnedEventIds.forEach { add(arrayOf("e", it)) }
groupPins(pins)
initializer()
}
}

Some files were not shown because too many files have changed in this diff Show More