mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/intelligent-babbage-23komv
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -140,6 +140,12 @@ android {
|
||||
buildConfigField("String", "RELEASE_NOTES_ID", "\"f7914e7a7e293988485439eb2bea29c09c388d54c452c4a19f89e106dbf1969e\"")
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// ARM Memory Tagging Extension for our native code (WebRTC, zxing-cpp, SQLite,
|
||||
// secp256k1, Arti). Async is the low-overhead production mode; debug overrides
|
||||
// it to sync so a tag fault crashes at the exact faulting access. Ignored on
|
||||
// devices without MTE hardware or with it switched off.
|
||||
manifestPlaceholders["memtagMode"] = "async"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
@@ -267,6 +273,7 @@ android {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
resValue("string", "app_name", "@string/app_name_debug")
|
||||
manifestPlaceholders["memtagMode"] = "sync"
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:memtagMode="${memtagMode}"
|
||||
tools:targetApi="34">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
|
||||
@@ -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
|
||||
@@ -364,6 +365,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
|
||||
|
||||
@@ -74,6 +74,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
|
||||
@@ -151,6 +152,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
|
||||
@@ -787,6 +789,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)
|
||||
|
||||
@@ -2177,6 +2182,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))
|
||||
@@ -2204,14 +2213,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) {
|
||||
@@ -4041,11 +4068,9 @@ class Account(
|
||||
// via wasVerified=false) would silently drop
|
||||
// kind:7 reactions / kind:5 deletions since
|
||||
// they never carry a Schnorr signature.
|
||||
val isNew = cache.justConsume(innerEvent, null, true)
|
||||
val innerNote = cache.getOrCreateNote(innerEvent.id)
|
||||
if (isNew) {
|
||||
innerNote.event = innerEvent
|
||||
}
|
||||
// Same indexing as live decryption, so a restored
|
||||
// kind:1009 edit is re-linked to its message too.
|
||||
val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note
|
||||
marmotGroupList.addMessage(groupId, innerNote)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
|
||||
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
|
||||
@@ -247,6 +249,47 @@ class AccountMarmotActions(
|
||||
manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a decrypted Marmot inner event: cache it, hold it on its note, and link an
|
||||
* edit to the message it replaces. Shared by live decryption and the startup restore
|
||||
* of the stored message log, so both see the same thing.
|
||||
*
|
||||
* A kind:1009 edit has no typed class, so `LocalCache` has no case for it and
|
||||
* `justConsume` drops it ("Event Not Supported"). The note then had no event and
|
||||
* `Note.latestMarmotEdit`, which matches on the event's kind, skipped it: an edit
|
||||
* from White Noise decrypted fine and never showed. An edit needs nothing from the
|
||||
* cache but its note, so it is attached directly.
|
||||
*/
|
||||
fun indexMarmotInnerEvent(innerEvent: Event): IndexedInnerEvent {
|
||||
val cache = account.cache
|
||||
val isEdit = innerEvent.kind == MarmotAppEvent.KIND_EDIT
|
||||
val innerNote = cache.getOrCreateNote(innerEvent.id)
|
||||
// wasVerified=true: MIP-03 inner events are unsigned rumors; MLS authenticated the sender.
|
||||
// For an edit, "new" is whether its note was empty — which also decides whether it is
|
||||
// persisted, so an edit is kept in the local log and survives a restart.
|
||||
val isNew = if (isEdit) innerNote.event == null else cache.justConsume(innerEvent, null, true)
|
||||
if (isNew || innerNote.event == null) {
|
||||
// loadEvent, not a bare `event =`: the overlay also matches the edit's AUTHOR to the
|
||||
// message's, and only loadEvent sets it.
|
||||
innerNote.loadEvent(innerEvent, cache.getOrCreateUser(innerEvent.pubKey), emptyList())
|
||||
}
|
||||
|
||||
// The overlay's rules (author-only, latest wins) are applied at render time by
|
||||
// `Note.latestMarmotEdit`: the target's author may not be known yet.
|
||||
if (isEdit) {
|
||||
MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit ->
|
||||
cache.getOrCreateNote(edit.targetId).addEdit(innerNote)
|
||||
}
|
||||
}
|
||||
return IndexedInnerEvent(innerNote, isNew)
|
||||
}
|
||||
|
||||
/** [note] holds the inner event; [isNew] is true the first time this client indexed it. */
|
||||
class IndexedInnerEvent(
|
||||
val note: Note,
|
||||
val isNew: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Marmot group [note] was received or sent in, or null when it is not a
|
||||
* Marmot message.
|
||||
|
||||
+66
-13
@@ -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
|
||||
|
||||
+107
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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()
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
+89
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+12
-2
@@ -1730,6 +1730,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) }
|
||||
|
||||
@@ -1864,12 +1870,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,
|
||||
@@ -1939,6 +1945,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) }
|
||||
|
||||
+5
-29
@@ -31,8 +31,6 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.marmot.GroupEventResult
|
||||
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
|
||||
import com.vitorpamplona.quartz.marmot.WelcomeResult
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit
|
||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -671,15 +669,9 @@ class GroupEventHandler(
|
||||
// needs this path to surface the note (otherwise the
|
||||
// operator saw nothing but a misleading "inner event
|
||||
// already in cache" log and the message never rendered).
|
||||
val isNew = cache.justConsume(innerEvent, null, true)
|
||||
val innerNote = cache.getOrCreateNote(innerEvent.id)
|
||||
if (isNew) {
|
||||
innerNote.event = innerEvent
|
||||
} else {
|
||||
Log.d("MarmotDbg") {
|
||||
"GroupEventHandler.add: inner event already in cache — surfacing in chatroom anyway"
|
||||
}
|
||||
}
|
||||
val indexed = account.marmot.indexMarmotInnerEvent(innerEvent)
|
||||
val innerNote = indexed.note
|
||||
val isNew = indexed.isNew
|
||||
|
||||
// Link the envelope to its inner note and copy over the
|
||||
// relays that delivered/accepted the kind-445 so far, so
|
||||
@@ -693,24 +685,8 @@ class GroupEventHandler(
|
||||
cache.copyRelaysFromTo(outerNote, innerEvent.id)
|
||||
}
|
||||
|
||||
// A kind:1009 edit is anchored to the message it replaces,
|
||||
// exactly like a Concord edit or a reaction: the bubble reads
|
||||
// `Note.edits`, and holding the edit as a hard-referenced
|
||||
// child of its target is what keeps it alive as long as that
|
||||
// target is. A Marmot inner event is decrypted exactly once —
|
||||
// the ratchet has moved on by the time anyone could re-fetch
|
||||
// it — so an edit left orphaned in the soft cache could be
|
||||
// collected and never come back.
|
||||
//
|
||||
// The overlay's own rules (author-only, latest wins) are
|
||||
// applied at render time by `Note.latestMarmotEdit`, not here:
|
||||
// the target's author is not necessarily known yet when the
|
||||
// edit arrives, and a link is not an endorsement.
|
||||
if (innerEvent.kind == MarmotAppEvent.KIND_EDIT) {
|
||||
MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit ->
|
||||
cache.getOrCreateNote(edit.targetId).addEdit(innerNote)
|
||||
}
|
||||
}
|
||||
// A kind:1009 edit was linked to the message it replaces by
|
||||
// indexMarmotInnerEvent, which also holds it on its note.
|
||||
|
||||
// Push token gossip (kinds 447/448/449) is routing data for
|
||||
// a notification server, addressed to the other members'
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+31
-5
@@ -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),
|
||||
|
||||
+12
-1
@@ -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,
|
||||
|
||||
+192
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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)
|
||||
|
||||
+14
-4
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+109
-20
@@ -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
-6
@@ -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) {
|
||||
|
||||
+84
-8
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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.
|
||||
*
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+15
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+135
-8
@@ -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
|
||||
|
||||
+1
-1
@@ -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. */
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+35
-5
@@ -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
|
||||
|
||||
+13
@@ -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")
|
||||
|
||||
+182
@@ -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()
|
||||
}
|
||||
}
|
||||
+36
@@ -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()
|
||||
}
|
||||
+80
-5
@@ -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) }
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+31
-3
@@ -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"
|
||||
}
|
||||
|
||||
+102
@@ -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()
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+169
@@ -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)))
|
||||
}
|
||||
}
|
||||
+128
@@ -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))
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -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"))
|
||||
|
||||
+59
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6165,6 +6165,20 @@
|
||||
<string name="cordn_admin_coordinator_silent">Koordynator nie odpowiedział, więc nic się nie zmieniło. Spróbuj ponownie za chwilę.</string>
|
||||
<string name="cordn_admin_no_key_package">%1$s nie opublikował jeszcze pakietu kluczy na tym koordynatorze, więc nie można go jeszcze dodać. Poproś go, aby najpierw uruchomił program cordn na tym koordynatorze.</string>
|
||||
<string name="cordn_admin_no_self_remove">Nie można samodzielnie usunąć się z grupy cordn.</string>
|
||||
<string name="cordn_admin_no_welcome">Nie udało się przydzielić %1$s do grupy, więc dodanie zostało przerwane.</string>
|
||||
<string name="cordn_admin_not_a_member">%1$s nie jest w tej grupie.</string>
|
||||
<string name="cordn_admin_someone">Ta osoba</string>
|
||||
<string name="cordn_admin_wrong_key_package">Ten koordynator obsłużył pakiet kluczy należący do kogoś innego, więc %1$s nie został dodany. Nic nie zostało zmienione.</string>
|
||||
<string name="cordn_backup_contents_body">Klucze szyfrujące grup, pakiety kluczy oraz wszystkie wiadomości w tych grupach. Każdy, kto je otworzy, może przeczytać treść rozmów. Hasło jest jedyną ochroną tych danych po opuszczeniu serwisu Amethyst.</string>
|
||||
<string name="cordn_backup_contents_title">Co znajduje się w pliku</string>
|
||||
<string name="cordn_backup_explainer">Twoje nsec nie pozwala na przywrócenie grup cordn. Grupa cordn istnieje jako stan szyfrowania na tym urządzeniu oraz jako stream koordynatora, który nie może jej odczytywać — utrata urządzenia bez kopii zapasowej oznacza utratę grupy, łącznie ze wszystkimi danymi, które już się w niej znajdowały.</string>
|
||||
<string name="cordn_backup_export">Eksport</string>
|
||||
<string name="cordn_backup_exported">Kopia zapasowa zapisana.</string>
|
||||
<string name="cordn_backup_failed">To nie zadziałało. Sprawdź hasło i plik.</string>
|
||||
<string name="cordn_backup_passphrase">Hasło</string>
|
||||
<string name="cordn_backup_restore">Przywróć</string>
|
||||
<string name="cordn_backup_restore_body">Przywrócenie powoduje zastąpienie grup „cordn” na tym urządzeniu grupami zawartymi w pliku. Funkcja ta jest przeznaczona dla telefonu, który przejął ustawienia od innego urządzenia — nie służy do jednoczesnego korzystania z tych samych grup na dwóch urządzeniach.</string>
|
||||
<string name="cordn_backup_restore_confirm_body">Wszystkie grupy Cordn znajdujące się obecnie na tym urządzeniu zostaną usunięte i zastąpione grupami zawartymi w pliku.\n\nNie przywracaj danych na drugim telefonie, dopóki pierwszy jest nadal w użyciu. Oba urządzenia miałyby wtedy te same klucze grupowe, a gdy oba wyślą wiadomość, grupa zostanie rozpadnięta dla wszystkich — w sposób niewidoczny i bez możliwości powrotu do poprzedniego stanu.</string>
|
||||
<string name="cordn_backup_restore_confirm_title">Czy zamienić grupy cordn tego urządzenia?</string>
|
||||
<string name="cordn_backup_restore_title">Przywróć</string>
|
||||
<string name="cordn_backup_restored">Przywrócono.</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+66
@@ -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
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+11
-1
@@ -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 -> {
|
||||
|
||||
+85
@@ -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)
|
||||
|
||||
+34
@@ -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
|
||||
|
||||
+4
-2
@@ -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
|
||||
@@ -373,7 +374,7 @@ class PoolRequests(
|
||||
// this relay has structurally refused too many times — replaying it on
|
||||
// every reconnect is the doomed-REQ loop this guard exists to stop.
|
||||
desiredSubs.forEach { subId, perRelayFilters ->
|
||||
val filters = perRelayFilters[relay]
|
||||
val filters = perRelayFilters[relay]?.let { relayRefusals.narrow(relay, it) }
|
||||
if (!filters.isNullOrEmpty()) {
|
||||
val send =
|
||||
subState(subId).let { state ->
|
||||
@@ -478,7 +479,8 @@ class PoolRequests(
|
||||
relay: NormalizedRelayUrl,
|
||||
): Command? {
|
||||
val oldFilters = state.currentFilters(relay)
|
||||
val newFilters = desiredSubs.get(subId)?.get(relay)
|
||||
// As the relay will serve them: kinds it refused by name are stripped.
|
||||
val newFilters = desiredSubs.get(subId)?.get(relay)?.let { relayRefusals.narrow(relay, it) }
|
||||
|
||||
return when {
|
||||
newFilters.isNullOrEmpty() -> {
|
||||
|
||||
+66
-5
@@ -99,6 +99,7 @@ class RelayReqRefusals(
|
||||
relay: NormalizedRelayUrl,
|
||||
reason: String,
|
||||
): Boolean {
|
||||
learnDisallowedKinds(relay, reason)
|
||||
val candidate = classify(reason) ?: return false
|
||||
// NO_READS is the strictest verdict; once reached, nothing softens it.
|
||||
if (blocked[relay] == Policy.NO_READS) return false
|
||||
@@ -126,14 +127,60 @@ class RelayReqRefusals(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
): Boolean =
|
||||
when (blocked[relay]) {
|
||||
Policy.NO_READS -> true
|
||||
Policy.SEARCH_ONLY -> filters.isNotEmpty() && filters.all { it.search.isNullOrEmpty() }
|
||||
null -> false
|
||||
}
|
||||
// Everything this REQ asks for is a kind the relay refused.
|
||||
(filters.isNotEmpty() && narrow(relay, filters).isEmpty()) ||
|
||||
when (blocked[relay]) {
|
||||
Policy.NO_READS -> true
|
||||
Policy.SEARCH_ONLY -> filters.isNotEmpty() && filters.all { it.search.isNullOrEmpty() }
|
||||
null -> false
|
||||
}
|
||||
|
||||
fun blockedRelays(): Map<NormalizedRelayUrl, Policy> = blocked.snapshot()
|
||||
|
||||
// Kinds a relay has said it will not serve at all ("kind not allowed: 21059").
|
||||
private val disallowedKinds = ConcurrentMap<NormalizedRelayUrl, Set<Int>>()
|
||||
|
||||
/**
|
||||
* Learn the kinds a relay named as not allowed in a CLOSED reason.
|
||||
*
|
||||
* A relay with a kind allowlist refuses the WHOLE REQ over one kind it doesn't
|
||||
* serve, so the kinds it does serve in that filter are lost with it. The message
|
||||
* names the kind, so one refusal is enough to learn it: nothing is guessed, and
|
||||
* [narrow] strips exactly that kind for exactly this relay.
|
||||
*/
|
||||
private fun learnDisallowedKinds(
|
||||
relay: NormalizedRelayUrl,
|
||||
reason: String,
|
||||
) {
|
||||
val kinds = parseDisallowedKinds(reason)
|
||||
if (kinds.isEmpty()) return
|
||||
disallowedKinds.merge(relay, kinds) { old, new -> old + new }
|
||||
}
|
||||
|
||||
/**
|
||||
* [filters] as [relay] will actually serve them: kinds the relay refused are
|
||||
* removed. A filter whose kinds all got removed is dropped, never sent with an
|
||||
* empty kind list, which would ask for EVERY kind. A filter with no kind list
|
||||
* is left alone.
|
||||
*/
|
||||
fun narrow(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
): List<Filter> {
|
||||
val refused = disallowedKinds[relay] ?: return filters
|
||||
return filters.mapNotNull { filter ->
|
||||
val kinds = filter.kinds ?: return@mapNotNull filter
|
||||
val kept = kinds.filterNot { it in refused }
|
||||
when {
|
||||
kept.size == kinds.size -> filter
|
||||
kept.isEmpty() -> null
|
||||
else -> filter.copy(kinds = kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disallowedKinds(relay: NormalizedRelayUrl): Set<Int> = disallowedKinds[relay] ?: emptySet()
|
||||
|
||||
private fun classify(reason: String): Policy? {
|
||||
val t = reason.lowercase()
|
||||
if (SEARCH_REQUIRED_MARKERS.any { it in t }) return Policy.SEARCH_ONLY
|
||||
@@ -142,6 +189,20 @@ class RelayReqRefusals(
|
||||
}
|
||||
|
||||
companion object {
|
||||
// "kind not allowed: 21059", "kinds not allowed: 7374, 30382", "kind 21059 is not allowed"
|
||||
private val KINDS_AFTER_MARKER = Regex("""kinds? (?:is |are )?not allowed:?\s*([0-9][0-9,\s]*)""")
|
||||
private val KIND_BEFORE_MARKER = Regex("""kind ([0-9]+) (?:is )?not allowed""")
|
||||
|
||||
fun parseDisallowedKinds(reason: String): Set<Int> {
|
||||
val t = reason.lowercase()
|
||||
val kinds = mutableSetOf<Int>()
|
||||
KINDS_AFTER_MARKER.findAll(t).forEach { m ->
|
||||
m.groupValues[1].split(',', ' ').mapNotNullTo(kinds) { it.trim().toIntOrNull() }
|
||||
}
|
||||
KIND_BEFORE_MARKER.findAll(t).forEach { m -> m.groupValues[1].toIntOrNull()?.let { kinds.add(it) } }
|
||||
return kinds
|
||||
}
|
||||
|
||||
// The relay only serves NIP-50 search REQs (a plain feed REQ is refused).
|
||||
private val SEARCH_REQUIRED_MARKERS =
|
||||
listOf(
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
+24
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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?
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user