diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index 996addba10..88b1488950 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -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 | diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 256a27ed35..707167a37c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e2a51f8237..9b08bff1d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 81932f49de..fbf80b4a67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListState.kt new file mode 100644 index 0000000000..e7b8309762 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListState.kt @@ -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 = favoriteFollowSetsListNote.flow().metadata.stateFlow + + fun getFavoriteFollowSetsList(): FavoriteFollowSetsListEvent? = favoriteFollowSetsListNote.event as? FavoriteFollowSetsListEvent + + suspend fun favoriteFollowSets(note: Note): Set
{ + val event = note.event as? FavoriteFollowSetsListEvent ?: return emptySet() + return decryptionCache.favoriteFollowSets(event) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getFavoriteFollowSetsListFlow() + .transformLatest { noteState -> + emit(favoriteFollowSets(noteState.note)) + }.onStart { + emit(favoriteFollowSets(favoriteFollowSetsListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val flowNotes: StateFlow> = + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt index b2012e8794..fc58597571 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 286ded391f..993c9e1355 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt index 4970e9aec4..8657b94992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index a4403f5659..877aefd589 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index ba253f5585..54d6667e67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -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 { + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt index 8aacd82061..3be6faab17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt index 362df0c9cc..4192f2599a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index c1029548c3..91d448980f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 981807d040..38e6e84d96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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") { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index bc90957b86..ea9b705ab1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 98fd7a4478..fbf94432b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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; diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FavoriteFollowSetToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FavoriteFollowSetToggle.kt new file mode 100644 index 0000000000..6979d181a4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FavoriteFollowSetToggle.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 2fd5c0428f..5418504ac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -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, @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index 1f5a19840b..e567b43ac5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -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()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 04e304dcee..5e1b5da3ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -140,6 +140,7 @@ class TopNavFilterState( fun mergePeopleLists( peopleLists: List, followLists: List, + favoriteFollowSets: List = emptyList(), ): List { 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> = 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, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2d308b0f12..80bfb805b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt index 942c0c7827..4091812db7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt index 9be726a715..fc4184018b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt index ab29c78d75..9980be0f5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 38541c96f9..ddbc9a2cba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt index 142417dafe..4f65d19674 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt index 1941a50d95..d1ed9b394a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index 775012e84e..6ee12c1654 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt index c8b6e75aaf..1b80cdec86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 0e00dd0e72..369946244e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -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. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index 2e091a4cf1..7d155b5587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt index 149d7d79f5..d39857b4df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -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 }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index aaa614f854..9168c65c5e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt index b1ffb3334d..b77d36e4d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt @@ -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. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 0954ef7a9e..992eb25bcf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListDecryptionCache.kt new file mode 100644 index 0000000000..b625d90e31 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteFollowSetsLists/FavoriteFollowSetsListDecryptionCache.kt @@ -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(signer) + + fun cachedFavoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteFollowSetsSet() + + suspend fun favoriteFollowSets(event: FavoriteFollowSetsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteFollowSetsSet() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt index 1837e2334c..c422e772a2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt @@ -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, + ) + + 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 = + 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 = + 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 = 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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt index fa8bccab2f..b360bbad8d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt index e82aa23c43..a6d03e4aa3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt @@ -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" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt index 444f4e1695..f27c2fc3bc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt @@ -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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index 0d1290113d..bab6f362a6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt index 3c63157b04..07cdef970b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -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")) diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 500df7421b..ac1ac25b4f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2482,6 +2482,8 @@ Select an option to filter the feed Add feed algorithm to favorites Remove from favorites + Add follow set to favorite feeds + Remove follow set from favorite feeds Feed algorithms you star appear here and as filter chips on the Home feed. Pin your favorite algorithms Tap "%1$s" below to browse algorithms. diff --git a/quartz/src/androidDeviceTest/resources/nip44.vectors.json b/quartz/src/androidDeviceTest/resources/nip44.vectors.json index 4d105420f3..09b7a7882b 100644 --- a/quartz/src/androidDeviceTest/resources/nip44.vectors.json +++ b/quartz/src/androidDeviceTest/resources/nip44.vectors.json @@ -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" } ] }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 0df568b07a..588e9782b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -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"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePath.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePath.kt new file mode 100644 index 0000000000..8f2d9c064d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePath.kt @@ -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, +) { + 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 = 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 { + val result = HashMap() + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt index 9734cb1d4f..765ba27086 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt @@ -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 = emptyList(), - privateRelays: List = emptyList(), + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate( 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() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt index c5eb78b4f8..c53f8862ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt @@ -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") } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index 98baa80452..5c3efeceac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -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(), - ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEvent.kt new file mode 100644 index 0000000000..fde30eb82f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + // 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 = tags.favoriteFollowSetBookmarks() + + suspend fun privateFavoriteFollowSets(signer: NostrSigner): List? = 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 = emptyList(), + privateFollowSets: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteFollowSetsListEvent = signer.sign(build(publicFollowSets, privateFollowSets, signer, createdAt)) + + suspend fun build( + publicFollowSets: List = emptyList(), + privateFollowSets: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateFollowSets.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + favoriteFollowSets(publicFollowSets) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListExt.kt new file mode 100644 index 0000000000..a633a6f94b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListExt.kt @@ -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.favoriteFollowSet(followSet: AddressBookmark) = add(followSet.toTagArray()) + +fun TagArrayBuilder.favoriteFollowSets(followSets: List) = 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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(followSets.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt index eaac7eaab7..8933fa48cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt @@ -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 = tags.interestSetPointers() + + suspend fun privateInterestSets(signer: NostrSigner): List? = 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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt index 9819d231a8..463fdd46b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt @@ -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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt index 29179f5d54..c0409803a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt @@ -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 = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + /** NIP-51: besides relays, kind 10012 can point (`a`) to kind:30002 relay sets. */ + fun publicRelaySets(): List = tags.relaySetPointers() + + suspend fun decryptPrivateRelaySets(signer: NostrSigner) = privateTags(signer)?.relaySetPointers() + + suspend fun decryptRelaySets(signer: NostrSigner): List = publicRelaySets() + (decryptPrivateRelaySets(signer) ?: emptyList()) + companion object { const val KIND = 10012 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt index c2dfb98574..bbb2ba4bbd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -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.indexerRelays(relays: List.proxyRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) -fun TagArrayBuilder.privateRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) - fun TagArrayBuilder.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) fun TagArrayBuilder.relayFeeds(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt index 43cb125f91..ef4185e9c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt @@ -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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt index fe4d7be2ba..526dbb8230 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/accepted/AcceptedBadgeSetEvent.kt @@ -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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index d5af20931b..8812301aa7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -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 = tags.mapNotNull(ReplyIdentifierTag::parseExternalId) + + /** Raw `i` tag values, including the ones no [ExternalId] parser recognizes. */ + fun inExternalIdValues(): List = 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 = 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 = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate = 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 = emptyList(), ): Array> { val tags = mutableListOf>() @@ -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)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTarget.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTarget.kt index 66e17243ca..9868f40b19 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTarget.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTag.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTag.kt index 5be6ef4927..442963e6ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTag.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsDiff.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsDiff.kt index 29ddcc0f85..b55619134a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsDiff.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsEvent.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsEvent.kt index c1982a6d02..55e1d53d27 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetsEvent.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt index 9f928defbc..97cac54110 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt @@ -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 = emptyList(), createdAt: Long = TimeUtils.now(), + firstPublishedAt: Long = createdAt, initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { dTag(urlToDTag(url)) bookmarkTitle?.let { title(it) } - publishedAt(createdAt) + publishedAt(firstPublishedAt) hashtags(tags) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index a3986c0516..e122de8707 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePathTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePathTest.kt new file mode 100644 index 0000000000..3ff657f1fc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip02FollowList/petnames/PetnamePathTest.kt @@ -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 = + 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)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt index 5ddbde8c18..f31161bb67 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt @@ -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) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt new file mode 100644 index 0000000000..9ff794032c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt @@ -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(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 { 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 { nip44v2.unpad(padded2) } + } + + @Test + fun unpadRejectsZeroLength() { + assertFailsWith { nip44v2.unpad(ByteArray(2 + 32)) } + assertFailsWith { 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 { 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 { 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 { 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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/SetPointersInStandardListsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/SetPointersInStandardListsTest.kt new file mode 100644 index 0000000000..6e1c50e096 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/SetPointersInStandardListsTest.kt @@ -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( + 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( + 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 }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEventTest.kt new file mode 100644 index 0000000000..1d04837ba7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteFollowSetsList/FavoriteFollowSetsListEventTest.kt @@ -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("0".repeat(64), "1".repeat(64), 1L, 10021, emptyArray(), "", "0".repeat(128)) + assertIs(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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt index 484ecb6f2f..459a1c7383 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt @@ -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.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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/BadgeSetEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/BadgeSetEventTest.kt new file mode 100644 index 0000000000..d45cb7b6ee --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/BadgeSetEventTest.kt @@ -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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightSourceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightSourceTest.kt new file mode 100644 index 0000000000..3c577edf1b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightSourceTest.kt @@ -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) = 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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTagTest.kt new file mode 100644 index 0000000000..18f75b4609 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA3PaymentTargets/PaymentTargetTagTest.kt @@ -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"))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt new file mode 100644 index 0000000000..761912059d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt @@ -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]) + } +} diff --git a/quartz/src/commonTest/resources/nip44.vectors.json b/quartz/src/commonTest/resources/nip44.vectors.json index 4d105420f3..09b7a7882b 100644 --- a/quartz/src/commonTest/resources/nip44.vectors.json +++ b/quartz/src/commonTest/resources/nip44.vectors.json @@ -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" } ] },