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