Merge branch 'worktree-agent-a27125bf23b8e3942' into claude/serene-lamport-3eyza2

This commit is contained in:
Claude
2026-09-27 20:14:02 +00:00
75 changed files with 2205 additions and 174 deletions
@@ -88,6 +88,7 @@ ent for NIP-04) |
| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage |
| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) |
| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events |
| A3 | `nipA3PaymentTargets/` | PaymentTargetsEvent.kt | Payment targets (`payto` tags, kind 10133) |
| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage |
### Web/Storage/Other
@@ -127,7 +128,6 @@ Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/
| `limits/` | Limit enforcement |
| `medical/` | Medical data |
| `nip95/` | File storage support |
| `nipA3/` | A3 protocol extension |
| `nns/` | Nostr Name System |
| `profileGallery/` | Profile gallery lists |
| `publicMessages/` | Public message lists |
@@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -98,6 +97,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList.Block
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
@@ -149,6 +150,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.bookmarkSets.BookmarkSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.StarterPacksState
@@ -199,7 +201,6 @@ import com.vitorpamplona.quartz.experimental.nip95.header.dimension
import com.vitorpamplona.quartz.experimental.nip95.header.fileSize
import com.vitorpamplona.quartz.experimental.nip95.header.hash
import com.vitorpamplona.quartz.experimental.nip95.header.mimeType
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.profileGallery.blurhash
import com.vitorpamplona.quartz.experimental.profileGallery.dimension
@@ -337,6 +338,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -765,6 +767,9 @@ class Account(
val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings)
val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope)
val favoriteFollowSetsListDecryptionCache = FavoriteFollowSetsListDecryptionCache(signer)
val favoriteFollowSetsList = FavoriteFollowSetsListState(signer, cache, favoriteFollowSetsListDecryptionCache, scope)
val geohashListDecryptionCache = GeohashListDecryptionCache(signer)
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
@@ -2136,6 +2141,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))
@@ -2163,14 +2172,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) {
@@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -84,6 +83,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteFollowSetsLists
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists.FavoriteFollowSetsListDecryptionCache
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
/**
* The account's NIP-51 kind 10021 "Favorite follow sets": kind:30000 follow sets, the user's own
* or anyone else's, pinned so they show up as feeds in the top-nav picker.
*/
class FavoriteFollowSetsListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: FavoriteFollowSetsListDecryptionCache,
val scope: CoroutineScope,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
val favoriteFollowSetsListNote = cache.getOrCreateAddressableNote(getFavoriteFollowSetsListAddress())
fun getFavoriteFollowSetsListAddress() = FavoriteFollowSetsListEvent.createAddress(signer.pubKey)
fun getFavoriteFollowSetsListFlow(): StateFlow<NoteState> = favoriteFollowSetsListNote.flow().metadata.stateFlow
fun getFavoriteFollowSetsList(): FavoriteFollowSetsListEvent? = favoriteFollowSetsListNote.event as? FavoriteFollowSetsListEvent
suspend fun favoriteFollowSets(note: Note): Set<Address> {
val event = note.event as? FavoriteFollowSetsListEvent ?: return emptySet()
return decryptionCache.favoriteFollowSets(event)
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<Address>> =
getFavoriteFollowSetsListFlow()
.transformLatest { noteState ->
emit(favoriteFollowSets(noteState.note))
}.onStart {
emit(favoriteFollowSets(favoriteFollowSetsListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
val flowNotes: StateFlow<List<AddressableNote>> =
flow
.map { addresses ->
addresses.map { cache.getOrCreateAddressableNote(it) }
}.onStart {
emit(flow.value.map { cache.getOrCreateAddressableNote(it) })
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun follow(followSet: AddressBookmark): FavoriteFollowSetsListEvent {
val list = getFavoriteFollowSetsList()
return if (list == null) {
FavoriteFollowSetsListEvent.create(followSet, false, signer)
} else {
FavoriteFollowSetsListEvent.add(list, followSet, false, signer)
}
}
suspend fun unfollow(followSet: Address): FavoriteFollowSetsListEvent? {
val list = getFavoriteFollowSetsList() ?: return null
return FavoriteFollowSetsListEvent.remove(list, followSet, signer)
}
}
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.model.nipA3PaymentTargets
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -28,12 +28,12 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
/**
* What payment rails would actually reach the recipient(s) of a zap.
@@ -31,7 +31,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -36,6 +35,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -85,6 +86,8 @@ val AccountInfoAndListsFromKeyKinds2 =
Bolt12OfferListEvent.KIND,
FavoriteRelayListEvent.KIND,
InterestSetEvent.KIND,
// NIP-51 kind 10021: follow sets (anyone's) pinned as feeds in the top-nav picker.
FavoriteFollowSetsListEvent.KIND,
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers.
// Loaded up-front so "My Groups" and group memberships resolve immediately at login,
// without waiting for the groups screen to mount its own subscription.
@@ -89,6 +89,21 @@ fun observeUserNickname(
return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedNickname(user) })
}
/**
* The name the account knows [user] by, for display: the NIP-85 nickname, else the NIP-02 petname
* from the account's own follow list. Editing UIs should keep using [observeUserNickname].
*/
@Composable
fun observeUserDisplayNickname(
user: User,
accountViewModel: AccountViewModel,
): State<Nickname?> {
val userAssertions = accountViewModel.account.userAssertions
val flow = remember(user) { userAssertions.displayNicknameFlow(user) }
return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedDisplayNickname(user) })
}
@Composable
fun observeUserAboutMe(
user: User,
@@ -74,7 +74,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
@Composable
fun PaymentTargetsScreen(
@@ -23,9 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions.paymentTargets
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -62,7 +63,8 @@ class PaymentTargetsViewModel : ViewModel() {
type: String,
authority: String,
) {
val trimmedType = type.trim().lowercase()
// Stores the NIP-A3 spelling of known aliases (btc -> bitcoin, cashapp -> cashme).
val trimmedType = PaymentTargetTypes.canonical(type)
val trimmedAuthority = authority.trim()
if (trimmedType.isEmpty() || trimmedAuthority.isEmpty()) return
val target = PaymentTarget(trimmedType, trimmedAuthority)
@@ -55,8 +55,8 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.CustomEmojiChecker
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.util.njumpLink
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
@@ -306,7 +306,7 @@ fun RenderUserAsClickableText(
nav: INav,
) {
val userState by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val nickname by observeUserDisplayNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CreateClickableTextWithEmoji(
@@ -124,8 +124,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.inlinePlaceholder
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview
@@ -982,7 +982,7 @@ private fun DisplayUserFromTag(
nav: INav,
) {
val meta by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val nickname by observeUserDisplayNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag") {
@@ -238,8 +238,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTarge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.paymentTargetStyleFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -248,6 +246,8 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
@@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserNameText
import com.vitorpamplona.amethyst.commons.ui.theme.StdButtonSizeModifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
@@ -103,7 +103,7 @@ fun UsernameDisplay(
accountViewModel: AccountViewModel,
) {
val userMetadata by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val nickname by observeUserDisplayNickname(baseUser, accountViewModel)
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") {
// the account's own nickname for this user wins over the user's metadata;
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_follow_set_to_favorites
import com.vitorpamplona.amethyst.commons.resources.remove_follow_set_from_favorites
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
/**
* Star toggle that adds a kind:30000 follow set (anyone's) to the account's NIP-51 kind 10021
* favorite follow sets, which puts it in the top-nav feed picker; tapping again removes it.
*/
@Composable
fun FavoriteFollowSetToggle(
followSetNote: AddressableNote,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
iconSizeModifier: Modifier = Size20Modifier,
) {
if (!accountViewModel.isWriteable()) return
val favorites by accountViewModel.account.favoriteFollowSetsList.flow
.collectAsStateWithLifecycle()
val isFavorite = favorites.contains(followSetNote.address)
ClickableBox(
modifier = modifier,
onClick = {
if (isFavorite) {
accountViewModel.unfollowFavoriteFollowSet(followSetNote.address)
} else {
accountViewModel.followFavoriteFollowSet(
AddressBookmark(
address = followSetNote.address,
relayHint = followSetNote.relayHintUrl(),
),
)
}
},
) {
if (isFavorite) {
Icon(
symbol = MaterialSymbols.Star,
contentDescription = stringRes(Res.string.remove_follow_set_from_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
symbol = MaterialSymbols.StarBorder,
contentDescription = stringRes(Res.string.add_follow_set_to_favorites),
modifier = iconSizeModifier,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
@@ -67,13 +68,16 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
import com.vitorpamplona.amethyst.ui.components.rememberTranslation
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindName
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -100,7 +104,9 @@ fun RenderHighlight(
highlight = noteEvent.quote(),
context = noteEvent.contextOrReconstructed(),
authorHex = noteEvent.author(),
url = noteEvent.inUrl(),
url = noteEvent.inReference(),
externalId = remember(noteEvent) { noteEvent.inExternalIds().firstOrNull() },
externalIdText = remember(noteEvent) { noteEvent.inExternalIdValues().firstOrNull() },
textFragmentPrefix = selector?.prefix,
textFragmentSuffix = selector?.suffix,
postAddress = noteEvent.inPostAddress(),
@@ -176,6 +182,8 @@ fun DisplayHighlight(
postAddress: Address?,
postVersion: ETag?,
makeItShort: Boolean,
externalId: ExternalId? = null,
externalIdText: String? = null,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
@@ -251,6 +259,8 @@ fun DisplayHighlight(
textFragmentSuffix = textFragmentSuffix,
postAddress = postAddress,
postVersion = postVersion,
externalId = externalId,
externalIdText = externalIdText,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -306,6 +316,8 @@ private fun DisplayQuoteAuthor(
textFragmentSuffix: String? = null,
postAddress: Address?,
postVersion: ETag?,
externalId: ExternalId? = null,
externalIdText: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -362,15 +374,25 @@ private fun DisplayQuoteAuthor(
}
}
baseUrl != null -> {
// NIP-84: `r` may be a URL or any text naming the source; only a web address is a link.
baseUrl != null && HighlightEvent.isUrlReference(baseUrl) -> {
val url =
remember(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix) {
buildTextFragmentUrl(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix)
buildTextFragmentUrl(HttpUrlFormatter.addSchemeIfNeeded(baseUrl), highlightQuote, textFragmentPrefix, textFragmentSuffix)
}
DisplayEntryForAUrl(url, userBase, accountViewModel, nav)
}
// A NIP-73 source (`i` tag): a book, paper, podcast episode...
externalId != null -> {
DisplayEntryForExternalId(externalId, userBase, accountViewModel, nav)
}
baseUrl != null || externalIdText != null -> {
DisplayEntryForText(baseUrl ?: externalIdText ?: "", userBase, accountViewModel, nav)
}
userBase != null -> {
userBase?.let {
DisplayEntryForUser(it, accountViewModel, nav)
@@ -437,6 +459,35 @@ fun DisplayEntryForNote(
}
}
@Composable
fun DisplayEntryForExternalId(
externalId: ExternalId,
userBase: User?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (userBase != null) {
DisplayEntryForUser(userBase, accountViewModel, nav)
Text("-", maxLines = 1)
}
DisplayExternalId(externalId, accountViewModel, nav)
}
/** A source named in plain text (a non-URL `r` tag, or an `i` value no NIP-73 parser knows). */
@Composable
fun DisplayEntryForText(
source: String,
userBase: User?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (userBase != null) {
DisplayEntryForUser(userBase, accountViewModel, nav)
Text("-", maxLines = 1)
}
Text(source, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
@Composable
fun DisplayEntryForAUrl(
url: String,
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -83,17 +84,23 @@ fun DisplayPeopleList(
val name by remember(noteEvent) { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } }
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.weight(1f)
.padding(5.dp),
textAlign = TextAlign.Center,
)
(baseNote as? AddressableNote)?.let {
FavoriteFollowSetToggle(it, accountViewModel)
}
}
LaunchedEffect(noteEvent) {
accountViewModel.loadUsers(noteEvent.taggedUserIds()) {
@@ -140,6 +140,7 @@ class TopNavFilterState(
fun mergePeopleLists(
peopleLists: List<AddressableNote>,
followLists: List<AddressableNote>,
favoriteFollowSets: List<AddressableNote> = emptyList(),
): List<FeedDefinition> {
val peopleListsDefs =
peopleLists.map {
@@ -157,19 +158,36 @@ class TopNavFilterState(
)
}
return (peopleListsDefs + followListsDefs).sortedBy { it.name.name() }
// NIP-51 kind 10021: follow sets the user favorited, including other people's. The
// user's own sets are already listed above, so those are not repeated.
val listed = peopleLists.mapTo(HashSet()) { it.address }
val favoriteDefs =
favoriteFollowSets.mapNotNull {
if (it.address in listed) {
null
} else {
FeedDefinition(
TopFilter.PeopleList(it.address),
PeopleListName(it),
)
}
}
return (peopleListsDefs + followListsDefs + favoriteDefs).sortedBy { it.name.name() }
}
val livePeopleListsFlow: Flow<List<FeedDefinition>> =
combine(
account.followSets.peopleListNotes,
account.starterPacks.starterPackNotes,
account.favoriteFollowSetsList.flowNotes,
::mergePeopleLists,
).onStart {
emit(
mergePeopleLists(
account.followSets.peopleListNotes.value,
account.starterPacks.starterPackNotes.value,
account.favoriteFollowSetsList.flowNotes.value,
),
)
}
@@ -1941,6 +1941,10 @@ class AccountViewModel(
fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) }
fun followFavoriteFollowSet(followSet: AddressBookmark) = launchSigner { account.followFavoriteFollowSet(followSet) }
fun unfollowFavoriteFollowSet(followSet: Address) = launchSigner { account.unfollowFavoriteFollowSet(followSet) }
fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm)
fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) }
@@ -100,7 +100,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
@@ -118,6 +117,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
@@ -127,9 +127,6 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -161,6 +158,9 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import org.jetbrains.compose.resources.PluralStringResource
@@ -118,7 +118,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
@@ -149,6 +148,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import org.jetbrains.compose.resources.StringResource
@@ -38,8 +38,8 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserDisplayNickname
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.note.InnerUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderUserCards
import com.vitorpamplona.amethyst.ui.note.WatchUserFollows
@@ -84,7 +84,7 @@ private fun WatchAndDisplayUser(
nav: INav,
) {
val userState by observeUserInfo(author, accountViewModel)
val nickname by observeUserNickname(author, accountViewModel)
val nickname by observeUserDisplayNickname(author, accountViewModel)
val petName = nickname?.petName
// A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key.
val displayName = nameOverride ?: petName ?: userState?.info?.bestName()
@@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in PaymentTargetTypes.LIGHTNING_TYPES
@@ -72,7 +72,7 @@ fun inAppPaymentRouteFor(
/**
* The URI an external wallet app should receive for [target]: the type's own
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/$…`) and RFC 8905
* `payto://` for types Amethyst has no dedicated scheme for. Shared with the
* payment-target dialog so the same pill hands off to the same app wherever
* it is tapped.
@@ -159,6 +159,10 @@ fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
return when (type) {
"bitcoin", "btc", "onchain" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BITCOIN")
"bip352" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "SILENT PAYMENT")
"bip353" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BIP-353")
"lightning", "ln" ->
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LIGHTNING")
"lnurl" ->
@@ -183,8 +187,12 @@ fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
PaymentTargetStyle(walletIcon, SOLANA_PURPLE, "SOLANA")
"tron", "trx" ->
PaymentTargetStyle(walletIcon, TRON_RED, "TRON")
"cashapp" ->
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASHAPP")
"cashme", "cashapp" ->
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASH APP")
"nano", "xno" ->
PaymentTargetStyle(walletIcon, NANO_BLUE, "NANO")
"revolut" ->
PaymentTargetStyle(walletIcon, REVOLUT_BLUE, "REVOLUT")
"venmo" ->
PaymentTargetStyle(walletIcon, VENMO_BLUE, "VENMO")
"paypal" ->
@@ -213,4 +221,6 @@ private val TRON_RED = Color(0xFFEF0027)
private val CASHAPP_LIME = Color(0xFF00E64D)
private val VENMO_BLUE = Color(0xFF008CFF)
private val PAYPAL_DEEP_BLUE = Color(0xFF003087)
private val NANO_BLUE = Color(0xFF209CE9)
private val REVOLUT_BLUE = Color(0xFF0666EB)
private val GENERIC_TARGET_COLOR = Color(0xFF7C8DA0)
@@ -69,7 +69,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import kotlinx.coroutines.launch
@Composable
@@ -78,8 +78,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.rememberProfileClinkOffer
import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress
import kotlinx.coroutines.launch
@@ -195,7 +195,6 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
@@ -324,6 +323,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
@@ -55,6 +55,9 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnamePath
import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
@@ -228,6 +231,12 @@ class SearchBarViewModel(
searchTerm
.debounce(400)
.mapLatest { term ->
// NIP-02 petname path (`~/erin/charlie`, `~npub1…/erin`, `~name@domain/erin`),
// walked over the follow lists already in the cache.
PetnamePath.parse(term)?.let { path ->
return@mapLatest resolvePetnamePath(path)
}
// NIP-05 resolution: user@domain or bare .bit domain
val nip05 =
if (term.contains('@')) {
@@ -293,6 +302,22 @@ class SearchBarViewModel(
}
}.flowOn(Dispatchers.IO)
private suspend fun resolvePetnamePath(path: PetnamePath): User? =
runCatching {
PetnameResolver
.resolve(
path = path,
currentUser = account.userProfile().pubkeyHex,
followListOf = { pubKey ->
(account.cache.getAddressableNoteIfExists(ContactListEvent.createAddress(pubKey))?.event as? ContactListEvent)?.tags
},
resolveNip05 = { identifier ->
Nip05Id.parse(identifier)?.let { nip05Client.get(it)?.pubkey }
},
)?.let { account.cache.getOrCreateUser(it) }
}.onFailure { if (it is CancellationException) throw it }
.getOrNull()
/**
* The routes the box opens on its own, which is now **only** an invite link.
*
@@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletViewModel
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
@Composable
fun NIP47SetupScreen(
@@ -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
},
@@ -30,8 +30,6 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserCards
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
@@ -43,6 +41,8 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlin.concurrent.Volatile
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.model.backups
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
@@ -53,6 +52,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
/** Which of the account's backed-up events a conflict is about. Drives the dialog's wording. */
@@ -177,7 +177,6 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
@@ -292,6 +291,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
@@ -392,6 +392,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
@@ -3864,6 +3865,7 @@ open class EventCache :
is LiveChessDrawOfferEvent,
is InterestListEvent,
is FavoriteAlgoFeedsListEvent,
is FavoriteFollowSetsListEvent,
is IndexerRelayListEvent,
is InteractiveStoryPrologueEvent,
is InteractiveStorySceneEvent,
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteFollowSetsLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.FavoriteFollowSetsListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteFollowSetsList.favoriteFollowSetsSet
class FavoriteFollowSetsListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<FavoriteFollowSetsListEvent>(signer)
fun cachedFavoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteFollowSetsSet()
suspend fun favoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteFollowSetsSet()
}
@@ -22,12 +22,15 @@ package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.petnames.PetnameResolver
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -59,6 +62,28 @@ 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.
private class PetnameIndex(
val event: ContactListEvent,
val petnames: Map<HexKey, String>,
)
private var petnameIndex: PetnameIndex? = null
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))
@@ -114,21 +139,59 @@ class UserAssertionsState(
return decryptionCache.cachedNickname(card)
}
/**
* 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()
/** Synchronous counterpart of [followListPetnameFlow]. */
fun cachedFollowListPetname(target: User): String? = followListPetname(followListNote.event as? ContactListEvent, 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], 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.
* account gave them wins over the profile's own display name, then the
* account's NIP-02 follow-list petname, falling back to the short npub.
*/
fun displayNameFlow(target: User): Flow<String> =
combine(
target.metadata().flow,
nicknameFlow(target),
displayNicknameFlow(target),
) { info, nickname ->
nickname?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex()
}.distinctUntilChanged()
/** Synchronous first value for [displayNameFlow], from already-decrypted data. */
fun cachedDisplayName(target: User): String = cachedNickname(target)?.petName ?: target.toBestDisplayName()
fun cachedDisplayName(target: User): String = cachedDisplayNickname(target)?.petName ?: target.toBestDisplayName()
suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) }
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.model.payments
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
/**
* Picks the NIP-A3 payment targets a sender can hand off to when paying a note's
@@ -37,6 +37,9 @@ package com.vitorpamplona.amethyst.commons.model.payments
* and the installed-app probe (which needs the URI before it has an authority).
*/
object PaymentTargetTypes {
private const val CASHME = "cashme"
private const val DOLLAR = "\$"
/** Lightning-family types Amethyst can pay in-app through the Send Payment screen. */
val LIGHTNING_TYPES = setOf("lightning", "ln", "lnurl")
@@ -62,10 +65,14 @@ object PaymentTargetTypes {
"doge" to "dogecoin",
"sol" to "solana",
"trx" to "tron",
"xno" to "nano",
// NIP-A3 names the Cash App cashtag type `cashme`; `cashapp` is what
// Amethyst (and others) wrote before the spec settled on it.
"cashapp" to "cashme",
)
/** Types whose hand-off is a web page rather than a registered URI scheme. */
private val WEB_TYPES = setOf("cashapp", "venmo", "paypal")
private val WEB_TYPES = setOf("cashme", "venmo", "paypal", "revolut")
/** Types with a dedicated URI scheme, keyed by canonical name. */
private val SCHEMES =
@@ -82,16 +89,31 @@ object PaymentTargetTypes {
"dogecoin" to "dogecoin",
"solana" to "solana",
"tron" to "tron",
"nano" to "nano",
)
/**
* Types whose scheme carries the address in a query parameter instead of the path:
* a BIP-352 silent payment address goes in a BIP-321 `bitcoin:` URI's `sp` field.
*/
private val QUERY_SCHEMES =
mapOf(
"bip352" to "bitcoin:?sp=",
)
private val WEB_HOSTS =
mapOf(
"cashapp" to "https://cash.app/",
"cashme" to "https://cash.app/",
"venmo" to "https://venmo.com/",
"paypal" to "https://paypal.me/",
"revolut" to "https://revolut.me/",
)
/** Trims, lowercases and collapses known aliases onto one family name. */
/**
* Trims, lowercases and collapses known aliases onto one family name. The result is the
* NIP-A3 spelling where the spec lists one (`cashapp` -> `cashme`), so it is also what a
* new `payto` tag should carry.
*/
fun canonical(rawType: String): String {
val trimmed = rawType.trim().lowercase()
return ALIASES[trimmed] ?: trimmed
@@ -130,6 +152,12 @@ object PaymentTargetTypes {
val type = canonical(rawType)
val value = authority.trim()
SCHEMES[type]?.let { return "$it:$value" }
QUERY_SCHEMES[type]?.let { return "$it$value" }
if (type == CASHME) {
// cash.app/$cashtag: NIP-A3 describes the `$`-prefixed tag, but accept a bare one.
val cashtag = if (value.isEmpty() || value.startsWith(DOLLAR)) value else DOLLAR + value
return WEB_HOSTS.getValue(CASHME) + cashtag
}
WEB_HOSTS[type]?.let { return "$it$value" }
return "payto://$type/$value"
}
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.relayClient.profile
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
@@ -33,6 +32,7 @@ import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
val UserProfileListKinds =
listOf(
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
@@ -37,6 +36,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTargetsEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -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"))
@@ -2482,6 +2482,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>
@@ -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
@@ -164,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
@@ -309,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
@@ -607,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"),
@@ -0,0 +1,212 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip02FollowList.petnames
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.coroutines.CancellationException
/** Where a NIP-02 petname path starts. */
@Immutable
sealed interface PetnameRoot {
/** `~/…`: the logged-in user's own follow list. */
data object CurrentUser : PetnameRoot
/** `~npub1…/…`: an absolute root by key. */
data class PubKey(
val pubKey: HexKey,
) : PetnameRoot
/** `~name@domain/…`: an absolute root by NIP-05 identifier. */
data class Nip05(
val identifier: String,
) : PetnameRoot
}
/**
* A NIP-02 petname path such as `~/erin/charlie`, `~npub1…/erin/charlie` or
* `~carol@names.com/erin/charlie`.
*
* Each component is looked up in the follow list (kind 3) of the profile the previous
* component resolved to: `~/erin/charlie` is whoever Erin calls `charlie`, where Erin is
* whoever the current user calls `erin`. Only petnames made of ASCII letters, digits and
* `_` take part in path resolution.
*/
@Immutable
data class PetnamePath(
val root: PetnameRoot,
val names: List<String>,
) {
fun encode(): String {
val prefix =
when (root) {
PetnameRoot.CurrentUser -> "$PREFIX"
is PetnameRoot.PubKey -> PREFIX + NPub.create(root.pubKey)
is PetnameRoot.Nip05 -> PREFIX + root.identifier
}
return if (names.isEmpty()) prefix else prefix + SEPARATOR + names.joinToString(SEPARATOR.toString())
}
companion object {
const val PREFIX = '~'
const val SEPARATOR = '/'
/** NIP-02: only ASCII letters, numbers or `_` qualify for petname resolution. */
fun isEligible(petname: String): Boolean =
petname.isNotEmpty() &&
petname.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' }
/** How a contact of a contact is shown in the current user's context: `~/erin/charlie`. */
fun display(names: List<String>): String = PREFIX + SEPARATOR.toString() + names.joinToString(SEPARATOR.toString())
/** Parses [input] into a path, or null when it is not a well-formed petname path. */
fun parse(input: String): PetnamePath? {
val text = input.trim()
if (text.length < 2 || text[0] != PREFIX) return null
val parts = text.substring(1).split(SEPARATOR)
val rootToken = parts[0]
val names = parts.drop(1)
if (!names.all(::isEligible)) return null
val root =
when {
rootToken.isEmpty() -> PetnameRoot.CurrentUser
rootToken.startsWith("npub1") || rootToken.startsWith("nprofile1") -> PetnameRoot.PubKey(decodeKey(rootToken) ?: return null)
looksLikeNip05(rootToken) -> PetnameRoot.Nip05(rootToken)
else -> return null
}
// `~/` alone names nobody; an absolute root on its own names the root.
if (root == PetnameRoot.CurrentUser && names.isEmpty()) return null
return PetnamePath(root, names)
}
private fun decodeKey(token: String): HexKey? =
try {
when (val entity = Nip19Parser.uriToRoute(token)?.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
private fun looksLikeNip05(token: String): Boolean {
val at = token.indexOf('@')
if (at < 0 || at == token.length - 1) return false
val domain = token.substring(at + 1)
return domain.contains('.') && !domain.contains('@')
}
}
}
/**
* Pure NIP-02 petname lookups over follow-list tags. Knows nothing about caches or relays:
* callers hand in how to get a follow list and how to resolve a NIP-05 identifier.
*/
object PetnameResolver {
/**
* The petname [followList] gives [pubKey], if any. Any non-blank petname counts here:
* the character restriction only applies to resolving paths.
*/
fun petnameOf(
followList: TagArray,
pubKey: HexKey,
): String? =
followList.fastFirstNotNullOfOrNull { tag ->
if (tag.size > 3 && ContactTag.isTagged(tag, pubKey) && tag[3].isNotBlank()) tag[3] else null
}
/**
* Every petname in [followList], by pubkey (the first one wins when a key repeats). Meant to be
* built once per follow-list version and reused for every name rendered against it.
*/
fun petnames(followList: TagArray): Map<HexKey, String> {
val result = HashMap<HexKey, String>()
followList.fastForEach { tag ->
if (tag.size > 3 && ContactTag.isTagged(tag) && tag[3].isNotBlank() && tag[1] !in result) {
result[tag[1]] = tag[3]
}
}
return result
}
/** The pubkey [followList] calls [petname] (exact, case-sensitive match; eligible names only). */
fun findByPetname(
followList: TagArray,
petname: String,
): HexKey? {
if (!PetnamePath.isEligible(petname)) return null
return followList.fastFirstNotNullOfOrNull { tag ->
if (tag.size > 3 && tag[3] == petname && ContactTag.isTagged(tag)) tag[1] else null
}
}
/**
* Resolves [path] one component at a time.
*
* @param currentUser the logged-in user, required for `~/…` paths
* @param followListOf the kind-3 tags of a user, or null when unknown
* @param resolveNip05 resolves a `name@domain` root; the default resolves nothing
* @return the pubkey the path points to, or null when any step cannot be resolved
*/
suspend fun resolve(
path: PetnamePath,
currentUser: HexKey?,
followListOf: suspend (HexKey) -> TagArray?,
resolveNip05: suspend (String) -> HexKey? = { null },
): HexKey? {
var current: HexKey =
when (val root = path.root) {
PetnameRoot.CurrentUser -> currentUser
is PetnameRoot.PubKey -> root.pubKey
is PetnameRoot.Nip05 -> resolveNip05(root.identifier)
} ?: return null
for (name in path.names) {
val followList = followListOf(current) ?: return null
current = findByPetname(followList, name) ?: return null
}
return current
}
/** Parses and resolves [input]; null when it is not a petname path or does not resolve. */
suspend fun resolve(
input: String,
currentUser: HexKey?,
followListOf: suspend (HexKey) -> TagArray?,
resolveNip05: suspend (String) -> HexKey? = { null },
): HexKey? {
val path = PetnamePath.parse(input) ?: return null
return resolve(path, currentUser, followListOf, resolveNip05)
}
}
@@ -38,12 +38,14 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.splitRelayListUpdate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-51 / NIP-37 kind 10013, "Private relays": where the user keeps drafts and other private
* data. NIP-51 requires these relays to be NIP-44 encrypted, so every relay this class writes goes
* into the private content. [publicRelays] still reads plain tags other clients may have left.
*/
@Immutable
class PrivateOutboxRelayListEvent(
id: HexKey,
@@ -83,12 +85,10 @@ class PrivateOutboxRelayListEvent(
): PrivateOutboxRelayListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
// Keeps public relays public and private relays private: rewriting them all as
// private tags blanks the list out for clients that only read the plain tags.
val split = splitRelayListUpdate(earlierVersion.tags.relays(), privateTags.relays(), relays)
val publicTags = earlierVersion.tags.remove(RelayTag::match).plus(split.publicRelays.map { RelayTag.assemble(it) })
val newPrivateTags = privateTags.remove(RelayTag::match).plus(split.privateRelays.map { RelayTag.assemble(it) })
// Unlike the other relay lists, 10013 relays MUST be private (NIP-51): plain relay
// tags another client left behind are moved into the encrypted content, not kept public.
val publicTags = earlierVersion.tags.remove(RelayTag::match)
val newPrivateTags = privateTags.remove(RelayTag::match).plus(relays.map { RelayTag.assemble(it) })
return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags)
}
@@ -111,19 +111,17 @@ class PrivateOutboxRelayListEvent(
return signer.signNip51List(createdAt, KIND, emptyArray(), privateTagArray)
}
/** Builds the list with every relay NIP-44 encrypted in the content, as NIP-51 requires. */
suspend fun build(
publicRelays: List<NormalizedRelayUrl> = emptyList(),
privateRelays: List<NormalizedRelayUrl> = emptyList(),
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PrivateOutboxRelayListEvent>.() -> Unit = {},
) = eventTemplate<PrivateOutboxRelayListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer),
description = PrivateTagsInContent.encryptNip44(relays.map { RelayTag.assemble(it) }.toTypedArray(), signer),
createdAt = createdAt,
) {
privateRelays(publicRelays)
initializer()
}
}
@@ -115,14 +115,17 @@ object Nip44 {
): String {
require(ciphertext.isNotBlank()) { "ciphertext must not be blank" }
// Ignores if it is not base64
val byteArray = Base64.decode(ciphertext)
require(ciphertext.length >= 4) { "ciphertext is too short" }
return when (byteArray[0].toInt()) {
// Only the version byte is needed here: the first base64 quantum (4 chars) holds it.
// Decoding the whole payload would allocate its full size just to be thrown away.
val version = Base64.decode(ciphertext, 0, 4)[0].toInt()
return when (version) {
EncryptedInfo.V -> Nip04.decrypt(ciphertext, privateKey, pubKey)
Nip44v1.EncryptedInfo.V -> v1.decrypt(ciphertext, privateKey, pubKey)
Nip44v2.EncryptedInfo.V -> v2.decrypt(ciphertext, privateKey, pubKey)
else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${byteArray[0].toInt()}")
else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code $version")
}
}
}
@@ -28,10 +28,17 @@ import com.vitorpamplona.quartz.utils.Secp256k1Instance
import com.vitorpamplona.quartz.utils.equalsConstantTime
import kotlinx.coroutines.CancellationException
import kotlin.io.encoding.Base64
import kotlin.math.floor
import kotlin.math.log2
class Nip44v2 {
/**
* NIP-44 v2 encryption.
*
* @param maxPayloadLength the largest base64 payload [decrypt] accepts. NIP-44 lets plaintexts reach
* 2^32 - 1 bytes but asks implementations to set their own ceiling and to enforce it before base64
* decoding, since decrypting needs several times the payload size in working memory.
*/
class Nip44v2(
val maxPayloadLength: Int = DEFAULT_MAX_PAYLOAD_LENGTH,
) {
private val sharedKeyCache = SharedKeyCache()
private val hkdf = Hkdf()
private val chaCha = ChaCha20()
@@ -40,10 +47,11 @@ class Nip44v2 {
private val hashLength = 32
private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b
private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb
private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb, u16 prefix
private val extMinPlaintextSize: Int = 0x00000001 // 1b msg => padded to 32b
private val extMaxPlaintextSize: Long = 0xffffffff // 4294967294 => padded
// Lengths at or above this use the extended [0,0][u32] prefix, and only those may.
private val extMinPlaintextSize: Long = 0x10000 // 65536 => padded to 64kb, 6-byte prefix
private val extMaxPlaintextSize: Long = 0xffffffff // 4294967295 => padded to 2^32
fun clearCache() {
sharedKeyCache.clearCache()
@@ -97,7 +105,7 @@ class Nip44v2 {
fun decrypt(
payload: String,
conversationKey: ByteArray,
): String = decrypt(EncryptedInfo.decodePayload(payload), conversationKey)
): String = decrypt(EncryptedInfo.decodePayload(payload, maxPayloadLength), conversationKey)
fun checkHMacAad(
messageKey: Hkdf.MessageKey,
@@ -137,58 +145,76 @@ class Nip44v2 {
return computed
}
fun calcPaddedLen(len: Int): Int {
fun calcPaddedLen(len: Int): Long = calcPaddedLen(len.toLong())
/**
* NIP-44 padded length. Integer-only: floating point loses precision above 2^24, and the
* result can reach 2^32 (for 2^32 - 1), which only fits 64-bit arithmetic.
*/
fun calcPaddedLen(len: Long): Long {
check(len > 0) { "expected positive integer" }
if (len <= 32) return 32
val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt()
val chunk = if (nextPower <= 256) 32 else nextPower / 8
return chunk * (floor((len - 1f) / chunk).toInt() + 1)
// 1 shl (floor(log2(len - 1)) + 1) == 1 shl bitLength(len - 1)
val nextPower = 1L shl (Long.SIZE_BITS - (len - 1).countLeadingZeroBits())
val chunk = if (nextPower <= 256) 32L else nextPower / 8
return chunk * ((len - 1) / chunk + 1)
}
fun pad(plaintext: String): ByteArray {
val unpadded = plaintext.encodeToByteArray()
val unpaddedLen = unpadded.size
check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" }
check(unpaddedLen >= minPlaintextSize) { "Message is empty ($unpaddedLen): $plaintext" }
val prefix =
if (unpaddedLen <= maxPlaintextSize) {
// 2 bytes in big endian
intTo2BytesBigEndian(unpaddedLen)
} else if (unpaddedLen <= extMaxPlaintextSize) {
// Extension to allow > 65KB payloads
// 2+4 bytes in big endian
byteArrayOf(0, 0) + intTo4BytesBigEndian(unpaddedLen)
} else {
throw IllegalArgumentException("Message is too long ($unpaddedLen): $plaintext")
}
val prefixLen = if (unpaddedLen <= maxPlaintextSize) 2 else 6
val totalLen = prefixLen + calcPaddedLen(unpaddedLen)
val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen)
return prefix + unpadded + suffix
require(totalLen <= Int.MAX_VALUE) {
"Message is too long for this platform ($unpaddedLen bytes pad to $totalLen)"
}
// Zero-filled: everything after the plaintext is already the padding.
val padded = ByteArray(totalLen.toInt())
if (prefixLen == 2) {
// 2 bytes in big endian
padded[0] = (unpaddedLen shr 8).toByte()
padded[1] = (unpaddedLen and 0xFF).toByte()
} else {
// Extension to allow >= 64KB payloads: [0, 0] + 4 bytes in big endian
padded[2] = (unpaddedLen shr 24).toByte()
padded[3] = (unpaddedLen shr 16).toByte()
padded[4] = (unpaddedLen shr 8).toByte()
padded[5] = (unpaddedLen and 0xFF).toByte()
}
unpadded.copyInto(padded, prefixLen)
return padded
}
fun unpad(padded: ByteArray): String {
check(padded.size >= 2) { "Invalid padding: ${padded.size} bytes" }
val unpaddedLenPreExt: Int = bytesToIntBigEndian(padded[0], padded[1])
return if (unpaddedLenPreExt == 0) {
// NIP-44 extension to handle bigger than 65K payloads
val unpaddedLenExt: Int = bytesToIntBigEndian(padded[2], padded[3], padded[4], padded[5])
check(padded.size >= 6) { "Invalid padding: ${padded.size} bytes" }
val unpaddedLenExt: Long = bytesToLongBigEndian(padded[2], padded[3], padded[4], padded[5])
// A length that fits the u16 prefix must use it: rejects a second encoding of short messages.
check(unpaddedLenExt in extMinPlaintextSize..extMaxPlaintextSize) {
"Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize"
}
check(padded.size == 6 + calcPaddedLen(unpaddedLenExt)) {
check(padded.size.toLong() == 6 + calcPaddedLen(unpaddedLenExt)) {
"Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt"
}
padded.decodeToString(6, 6 + unpaddedLenExt)
padded.decodeToString(6, 6 + unpaddedLenExt.toInt())
} else {
check(unpaddedLenPreExt in minPlaintextSize..maxPlaintextSize) {
"Invalid size $unpaddedLenPreExt not between $minPlaintextSize and $maxPlaintextSize"
}
check(padded.size == 2 + calcPaddedLen(unpaddedLenPreExt)) {
check(padded.size.toLong() == 2 + calcPaddedLen(unpaddedLenPreExt)) {
"Invalid padding ${calcPaddedLen(unpaddedLenPreExt)} != $unpaddedLenPreExt"
}
@@ -235,15 +261,29 @@ class Nip44v2 {
companion object {
const val V: Int = 2
fun decodePayload(payload: String): EncryptedInfo {
check(payload.length >= 132) {
// version (1) + nonce (32) + smallest padded plaintext with prefix (2 + 32) + mac (32)
const val MIN_DECODED_LENGTH: Int = 99
// base64 of [MIN_DECODED_LENGTH] bytes
const val MIN_PAYLOAD_LENGTH: Int = 132
fun decodePayload(
payload: String,
maxPayloadLength: Int = DEFAULT_MAX_PAYLOAD_LENGTH,
): EncryptedInfo {
check(payload.isNotEmpty() && payload[0] != '#') { "Unknown encryption version ${payload.getOrNull(0)}" }
check(payload.length >= MIN_PAYLOAD_LENGTH) {
"Invalid payload length ${payload.length} for $payload"
}
check(payload[0] != '#') { "Unknown encryption version ${payload.get(0)}" }
// Before base64 decoding, so an oversized payload costs nothing to reject.
check(payload.length <= maxPayloadLength) {
"Payload length ${payload.length} exceeds the maximum of $maxPayloadLength"
}
return try {
val byteArray = Base64.decode(payload)
check(byteArray[0].toInt() == V)
check(byteArray.size >= MIN_DECODED_LENGTH) { "Invalid decoded length ${byteArray.size}" }
check(byteArray[0].toInt() == V) { "Unknown encryption version ${byteArray[0]}" }
EncryptedInfo(
nonce = byteArray.copyOfRange(1, 33),
ciphertext = byteArray.copyOfRange(33, byteArray.size - 32),
@@ -267,32 +307,22 @@ class Nip44v2 {
byte2: Byte,
): Int = (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF))
private fun bytesToIntBigEndian(
private fun bytesToLongBigEndian(
byte1: Byte,
byte2: Byte,
byte3: Byte,
byte4: Byte,
): Int {
val result =
((byte1.toLong() and 0xFF) shl 24) or
((byte2.toLong() and 0xFF) shl 16) or
((byte3.toLong() and 0xFF) shl 8) or
(byte4.toLong() and 0xFF)
): Long =
((byte1.toLong() and 0xFF) shl 24) or
((byte2.toLong() and 0xFF) shl 16) or
((byte3.toLong() and 0xFF) shl 8) or
(byte4.toLong() and 0xFF)
check(result <= Int.MAX_VALUE) {
"JVM cannot handle more than 2GB payloads. Current length: $result"
}
return result.toInt()
companion object {
/**
* Default ceiling for a base64 payload: 64M chars (~48 MB decoded). Comfortably above the
* spec's 20,000,000-byte test vector while bounding what a hostile event can make us allocate.
*/
const val DEFAULT_MAX_PAYLOAD_LENGTH: Int = 64 * 1024 * 1024
}
private fun intTo2BytesBigEndian(value: Int): ByteArray = byteArrayOf((value shr 8).toByte(), (value and 0xFF).toByte())
private fun intTo4BytesBigEndian(value: Int): ByteArray =
byteArrayOf(
(value shr 24).toByte(),
(value shr 16).toByte(),
(value shr 8).toByte(),
(value and 0xFF).toByte(),
)
}
@@ -0,0 +1,181 @@
/*
* 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.nip51Lists.favoriteFollowSetsList
import androidx.compose.runtime.Immutable
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.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-51 kind 10021, "Favorite follow sets": `a` pointers to kind:30000 follow sets, the
* user's own or anyone else's, that the user wants to keep at hand (e.g. as feeds).
* Like the other standard lists, entries can be public tags or NIP-44 private tags.
*/
@Immutable
class FavoriteFollowSetsListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<FavoriteFollowSetsListDiff> {
// A plain replaceable (10000..19999): a stray `d` tag must not split its address.
override fun dTag() = FIXED_D_TAG
override fun diffFrom(older: Event): FavoriteFollowSetsListDiff? {
if (older !is FavoriteFollowSetsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return FavoriteFollowSetsListDiff(
ListDiff.of(older.publicFavoriteFollowSets(), publicFavoriteFollowSets(), { it.address }, { a, b -> a.relayHint == b.relayHint }),
privateItemsChangeFrom(older),
)
}
fun publicFavoriteFollowSets(): List<AddressBookmark> = tags.favoriteFollowSetBookmarks()
suspend fun privateFavoriteFollowSets(signer: NostrSigner): List<AddressBookmark>? = privateTags(signer)?.favoriteFollowSetBookmarks()
companion object {
const val KIND = 10021
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
suspend fun create(
followSet: AddressBookmark,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteFollowSetsListEvent =
if (isPrivate) {
create(publicFollowSets = emptyList(), privateFollowSets = listOf(followSet), signer = signer, createdAt = createdAt)
} else {
create(publicFollowSets = listOf(followSet), privateFollowSets = emptyList(), signer = signer, createdAt = createdAt)
}
suspend fun add(
earlierVersion: FavoriteFollowSetsListEvent,
followSet: AddressBookmark,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteFollowSetsListEvent =
if (isPrivate) {
val privateTags =
earlierVersion.privateTags(signer)
?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.remove(followSet.toTagIdOnly()) + followSet.toTagArray(),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.remove(followSet.toTagIdOnly()) + followSet.toTagArray(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun remove(
earlierVersion: FavoriteFollowSetsListEvent,
followSet: Address,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteFollowSetsListEvent {
val idOnly = AddressBookmark.assemble(followSet, null)
val privateTags = earlierVersion.privateTags(signer)
return if (privateTags != null) {
resign(
privateTags = privateTags.remove(idOnly),
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
}
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip44(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteFollowSetsListEvent = signer.sign(createdAt, KIND, tags, content)
suspend fun create(
publicFollowSets: List<AddressBookmark> = emptyList(),
privateFollowSets: List<AddressBookmark> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteFollowSetsListEvent = signer.sign(build(publicFollowSets, privateFollowSets, signer, createdAt))
suspend fun build(
publicFollowSets: List<AddressBookmark> = emptyList(),
privateFollowSets: List<AddressBookmark> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<FavoriteFollowSetsListEvent>.() -> Unit = {},
) = eventTemplate<FavoriteFollowSetsListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateFollowSets.map { it.toTagArray() }.toTypedArray(), signer),
createdAt = createdAt,
) {
favoriteFollowSets(publicFollowSets)
initializer()
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.nip51Lists.favoriteFollowSetsList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
/** `a` pointers to kind:30000 follow sets; pointers to any other kind are not follow sets and are skipped. */
fun TagArray.favoriteFollowSetBookmarks() = mapNotNull { tag -> AddressBookmark.parse(tag)?.takeIf { it.address.kind == FollowSetEvent.KIND } }
fun TagArray.favoriteFollowSetsSet() = mapNotNullTo(mutableSetOf()) { tag -> AddressBookmark.parseAddress(tag)?.takeIf { it.kind == FollowSetEvent.KIND } }
fun TagArrayBuilder<FavoriteFollowSetsListEvent>.favoriteFollowSet(followSet: AddressBookmark) = add(followSet.toTagArray())
fun TagArrayBuilder<FavoriteFollowSetsListEvent>.favoriteFollowSets(followSets: List<AddressBookmark>) = addAll(followSets.map { it.toTagArray() })
/** Changes to the favorite follow sets: public ones as parsed bookmarks, private ones as a whole. */
@Immutable
class FavoriteFollowSetsListDiff(
val followSets: ListDiff<AddressBookmark>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(followSets.hasRemovals()) || privateItems.isRemoval()
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.removeAny
@@ -60,6 +61,11 @@ class InterestListEvent(
fun publicHashtags() = tags.mapNotNull(HashtagTag::parse)
/** NIP-51: besides hashtags, kind 10015 can point (`a`) to kind:30015 interest sets. */
fun publicInterestSets(): List<AddressBookmark> = tags.interestSetPointers()
suspend fun privateInterestSets(signer: NostrSigner): List<AddressBookmark>? = privateTags(signer)?.interestSetPointers()
companion object {
const val KIND = 10015
const val FIXED_D_TAG = ""
@@ -194,8 +200,8 @@ class InterestListEvent(
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): InterestListEvent {
val privateTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray()
val publicTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray()
val publicTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray()
val privateTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray()
return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray)
}
@@ -22,7 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.interestList
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
fun TagArray.hashtagList() = mapNotNull(HashtagTag::parse)
fun TagArray.hashtagSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parse)
/** `a` pointers to kind:30015 interest sets, as kind 10015 may carry them. */
fun TagArray.interestSetPointers() = mapNotNull { tag -> AddressBookmark.parse(tag)?.takeIf { it.address.kind == InterestSetEvent.KIND } }
@@ -34,10 +34,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relayFeeds
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relaySetPointers
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.splitRelayListUpdate
@@ -64,6 +66,13 @@ class FavoriteRelayListEvent(
suspend fun decryptRelays(signer: NostrSigner): List<NormalizedRelayUrl> = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList())
/** NIP-51: besides relays, kind 10012 can point (`a`) to kind:30002 relay sets. */
fun publicRelaySets(): List<AddressBookmark> = tags.relaySetPointers()
suspend fun decryptPrivateRelaySets(signer: NostrSigner) = privateTags(signer)?.relaySetPointers()
suspend fun decryptRelaySets(signer: NostrSigner): List<AddressBookmark> = publicRelaySets() + (decryptPrivateRelaySets(signer) ?: emptyList())
companion object {
const val KIND = 10012
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists.tags
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
@@ -48,8 +47,6 @@ fun TagArrayBuilder<IndexerRelayListEvent>.indexerRelays(relays: List<Normalized
fun TagArrayBuilder<ProxyRelayListEvent>.proxyRelays(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
fun TagArrayBuilder<PrivateOutboxRelayListEvent>.privateRelays(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
fun TagArrayBuilder<RelaySetEvent>.relaySet(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
fun TagArrayBuilder<FavoriteRelayListEvent>.relayFeeds(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
@@ -21,7 +21,12 @@
package com.vitorpamplona.quartz.nip51Lists.relayLists.tags
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
fun TagArray.relays() = mapNotNull(RelayTag::parse)
fun TagArray.relaySet() = mapNotNullTo(mutableSetOf(), RelayTag::parse)
/** `a` pointers to kind:30002 relay sets, as kind 10012 may carry them. */
fun TagArray.relaySetPointers() = mapNotNull { tag -> AddressBookmark.parse(tag)?.takeIf { it.address.kind == RelaySetEvent.KIND } }
@@ -34,9 +34,17 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Kind 30008. NIP-58 now defines it as a NIP-51 "Badge set" (categorized groups of badges, any
* `d` tag, `a` badge definitions paired with `e` awards); the `profile_badges` d tag is the legacy
* form of the kind 10008 Profile Badges list, which is what [createAddress] points to.
*/
@Immutable
class AcceptedBadgeSetEvent(
id: HexKey,
@@ -67,6 +75,16 @@ class AcceptedBadgeSetEvent(
fun badgeAwardDefinitions() = tags.badgeAwardDefinitions()
/** True for the legacy profile-badges list (`d` = `profile_badges`), false for a general badge set. */
fun isLegacyProfileBadges() = dTag() == STANDARD_D_TAG
/** NIP-51 set metadata of a general badge set. */
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
companion object {
const val KIND = 30008
private const val STANDARD_D_TAG = "profile_badges"
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUserId
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -49,8 +50,10 @@ import com.vitorpamplona.quartz.nip19Bech32.eventIds
import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints
import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip22Comments.tags.ReplyIdentifierTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag
import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag
import com.vitorpamplona.quartz.nip84Highlights.tags.TextQuoteSelectorTag
@@ -126,7 +129,32 @@ class HighlightEvent(
return pHints + nip19Hints
}
fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse)
/**
* The highlight's `r` source. NIP-84 lets it be a URL or any other text naming the source,
* so this is not necessarily a URL: check with [isUrlReference] before opening it. Prefers the
* tag marked `source` and skips `mention` ones, which quote highlights use for URLs cited in
* their comment.
*/
fun inReference(): String? {
var firstUnmarked: String? = null
tags.forEach { tag ->
if (tag.size > 1 && tag[0] == ReferenceTag.TAG_NAME && tag[1].isNotBlank()) {
val marker = tag.getOrNull(2)
if (marker == SOURCE_MARKER) return tag[1]
if (marker != MENTION_MARKER && firstUnmarked == null) firstUnmarked = tag[1]
}
}
return firstUnmarked
}
/** The `r` source when it is a web address; see [inReference] for any text. */
fun inUrl(): String? = inReference()?.takeIf(::isUrlReference)
/** The NIP-73 external ids (`i` tags) the highlight was taken from: a book, paper, podcast... */
fun inExternalIds(): List<ExternalId> = tags.mapNotNull(ReplyIdentifierTag::parseExternalId)
/** Raw `i` tag values, including the ones no [ExternalId] parser recognizes. */
fun inExternalIdValues(): List<String> = tags.mapNotNull(ReplyIdentifierTag::parse)
/**
* The pubkey of the author of the highlighted content.
@@ -194,6 +222,29 @@ class HighlightEvent(
/** NIP-84 role marker on the `p` tag that identifies the highlighted content's author. */
private const val AUTHOR_MARKER = "author"
/** NIP-84 marker for the `r` tag naming the highlighted source in a quote highlight. */
const val SOURCE_MARKER = "source"
/** NIP-84 marker for `r`/`p` tags cited by a quote highlight's comment. */
const val MENTION_MARKER = "mention"
private val WHITESPACE = Regex("\\s")
/**
* Whether an `r` value reads as a web address. NIP-84 allows any text there ("Chapter 3
* of Dune"), which must neither be opened as a link nor get `https://` glued on.
*/
fun isUrlReference(reference: String): Boolean {
val trimmed = reference.trim()
if (trimmed.isEmpty() || WHITESPACE.containsMatchIn(trimmed)) return false
if (trimmed.startsWith("https://", ignoreCase = true) || trimmed.startsWith("http://", ignoreCase = true)) {
return HttpUrlFormatter.isValidUrl(trimmed)
}
// Scheme-less host/path, as some clients write it: needs a dotted host.
val host = trimmed.substringBefore('/').substringBefore('?').substringBefore('#')
return host.contains('.') && !host.startsWith('.') && !host.endsWith('.') && HttpUrlFormatter.isValidUrl(trimmed)
}
/** Any run of whitespace (spaces, tabs, newlines) — collapsed to a single space. */
private val WHITESPACE_RUN = Regex("\\s+")
@@ -211,8 +262,10 @@ class HighlightEvent(
* - [address] → an `a` reference to a nostr addressable source (e.g. a NIP-23 article),
* - [event] → an `e` reference to a specific nostr event version highlighted,
* - [author] → a `p` attribution to the highlighted content's author,
* - [url] → an `r` source reference (normalized by [ReferenceTag]; clean it of
* trackers with [com.vitorpamplona.quartz.nip84Highlights.parse.UrlTrackerCleaner] first),
* - [url] → an `r` source reference. A web address is normalized by [ReferenceTag] (clean
* it of trackers with [com.vitorpamplona.quartz.nip84Highlights.parse.UrlTrackerCleaner]
* first); any other text is kept verbatim, as NIP-84 allows,
* - [externalIds] → `i` tags for NIP-73 sources (ISBNs, DOIs, podcast episodes...),
* - [prefix]/[suffix] → a `textquoteselector` anchor (the `exact` field stays a
* placeholder since the passage already lives in `content`),
* - [context] → the surrounding paragraph as a `context` tag,
@@ -231,9 +284,10 @@ class HighlightEvent(
address: String? = null,
event: String? = null,
author: String? = null,
externalIds: List<ExternalId> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HighlightEvent = signer.sign(createdAt, KIND, assembleTags(url, prefix, suffix, comment, context, address, event, author), quote)
): HighlightEvent = signer.sign(createdAt, KIND, assembleTags(url, prefix, suffix, comment, context, address, event, author, externalIds), quote)
/**
* The unsigned [EventTemplate] counterpart of [create], for the app's
@@ -250,11 +304,12 @@ class HighlightEvent(
address: String? = null,
event: String? = null,
author: String? = null,
externalIds: List<ExternalId> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HighlightEvent>.() -> Unit = {},
): EventTemplate<HighlightEvent> =
eventTemplate(KIND, quote, createdAt) {
addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author))
addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author, externalIds))
initializer()
}
@@ -267,6 +322,7 @@ class HighlightEvent(
address: String? = null,
event: String? = null,
author: String? = null,
externalIds: List<ExternalId> = emptyList(),
): Array<Array<String>> {
val tags = mutableListOf<Array<String>>()
@@ -281,8 +337,18 @@ class HighlightEvent(
// carries `mention` p tags — the producer-side counterpart of that reader logic.
tags.add(arrayOf(PTag.TAG_NAME, author, "", AUTHOR_MARKER))
}
externalIds.forEach { tags.add(ReplyIdentifierTag.assemble(it)) }
if (!url.isNullOrBlank()) {
tags.add(ReferenceTag.assemble(url))
// `r` may be any text: only a web address gets URL-normalized. A quote highlight
// marks its source so the comment's own cited URLs (`mention`) can't be mistaken for it.
val reference = if (isUrlReference(url)) HttpUrlFormatter.normalize(url) else url.trim()
tags.add(
if (comment.isNullOrBlank()) {
arrayOf(ReferenceTag.TAG_NAME, reference)
} else {
arrayOf(ReferenceTag.TAG_NAME, reference, SOURCE_MARKER)
},
)
}
if (!prefix.isNullOrEmpty() || !suffix.isNullOrEmpty()) {
tags.add(TextQuoteSelectorTag.assemble(null, prefix, suffix))
@@ -18,7 +18,7 @@
* 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.experimental.nipA3
package com.vitorpamplona.quartz.nipA3PaymentTargets
data class PaymentTarget(
val type: String,
@@ -18,7 +18,7 @@
* 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.experimental.nipA3
package com.vitorpamplona.quartz.nipA3PaymentTargets
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
@@ -38,9 +38,11 @@ class PaymentTargetTag {
ensure(tag.has(2)) { return null }
ensure(tag[2].isNotEmpty()) { return null }
return PaymentTarget(tag[1], tag[2])
// NIP-A3: the type is always lowercase. Normalize what other clients wrote so
// `Bitcoin` and `bitcoin` are one rail.
return PaymentTarget(tag[1].lowercase(), tag[2])
}
fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority)
fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type.lowercase(), paymentTarget.authority)
}
}
@@ -18,7 +18,7 @@
* 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.experimental.nipA3
package com.vitorpamplona.quartz.nipA3PaymentTargets
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
@@ -18,7 +18,7 @@
* 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.experimental.nipA3
package com.vitorpamplona.quartz.nipA3PaymentTargets
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
@@ -55,10 +55,11 @@ class WebBookmarkEvent(
visitor.visit(description())
}
fun url(): String {
val dTagValue = dTag()
return if (dTagValue.isNotEmpty()) "https://$dTagValue" else ""
}
/**
* The bookmarked URI. NIP-B0 only drops the scheme for `https`, so a d tag without a scheme
* is an https URL and anything else (`http://`, `gemini://`, `magnet:`...) is already complete.
*/
fun url(): String = dTagToUrl(dTag())
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
@@ -71,23 +72,73 @@ class WebBookmarkEvent(
companion object {
const val KIND = 39701
fun urlToDTag(url: String): String =
url
.removePrefix("https://")
.removePrefix("http://")
.trimEnd('/')
private const val HTTPS_PREFIX = "https://"
// RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed by ":"
private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.\\-]*):(.*)$")
// What follows "host:" when the colon introduces a port rather than ending a scheme.
private val PORT = Regex("^[0-9]{1,5}([/?#].*)?$")
/**
* True when [uri] starts with a scheme (`http://`, `mailto:`, `magnet:`) rather than a
* `host[:port]` of a scheme-less https d tag. `://` always means a scheme; otherwise a
* dotted or `localhost` prefix, or a numeric port after the colon, means a host.
*/
fun hasScheme(uri: String): Boolean {
val match = SCHEME.find(uri) ?: return false
val candidate = match.groupValues[1]
val rest = match.groupValues[2]
if (rest.startsWith("//")) return true
if (candidate.contains('.') || candidate.equals("localhost", ignoreCase = true)) return false
return !PORT.matches(rest)
}
/**
* NIP-B0 d tag: the URI itself, except that for `https` everything before the hostname
* (scheme, `//` and any userinfo) is omitted. Other schemes, `http` included, are kept.
*
* The spec says nothing about trailing slashes or case; this keeps the long-standing
* trailing-slash trim (so re-saving an old bookmark lands on the same address) and does
* not change case.
*/
fun urlToDTag(url: String): String {
val trimmed = url.trim()
val uri =
if (trimmed.startsWith(HTTPS_PREFIX, ignoreCase = true)) {
val afterScheme = trimmed.substring(HTTPS_PREFIX.length)
val authorityEnd = afterScheme.indexOfAny(charArrayOf('/', '?', '#')).let { if (it < 0) afterScheme.length else it }
val userInfoEnd = afterScheme.lastIndexOf('@', authorityEnd - 1)
if (userInfoEnd >= 0) afterScheme.substring(userInfoEnd + 1) else afterScheme
} else {
trimmed
}
return uri.trimEnd('/')
}
fun dTagToUrl(dTag: String): String =
when {
dTag.isEmpty() -> ""
hasScheme(dTag) -> dTag
else -> HTTPS_PREFIX + dTag
}
/**
* @param firstPublishedAt when the bookmark was first published; pass the original value when
* editing so it survives the replacement.
*/
fun build(
url: String,
bookmarkTitle: String?,
description: String,
tags: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
firstPublishedAt: Long = createdAt,
initializer: TagArrayBuilder<WebBookmarkEvent>.() -> Unit = {},
) = eventTemplate<WebBookmarkEvent>(KIND, description, createdAt) {
dTag(urlToDTag(url))
bookmarkTitle?.let { title(it) }
publishedAt(createdAt)
publishedAt(firstPublishedAt)
hashtags(tags)
initializer()
}
@@ -149,7 +149,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
@@ -264,6 +263,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
@@ -411,6 +411,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
@@ -702,6 +703,7 @@ class EventFactory {
GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig)
ZapGoalEvent.KIND -> ZapGoalEvent(id, pubKey, createdAt, tags, content, sig)
FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig)
FavoriteFollowSetsListEvent.KIND -> FavoriteFollowSetsListEvent(id, pubKey, createdAt, tags, content, sig)
InterestListEvent.KIND -> InterestListEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip02FollowList.petnames
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PetnamePathTest {
private val me = "a".repeat(64)
private val erin = "e".repeat(64)
private val charlie = "c".repeat(64)
private val carol = "b".repeat(64)
private val dave = "d".repeat(64)
private fun p(
pubKey: HexKey,
petname: String? = null,
) = if (petname == null) arrayOf("p", pubKey) else arrayOf("p", pubKey, "", petname)
private val followLists: Map<HexKey, TagArray> =
mapOf(
me to arrayOf(p(erin, "erin"), p(dave), p(carol, "Carol Smith")),
erin to arrayOf(p(charlie, "charlie"), p(me, "boss")),
carol to arrayOf(p(erin, "erin_2")),
)
private suspend fun follows(pubKey: HexKey): TagArray? = followLists[pubKey]
@Test
fun parsesRelativePaths() {
assertEquals(PetnamePath(PetnameRoot.CurrentUser, listOf("erin", "charlie")), PetnamePath.parse("~/erin/charlie"))
assertEquals(PetnamePath(PetnameRoot.CurrentUser, listOf("erin")), PetnamePath.parse(" ~/erin "))
}
@Test
fun parsesAbsoluteRoots() {
val npub = NPub.create(carol)
assertEquals(PetnamePath(PetnameRoot.PubKey(carol), listOf("erin_2", "charlie")), PetnamePath.parse("~$npub/erin_2/charlie"))
assertEquals(PetnamePath(PetnameRoot.PubKey(carol), emptyList()), PetnamePath.parse("~$npub"))
assertEquals(PetnamePath(PetnameRoot.Nip05("carol@names.com"), listOf("erin", "charlie")), PetnamePath.parse("~carol@names.com/erin/charlie"))
}
@Test
fun rejectsMalformedPaths() {
assertNull(PetnamePath.parse("erin/charlie"))
assertNull(PetnamePath.parse("~"))
assertNull(PetnamePath.parse("~/"))
assertNull(PetnamePath.parse("~/erin//charlie"))
assertNull(PetnamePath.parse("~/erin/"))
assertNull(PetnamePath.parse("~/Carol Smith"))
assertNull(PetnamePath.parse("~/josé"))
assertNull(PetnamePath.parse("~npub1notakey/erin"))
assertNull(PetnamePath.parse("~carol/erin"))
}
@Test
fun onlyAsciiLettersDigitsAndUnderscoreAreEligible() {
assertTrue(PetnamePath.isEligible("erin_2"))
assertTrue(PetnamePath.isEligible("ERIN9"))
assertFalse(PetnamePath.isEligible(""))
assertFalse(PetnamePath.isEligible("erin-2"))
assertFalse(PetnamePath.isEligible("erin.2"))
assertFalse(PetnamePath.isEligible("Carol Smith"))
assertFalse(PetnamePath.isEligible("émile"))
}
@Test
fun encodesBackToText() {
assertEquals("~/erin/charlie", PetnamePath(PetnameRoot.CurrentUser, listOf("erin", "charlie")).encode())
assertEquals("~carol@names.com/erin", PetnamePath(PetnameRoot.Nip05("carol@names.com"), listOf("erin")).encode())
assertEquals("~" + NPub.create(carol) + "/erin_2", PetnamePath(PetnameRoot.PubKey(carol), listOf("erin_2")).encode())
assertEquals("~/erin/charlie", PetnamePath.display(listOf("erin", "charlie")))
}
@Test
fun resolvesOneComponentAtATime() =
runTest {
assertEquals(erin, PetnameResolver.resolve("~/erin", me, ::follows))
assertEquals(charlie, PetnameResolver.resolve("~/erin/charlie", me, ::follows))
assertEquals(me, PetnameResolver.resolve("~/erin/boss", me, ::follows))
}
@Test
fun resolvesAbsoluteRootsWithoutALoggedInUser() =
runTest {
val npub = NPub.create(carol)
assertEquals(charlie, PetnameResolver.resolve("~$npub/erin_2/charlie", null, ::follows))
assertEquals(carol, PetnameResolver.resolve("~$npub", null, ::follows))
assertEquals(
charlie,
PetnameResolver.resolve("~carol@names.com/erin_2/charlie", null, ::follows) { if (it == "carol@names.com") carol else null },
)
}
@Test
fun failsWhenAnyStepIsMissing() =
runTest {
assertNull(PetnameResolver.resolve("~/erin", null, ::follows))
assertNull(PetnameResolver.resolve("~/nobody", me, ::follows))
assertNull(PetnameResolver.resolve("~/erin/charlie/x", me, ::follows)) // charlie's list is unknown
assertNull(PetnameResolver.resolve("~/Erin", me, ::follows)) // exact match
assertNull(PetnameResolver.resolve("~carol@names.com/erin_2", null, ::follows)) // no NIP-05 resolver
assertNull(PetnameResolver.resolve("not a path", me, ::follows))
}
@Test
fun petnameOfReturnsAnyPetnameForDisplay() {
val mine = followLists.getValue(me)
assertEquals("erin", PetnameResolver.petnameOf(mine, erin))
assertEquals("Carol Smith", PetnameResolver.petnameOf(mine, carol))
assertNull(PetnameResolver.petnameOf(mine, dave))
assertNull(PetnameResolver.petnameOf(mine, charlie))
// Ineligible names still display but never resolve a path.
assertNull(PetnameResolver.findByPetname(mine, "Carol Smith"))
assertEquals(mapOf(erin to "erin", carol to "Carol Smith"), PetnameResolver.petnames(mine))
}
}
@@ -56,7 +56,7 @@ class Nip44v2BaseTest {
fun paddingTest() {
for (v in vectors.v2?.valid?.calcPaddedLen!!) {
val actual = nip44v2.calcPaddedLen(v[0])
assertEquals(v[1], actual)
assertEquals(v[1].toLong(), actual)
}
}
@@ -0,0 +1,136 @@
/*
* 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.nip44Encryption
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlin.io.encoding.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Padding and payload-size rules from NIP-44 v2 (extended length prefix, 64-bit padding math).
*/
class Nip44v2PaddingTest {
private val nip44v2 = Nip44v2()
@Test
fun paddingUsesIntegerMathAboveFloatPrecision() {
// Float math rounds these up to the next bucket (25165824 / 41943040).
assertEquals(20_971_520L, nip44v2.calcPaddedLen(20_971_520))
assertEquals(33_554_432L, nip44v2.calcPaddedLen(33_554_432))
assertEquals(41_943_040L, nip44v2.calcPaddedLen(33_554_433))
assertEquals(20_971_520L, nip44v2.calcPaddedLen(16_777_217))
}
@Test
fun paddingDoesNotOverflowNearTheJvmArrayLimit() {
assertEquals(1L shl 30, nip44v2.calcPaddedLen(1 shl 30))
assertEquals(1_342_177_280L, nip44v2.calcPaddedLen((1 shl 30) + 1))
assertEquals(1L shl 31, nip44v2.calcPaddedLen(Int.MAX_VALUE))
// The spec's theoretical maximum: 2^32 - 1 pads to 2^32, which needs 64-bit math.
assertEquals(1L shl 32, nip44v2.calcPaddedLen(0xffffffffL))
}
@Test
fun paddingMatchesSpecFormulaAroundBoundaries() {
val probes = listOf(33L, 256L, 257L, 65_535L, 65_536L, 65_537L, 1L shl 24, (1L shl 24) + 1, 20_000_000L, 100_000_000L)
for (n in probes) {
assertEquals(specPaddedLen(n), nip44v2.calcPaddedLen(n), "calcPaddedLen($n)")
}
}
@Test
fun padUsesExtendedPrefixFrom65536() {
val small = nip44v2.pad("a".repeat(65_535))
assertEquals(2 + 65_536, small.size)
assertEquals(0xff.toByte(), small[0])
assertEquals(0xff.toByte(), small[1])
val big = nip44v2.pad("a".repeat(65_536))
assertEquals(6 + 65_536, big.size)
assertEquals(listOf<Byte>(0, 0, 0, 1, 0, 0), big.copyOfRange(0, 6).toList())
assertEquals("a".repeat(65_536), nip44v2.unpad(big))
}
@Test
fun unpadRejectsExtendedPrefixForShortLengths() {
// [0,0][u32 = 5]["hello"][zeros] would be a second encoding of a 5-byte plaintext.
val padded = ByteArray(6 + 32)
padded[5] = 5
"hello".encodeToByteArray().copyInto(padded, 6)
assertFailsWith<IllegalStateException> { nip44v2.unpad(padded) }
// The largest length that must not use the extended prefix.
val padded2 = ByteArray(6 + 65_536)
padded2[4] = 0xff.toByte()
padded2[5] = 0xff.toByte()
assertFailsWith<IllegalStateException> { nip44v2.unpad(padded2) }
}
@Test
fun unpadRejectsZeroLength() {
assertFailsWith<IllegalStateException> { nip44v2.unpad(ByteArray(2 + 32)) }
assertFailsWith<IllegalStateException> { nip44v2.unpad(ByteArray(6 + 32)) }
}
@Test
fun rejectsOversizedPayloadBeforeDecoding() {
val key = RandomInstance.bytes(32)
val payload = nip44v2.encrypt("a".repeat(1000), key).encodePayload()
val limited = Nip44v2(maxPayloadLength = payload.length - 1)
val error = assertFailsWith<IllegalStateException> { limited.decrypt(payload, key) }
assertTrue(error.message!!.contains("exceeds"), error.message)
// Not base64 at all: must still fail on the size check, without reaching the decoder.
val garbage = "!".repeat(1000)
val error2 = assertFailsWith<IllegalStateException> { Nip44v2.EncryptedInfo.decodePayload(garbage, maxPayloadLength = 500) }
assertTrue(error2.message!!.contains("exceeds"), error2.message)
assertEquals("a".repeat(1000), Nip44v2(maxPayloadLength = payload.length).decrypt(payload, key))
}
@Test
fun defaultMaxPayloadAcceptsTheLongestSpecVector() {
// 20,000,000-byte plaintext from encrypt_decrypt_long_msg.
val rawLen = 1 + 32 + 6 + nip44v2.calcPaddedLen(20_000_000) + 32
val base64Len = (rawLen + 2) / 3 * 4
assertTrue(base64Len <= Nip44v2.DEFAULT_MAX_PAYLOAD_LENGTH)
}
@Test
fun rejectsShortDecodedPayload() {
// Long enough as text (132 chars) but only 98 bytes once decoded.
val tooShort = Base64.encode(byteArrayOf(2) + ByteArray(97))
assertEquals(132, tooShort.length)
assertFailsWith<IllegalStateException> { Nip44v2.EncryptedInfo.decodePayload(tooShort) }
}
private fun specPaddedLen(n: Long): Long {
if (n <= 32) return 32
val bitLength = 64 - (n - 1).countLeadingZeroBits()
val nextPower = 1L shl bitLength
val chunk = if (nextPower <= 256) 32 else nextPower / 8
return chunk * ((n - 1) / chunk + 1)
}
}
@@ -0,0 +1,96 @@
/*
* 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.nip51Lists
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* NIP-51: kind 10012 may point to kind:30002 relay sets and kind 10015 to kind:30015 interest
* sets. Those `a` pointers must be readable and must survive edits of the relays/hashtags.
*/
class SetPointersInStandardListsTest {
private val keyPair = "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()
private val signer = NostrSignerInternal(keyPair)
private val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
private val nosLol = RelayUrlNormalizer.normalize("wss://nos.lol")
private val relaySet = "30002:" + "a".repeat(64) + ":my-relays"
private val interestSet = "30015:" + "b".repeat(64) + ":nostr"
private val notASet = "30023:" + "b".repeat(64) + ":article"
@Test
fun favoriteRelayListReadsAndKeepsRelaySetPointers() =
runTest {
val before =
signer.sign(
EventTemplate<FavoriteRelayListEvent>(
1740669816,
FavoriteRelayListEvent.KIND,
arrayOf(arrayOf("relay", damus.url), arrayOf("a", relaySet), arrayOf("a", notASet)),
"",
),
)
assertEquals(listOf(Address.parse(relaySet)), before.publicRelaySets().map { it.address })
val after = FavoriteRelayListEvent.updateRelayList(before, listOf(damus, nosLol), signer, 1740669817)
assertEquals(listOf(Address.parse(relaySet)), after.decryptRelaySets(signer).map { it.address })
assertEquals(setOf(damus, nosLol), after.decryptRelays(signer).toSet())
}
@Test
fun interestListReadsAndKeepsInterestSetPointers() =
runTest {
val before =
signer.sign(
EventTemplate<InterestListEvent>(
1740669816,
InterestListEvent.KIND,
arrayOf(arrayOf("t", "nostr"), arrayOf("a", interestSet), arrayOf("a", notASet)),
"",
),
)
assertEquals(listOf(Address.parse(interestSet)), before.publicInterestSets().map { it.address })
val added = InterestListEvent.add(before, "bitcoin", isPrivate = false, signer = signer, createdAt = 1740669817)
val removed = InterestListEvent.remove(added, "nostr", signer, 1740669818)
assertEquals(listOf(Address.parse(interestSet)), removed.publicInterestSets().map { it.address })
assertEquals(listOf("bitcoin"), removed.publicHashtags())
}
@Test
fun syncInterestListCreateKeepsPublicAndPrivateApart() =
runTest {
val event = InterestListEvent.create(listOf("public"), listOf("private"), NostrSignerSync(keyPair), 1740669816)
assertEquals(listOf("public"), event.publicHashtags())
assertEquals(listOf("private"), event.privateTags(signer)?.mapNotNull { if (it[0] == "t") it[1] else null })
}
}
@@ -0,0 +1,96 @@
/*
* 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.nip51Lists.favoriteFollowSetsList
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.utils.EventFactory
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class FavoriteFollowSetsListEventTest {
private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair())
private fun followSet(
pubkey: String,
dTag: String = "friends",
) = AddressBookmark(Address(30000, pubkey, dTag))
@Test
fun kindMatchesSpec() {
assertEquals(10021, FavoriteFollowSetsListEvent.KIND)
assertEquals(10021, FavoriteFollowSetsListEvent.createAddress("a".repeat(64)).kind)
assertEquals("", FavoriteFollowSetsListEvent.createAddress("a".repeat(64)).dTag)
}
@Test
fun eventFactoryBuildsTheTypedEvent() {
val event = EventFactory.create<Event>("0".repeat(64), "1".repeat(64), 1L, 10021, emptyArray(), "", "0".repeat(128))
assertIs<FavoriteFollowSetsListEvent>(event)
}
@Test
fun addsAndRemovesPublicFollowSets() =
runTest {
val mine = followSet(signer.pubKey)
val theirs = followSet("b".repeat(64), "devs")
val first = FavoriteFollowSetsListEvent.create(mine, isPrivate = false, signer = signer, createdAt = 1740669816)
val second = FavoriteFollowSetsListEvent.add(first, theirs, isPrivate = false, signer = signer, createdAt = 1740669817)
val dupe = FavoriteFollowSetsListEvent.add(second, theirs, isPrivate = false, signer = signer, createdAt = 1740669818)
assertEquals(listOf(mine.address, theirs.address), dupe.publicFavoriteFollowSets().map { it.address })
val removed = FavoriteFollowSetsListEvent.remove(dupe, mine.address, signer, 1740669819)
assertEquals(listOf(theirs.address), removed.publicFavoriteFollowSets().map { it.address })
}
@Test
fun keepsPrivateFollowSetsEncrypted() =
runTest {
val secret = followSet("c".repeat(64), "secret")
val event = FavoriteFollowSetsListEvent.create(secret, isPrivate = true, signer = signer, createdAt = 1740669816)
assertTrue(event.publicFavoriteFollowSets().isEmpty())
assertEquals(listOf(secret.address), event.privateFavoriteFollowSets(signer)?.map { it.address })
val removed = FavoriteFollowSetsListEvent.remove(event, secret.address, signer, 1740669817)
assertEquals(emptyList(), removed.privateFavoriteFollowSets(signer)?.map { it.address })
}
@Test
fun ignoresPointersThatAreNotFollowSets() {
val tags =
arrayOf(
arrayOf("a", "30000:" + "a".repeat(64) + ":friends"),
arrayOf("a", "30002:" + "a".repeat(64) + ":relays"),
arrayOf("a", "39089:" + "a".repeat(64) + ":pack"),
)
assertEquals(listOf("friends"), tags.favoriteFollowSetBookmarks().map { it.address.dTag })
assertEquals(setOf(Address(30000, "a".repeat(64), "friends")), tags.favoriteFollowSetsSet())
}
}
@@ -116,11 +116,26 @@ class RelayListPublicEntriesTest {
}
@Test
fun privateOutboxRelayListKeepsTheSurvivorPublic() =
fun privateOutboxRelayListMovesEveryRelayIntoThePrivateContent() =
runTest {
// The exception: NIP-51 requires kind 10013 relays to be NIP-44 encrypted, so the plain
// tags another client wrote are moved into the content instead of being kept public.
val before = publicListFromAnotherClient<PrivateOutboxRelayListEvent>(PrivateOutboxRelayListEvent.KIND)
val after = PrivateOutboxRelayListEvent.updateRelayList(before, listOf(damus), signer, 1740669817)
assertEquals(listOf(damus), after.tags.relays())
val after = PrivateOutboxRelayListEvent.updateRelayList(before, listOf(damus, nosLol), signer, 1740669817)
assertEquals(emptyList(), after.tags.relays())
assertEquals(listOf(damus, nosLol), after.privateRelays(signer))
}
@Test
fun privateOutboxRelayListBuildsOnlyPrivateRelays() =
runTest {
val created = PrivateOutboxRelayListEvent.create(listOf(damus), signer, 1740669817)
assertEquals(emptyList(), created.tags.relays())
assertEquals(listOf(damus), created.privateRelays(signer))
val built = signer.sign(PrivateOutboxRelayListEvent.build(listOf(damus), signer, 1740669817))
assertEquals(emptyList(), built.tags.relays())
assertEquals(listOf(damus), built.privateRelays(signer))
}
@Test
@@ -0,0 +1,61 @@
/*
* 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.nip58Badges
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BadgeSetEventTest {
private val issuer = "a".repeat(64)
@Test
fun readsAGeneralNip51BadgeSet() {
val set =
AcceptedBadgeSetEvent(
"0".repeat(64),
"1".repeat(64),
1L,
arrayOf(
arrayOf("d", "conferences"),
arrayOf("title", "Conferences"),
arrayOf("description", "Badges from events I attended"),
arrayOf("a", "30009:$issuer:nostrasia"),
arrayOf("e", "b".repeat(64)),
),
"",
"",
)
assertFalse(set.isLegacyProfileBadges())
assertEquals("Conferences", set.title())
assertEquals("Badges from events I attended", set.description())
assertEquals(1, set.acceptedBadges().size)
}
@Test
fun recognizesTheLegacyProfileBadges() {
val legacy = AcceptedBadgeSetEvent("0".repeat(64), "1".repeat(64), 1L, arrayOf(arrayOf("d", "profile_badges")), "", "")
assertTrue(legacy.isLegacyProfileBadges())
}
}
@@ -0,0 +1,116 @@
/*
* 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.nip84Highlights
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip73ExternalIds.books.BookId
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** NIP-84 sources: `i` tags for NIP-73 ids, and `r` tags that may hold any text. */
class HighlightSourceTest {
private val signer = NostrSignerInternal(KeyPair())
private fun highlight(vararg tags: Array<String>) = HighlightEvent("00", "00", 0, arrayOf(*tags), "a passage", "00")
@Test
fun readsNip73ExternalIds() {
val event = highlight(arrayOf("i", "isbn:9780765382030"), arrayOf("i", "custom:thing"))
val ids = event.inExternalIds()
assertEquals(1, ids.size)
assertEquals("isbn:9780765382030", ids.first().toScope())
assertEquals(listOf("isbn:9780765382030", "custom:thing"), event.inExternalIdValues())
}
@Test
fun aTextReferenceIsNotAUrl() {
val event = highlight(arrayOf("r", "Dune, chapter 3"))
assertEquals("Dune, chapter 3", event.inReference())
assertNull(event.inUrl())
}
@Test
fun aWebReferenceIsStillAUrl() {
val event = highlight(arrayOf("r", "https://example.com/post"))
assertEquals("https://example.com/post", event.inReference())
assertEquals("https://example.com/post", event.inUrl())
}
@Test
fun prefersTheSourceMarkedReferenceOverMentions() {
val event =
highlight(
arrayOf("r", "https://cited.example.com", "mention"),
arrayOf("r", "https://source.example.com", "source"),
)
assertEquals("https://source.example.com", event.inReference())
val onlyMention = highlight(arrayOf("r", "https://cited.example.com", "mention"))
assertNull(onlyMention.inReference())
}
@Test
fun classifiesReferences() {
assertTrue(HighlightEvent.isUrlReference("https://example.com/post?x=1"))
assertTrue(HighlightEvent.isUrlReference("http://example.com"))
assertTrue(HighlightEvent.isUrlReference("example.com/post"))
assertFalse(HighlightEvent.isUrlReference("Dune, chapter 3"))
assertFalse(HighlightEvent.isUrlReference("The Bitcoin whitepaper"))
assertFalse(HighlightEvent.isUrlReference("Bitcoin"))
assertFalse(HighlightEvent.isUrlReference(""))
}
@Test
fun builderKeepsATextReferenceVerbatim() =
runTest {
val event = HighlightEvent.create(quote = "a passage", url = " Dune, chapter 3 ", signer = signer)
assertContentEquals(arrayOf("r", "Dune, chapter 3"), event.tags.first { it[0] == "r" })
assertEquals("Dune, chapter 3", event.inReference())
}
@Test
fun builderStillNormalizesAWebReference() =
runTest {
val event = HighlightEvent.create(quote = "a passage", url = "example.com/post", signer = signer)
assertEquals("https://example.com/post", event.inUrl())
}
@Test
fun builderMarksTheSourceOfAQuoteHighlight() =
runTest {
val event = HighlightEvent.create(quote = "a passage", url = "https://example.com/post", comment = "so true", signer = signer)
assertContentEquals(arrayOf("r", "https://example.com/post", "source"), event.tags.first { it[0] == "r" })
}
@Test
fun builderWritesExternalIds() =
runTest {
val event = HighlightEvent.create(quote = "a passage", externalIds = listOf(BookId("978-0765382030")), signer = signer)
assertContentEquals(arrayOf("i", "isbn:9780765382030"), event.tags.first { it[0] == "i" })
assertEquals("isbn:9780765382030", event.inExternalIds().single().toScope())
}
}
@@ -0,0 +1,61 @@
/*
* 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.nipA3PaymentTargets
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
class PaymentTargetTagTest {
@Test
fun parsesTheSpecExample() {
val tags =
arrayOf(
arrayOf("payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"),
arrayOf("payto", "nano", "nano_1dctqbmqxfppo9pswbm6kg9d4s4mbraqn8i4m7ob9gnzz91aurmuho48jx3c"),
arrayOf("payto", "unknowntype", "l7tbta5b9xze6ckkfc99uohzxd009b0r"),
)
val event = PaymentTargetsEvent("0".repeat(64), "1".repeat(64), 1L, tags, "", "")
assertEquals(
listOf(
PaymentTarget("bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"),
PaymentTarget("nano", "nano_1dctqbmqxfppo9pswbm6kg9d4s4mbraqn8i4m7ob9gnzz91aurmuho48jx3c"),
PaymentTarget("unknowntype", "l7tbta5b9xze6ckkfc99uohzxd009b0r"),
),
event.paymentTargets(),
)
}
@Test
fun typeIsAlwaysLowercase() {
assertEquals(PaymentTarget("bitcoin", "bc1q"), PaymentTargetTag.parse(arrayOf("payto", "Bitcoin", "bc1q")))
assertContentEquals(arrayOf("payto", "cashme", "\$Vitor"), PaymentTargetTag.assemble(PaymentTarget("CashMe", "\$Vitor")))
}
@Test
fun rejectsIncompleteTags() {
assertNull(PaymentTargetTag.parse(arrayOf("payto", "bitcoin")))
assertNull(PaymentTargetTag.parse(arrayOf("payto", "", "bc1q")))
assertNull(PaymentTargetTag.parse(arrayOf("payto", "bitcoin", "")))
assertNull(PaymentTargetTag.parse(arrayOf("r", "bitcoin", "bc1q")))
}
}
@@ -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.nipB0WebBookmarks
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WebBookmarkEventTest {
private fun bookmark(dTag: String) = WebBookmarkEvent("0".repeat(64), "1".repeat(64), 1L, arrayOf(arrayOf("d", dTag)), "", "")
@Test
fun httpsDropsEverythingBeforeTheHostname() {
assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("https://alice.blog/post"))
assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("HTTPS://alice.blog/post"))
assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("https://user:pw@alice.blog/post"))
assertEquals("alice.blog:8443/post?q=1#f", WebBookmarkEvent.urlToDTag("https://alice.blog:8443/post?q=1#f"))
}
@Test
fun otherSchemesAreKept() {
assertEquals("http://alice.i2p/post", WebBookmarkEvent.urlToDTag("http://alice.i2p/post"))
assertEquals("ftp://files.example.com/a.txt", WebBookmarkEvent.urlToDTag("ftp://files.example.com/a.txt"))
assertEquals("gemini://alice.space/post", WebBookmarkEvent.urlToDTag("gemini://alice.space/post"))
assertEquals("magnet:?xt=urn:btih:abc", WebBookmarkEvent.urlToDTag("magnet:?xt=urn:btih:abc"))
}
@Test
fun schemelessInputIsAlreadyInHttpsForm() {
assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("alice.blog/post"))
assertEquals("localhost:8080/x", WebBookmarkEvent.urlToDTag("localhost:8080/x"))
}
@Test
fun trailingSlashIsTrimmedAsBefore() {
// NIP-B0 is silent on trailing slashes; keep the long-standing trim so existing d tags still match.
assertEquals("alice.blog", WebBookmarkEvent.urlToDTag("https://alice.blog/"))
assertEquals("http://alice.blog", WebBookmarkEvent.urlToDTag("http://alice.blog/"))
}
@Test
fun urlRestoresHttpsOnlyWhenTheDTagHasNoScheme() {
assertEquals("https://alice.blog/post", bookmark("alice.blog/post").url())
assertEquals("https://alice.blog:8443/post", bookmark("alice.blog:8443/post").url())
assertEquals("https://localhost:8080/x", bookmark("localhost:8080/x").url())
assertEquals("http://alice.i2p/post", bookmark("http://alice.i2p/post").url())
assertEquals("gemini://alice.space/post", bookmark("gemini://alice.space/post").url())
assertEquals("magnet:?xt=urn:btih:abc", bookmark("magnet:?xt=urn:btih:abc").url())
assertEquals("", bookmark("").url())
}
@Test
fun roundTripsThroughTheDTag() {
listOf(
"https://alice.blog/post",
"http://alice.i2p/post",
"ftp://files.example.com/a.txt",
"magnet:?xt=urn:btih:abc",
).forEach { url ->
assertEquals(url, bookmark(WebBookmarkEvent.urlToDTag(url)).url())
}
}
@Test
fun detectsSchemes() {
assertTrue(WebBookmarkEvent.hasScheme("http://a.b"))
assertTrue(WebBookmarkEvent.hasScheme("mailto:alice@a.b"))
assertFalse(WebBookmarkEvent.hasScheme("a.b/c"))
assertFalse(WebBookmarkEvent.hasScheme("a.b:80/c"))
assertFalse(WebBookmarkEvent.hasScheme("localhost:80"))
assertFalse(WebBookmarkEvent.hasScheme("192.168.0.1:80/c"))
}
@Test
fun buildKeepsTheOriginalPublishedAt() {
val template = WebBookmarkEvent.build("http://alice.i2p/post", "t", "", createdAt = 200, firstPublishedAt = 100)
assertEquals("http://alice.i2p/post", template.tags.first { it[0] == "d" }[1])
assertEquals("100", template.tags.first { it[0] == "published_at" }[1])
}
}
@@ -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"
}
]
},