mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'upstream/main' into claude/embed-browser-keyboard-focus-kcgqqc
This commit is contained in:
@@ -94,6 +94,32 @@ class MetadataFilterAssembler(
|
||||
|
||||
Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey.
|
||||
|
||||
## Per-visible loading — the canonical entry points (`observeUser*` / `observeNote*`)
|
||||
|
||||
Prefer these over hand-rolled "load metadata for this list" calls. They are the shared,
|
||||
KMP way to load data **only for what's on screen** — a composable subscribes while it is in
|
||||
composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in
|
||||
`commons/relayClient/`:
|
||||
|
||||
- **Per user** (`relayClient/user/`): `observeUserInfo/Picture/Banner/AboutMe/Name(user)`
|
||||
each open a composition-scoped `UserFinderFilterAssemblerSubscription(user)` **and** return
|
||||
reactive `State`. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced
|
||||
into one batched REQ per relay for the whole visible set.
|
||||
- **Per note** (`relayClient/event/`): `EventFinderFilterAssemblerSubscription(note)` loads a
|
||||
note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's
|
||||
`observeNote*` display observers layer on top of the same subscription.
|
||||
|
||||
Both read front-end-provided CompositionLocals — `LocalUserFinder` / `LocalUserFinderAccount`
|
||||
(reused by the event finder) / `LocalEventFinder` — provided once near the composition root
|
||||
(Android `AppModules`, Desktop `Main.kt` via its subscriptions coordinator). The account seam
|
||||
is the narrow `UserFinderAccount` (snapshot relay-hint getters), NOT the fat `IAccount`.
|
||||
`error()` defaults mean these must never be reached from a composition without a relay client
|
||||
(e.g. the Android `:napplet` sandbox).
|
||||
|
||||
The load-once, viewport-batch path (`FeedMetadataCoordinator.loadMetadataForNotes` /
|
||||
`loadMetadataBatched`) is superseded for foreground loading; `MetadataPreloader` remains only
|
||||
as an optional off-screen background warmer.
|
||||
|
||||
## Preloaders
|
||||
|
||||
`MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks.
|
||||
|
||||
@@ -92,13 +92,29 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# Mirrors the same step in create-release.yml so this job exercises the
|
||||
# exact .deb release ships. libskiko-linux-arm64.so has libEGL.so.1 in
|
||||
# DT_NEEDED and jpackage does not scan lib/app/ for Depends, so without
|
||||
# this the arm64 app dies at startup with
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# See scripts/add-deb-libegl-dep.sh for the full rationale.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Install .deb
|
||||
run: |
|
||||
# Installed via apt (not `dpkg -i`) so the .deb's declared Depends are
|
||||
# actually resolved — that is what pulls in libegl1 on the arm64
|
||||
# runner, which does not ship it preinstalled.
|
||||
#
|
||||
# jpackage's post-install script runs xdg-desktop-menu which fails
|
||||
# on CI runners ("No writable system menu directory"). The files are
|
||||
# extracted successfully; only the menu registration fails. Allow the
|
||||
# dpkg error, then verify the binary was actually installed.
|
||||
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
|
||||
# install error, then verify the binary was actually installed.
|
||||
sudo apt-get install -y ./desktopApp/build/compose/binaries/main-release/deb/*.deb || true
|
||||
echo "Installed files:"
|
||||
dpkg -L amethyst | head -30
|
||||
# Fail if the binary wasn't actually extracted
|
||||
|
||||
@@ -165,27 +165,17 @@ object FavoriteAppLauncher {
|
||||
return when (event) {
|
||||
is RootNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
"",
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: "Napplet",
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = "",
|
||||
)
|
||||
is NamedNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
event.identifier(),
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: event.identifier(),
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = event.identifier(),
|
||||
)
|
||||
is RootSiteEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
|
||||
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
@@ -59,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -286,6 +289,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
@@ -354,7 +358,8 @@ class Account(
|
||||
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
|
||||
signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
|
||||
nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
|
||||
) : IAccount {
|
||||
) : IAccount,
|
||||
UserFinderAccount {
|
||||
private var userProfileCache: User? = null
|
||||
|
||||
override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
|
||||
@@ -366,6 +371,34 @@ class Account(
|
||||
override val hiddenUsersHashCodes: Set<Int> get() = hiddenUsers.flow.value.hiddenUsersHashCodes
|
||||
override val spammersHashCodes: Set<Int> get() = hiddenUsers.flow.value.spammersHashCodes
|
||||
|
||||
// UserFinderAccount — narrow, read-only relay-hint view used by the shared
|
||||
// per-user metadata + per-note event finders (moved to commons). Snapshot
|
||||
// getters read `.value` fresh on every filter rebuild. userFinderPubkeyHex
|
||||
// doubles as the attribution pubkey for ExplainedFilter.accountPubKeys.
|
||||
override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex
|
||||
|
||||
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList }
|
||||
|
||||
override fun outboxHomeRelays(): Set<NormalizedRelayUrl> = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value
|
||||
|
||||
// searchRelayList.flow already applies the DefaultSearchRelayList fallback internally
|
||||
// (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here.
|
||||
override fun searchRelays(): Set<NormalizedRelayUrl> = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet()
|
||||
|
||||
override fun searchOnlyRelays(): Set<NormalizedRelayUrl> = searchRelayList.flow.value
|
||||
|
||||
override fun followPlusAllMineWithSearchRelays(): Set<NormalizedRelayUrl> = followPlusAllMineWithSearch.flow.value
|
||||
|
||||
override fun commonRelays(): Set<NormalizedRelayUrl> = followSharedOutboxesOrProxy.flow.value.ifEmpty { Constants.eventFinderRelays }
|
||||
|
||||
override fun cardHomeRelays(): Set<NormalizedRelayUrl> = homeRelays.flow.value
|
||||
|
||||
override fun trustProvider(): ServiceProviderTag? = trustProviderList.liveUserRankProvider.value
|
||||
|
||||
override fun followerCountProvider(): ServiceProviderTag? = trustProviderList.liveUserFollowerCount.value
|
||||
|
||||
override fun declaredFollowsByOutboxRelay(): Map<NormalizedRelayUrl, Set<HexKey>> = declaredFollowsPerOutboxRelay.value
|
||||
|
||||
val userMetadata = UserMetadataState(signer, cache, scope, settings)
|
||||
|
||||
// Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
@@ -33,18 +34,19 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
@@ -52,8 +54,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -63,6 +68,9 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Name of the default Concord community Admin role minted by "Make admin". */
|
||||
@@ -76,6 +84,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||
*/
|
||||
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
|
||||
*
|
||||
* 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44
|
||||
* encryptions at the ceiling — heavy but survivable on a phone, and far above any real community.
|
||||
* Raising it raises the cost of the attack it exists to bound, not the safety.
|
||||
*/
|
||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||
|
||||
/**
|
||||
* Concord (encrypted communities) orchestration for an [Account]: join/create/
|
||||
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
|
||||
@@ -158,6 +175,138 @@ class AccountConcordActions(
|
||||
return community.communityIdHex
|
||||
}
|
||||
|
||||
// ---- CORD-05 Invite List (kind 13303) -------------------------------------
|
||||
|
||||
/**
|
||||
* This account's Invite List (kind 13303): the creator's private, self-encrypted record of every
|
||||
* link they minted (`token` + `signer_sk` per entry).
|
||||
*
|
||||
* Returns **null** when the list could not be read — no relay answered, or the signer refused
|
||||
* the decrypt — and an empty document only when the account genuinely has no list yet. Callers
|
||||
* must not conflate the two: republishing an "empty" list over this replaceable coordinate
|
||||
* destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated.
|
||||
*
|
||||
* Read on the account's OUTBOX relays, never a community's: the coordinate is
|
||||
* (13303, me, "") — one list for the whole account — so scoping it per community would fork it
|
||||
* into divergent versions that the newest-wins rule then silently collapses.
|
||||
*
|
||||
* Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is
|
||||
* bookkeeping the user never sees, needed only at mint and at rotation.
|
||||
*/
|
||||
private suspend fun readConcordInviteList(): ConcordInviteListDocument? {
|
||||
val relays = account.outboxRelays.flow.value
|
||||
if (relays.isEmpty()) return null
|
||||
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey))
|
||||
// Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay
|
||||
// served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle
|
||||
// timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes
|
||||
// the signer_sk of every link it failed to read, so the two must be told apart.
|
||||
val reasons = mutableMapOf<NormalizedRelayUrl, String>()
|
||||
val events =
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
doneOut = reasons,
|
||||
) { _, _ -> true }
|
||||
|
||||
val newest =
|
||||
events
|
||||
.mapNotNull { it.second as? ConcordInviteListEvent }
|
||||
// Filter by kind BEFORE picking the newest: taking the newest of anything and then
|
||||
// casting means one stray event at this coordinate reads as "unreadable" forever.
|
||||
.maxByOrNull { it.createdAt }
|
||||
?: return if (reasons.anyRelayServed()) {
|
||||
ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one
|
||||
} else {
|
||||
null // nobody answered; we know nothing about what is published
|
||||
}
|
||||
return newest.decrypt(account.signer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges [patch] into the published Invite List and republishes it, returning whether it landed.
|
||||
*
|
||||
* Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is
|
||||
* replaceable, so publishing a patch-only document over an unread list deletes every other
|
||||
* link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next
|
||||
* rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is
|
||||
* enough to trigger that, which is exactly how the kind-13302 community list was once emptied.
|
||||
*/
|
||||
private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean {
|
||||
val publishTo = account.outboxRelays.flow.value
|
||||
if (publishTo.isEmpty()) return false
|
||||
val base =
|
||||
readConcordInviteList() ?: run {
|
||||
Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" }
|
||||
return false
|
||||
}
|
||||
// publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event
|
||||
// and never reports acceptance — so a `runCatching { publish(); true }` is true whenever
|
||||
// local signing worked, and every caller's "did the record land?" gate becomes decorative.
|
||||
return runCatching {
|
||||
account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo)
|
||||
}.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-posts every live link this account minted for [entry]'s community at its own coordinate,
|
||||
* carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the
|
||||
* link signer, so this moves the link behind the same URL instead of orphaning it at a dead
|
||||
* epoch — which is the whole premise stranded recovery rests on.
|
||||
*
|
||||
* [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow
|
||||
* decrypts asynchronously, so reading it straight after adopting a new root yields the OLD
|
||||
* epoch and would re-mint every link onto the epoch we just left.
|
||||
*
|
||||
* Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link
|
||||
* fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A
|
||||
* coordinate whose newest event is a revocation tombstone is left alone: re-posting a live
|
||||
* bundle over it would silently un-revoke the link.
|
||||
*/
|
||||
private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int {
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||
if (relays.isEmpty()) return 0
|
||||
val list = readConcordInviteList() ?: return 0
|
||||
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// An elapsed or retired link can no longer be joined; re-posting it would only resurrect a
|
||||
// dead URL at a live epoch.
|
||||
val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned }
|
||||
if (links.isEmpty()) return 0
|
||||
|
||||
// One REQ for every link's bundle rather than a round trip each. This runs inside the
|
||||
// user-visible Refounding, and a serial fetch per link makes a removal take time linear in
|
||||
// how many links the creator ever minted, each able to wait out its own idle timeout.
|
||||
val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() }
|
||||
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) })
|
||||
val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() }
|
||||
|
||||
return coroutineScope {
|
||||
byAuthor
|
||||
.map { (author, link) ->
|
||||
async {
|
||||
runCatching {
|
||||
val token = link.token.hexToByteArray()
|
||||
// Classify per coordinate, never over the pooled set: one link's newer
|
||||
// revocation tombstone must not decide another link's status.
|
||||
val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false
|
||||
val moved =
|
||||
current.invite.copy(
|
||||
communityRoot = entry.root,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
controlPk = entry.controlPk,
|
||||
relays = entry.relays,
|
||||
)
|
||||
// Confirmed: a link counted as moved but never stored is a link its
|
||||
// holders can no longer redeem, reported as a success.
|
||||
account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
|
||||
}.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false)
|
||||
}
|
||||
}.awaitAll()
|
||||
.count { it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a shareable invite link for a joined community and publish its
|
||||
* kind-33301 public bundle to the community relays. Returns the `…/invite/…`
|
||||
@@ -171,6 +320,21 @@ class AccountConcordActions(
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return null
|
||||
// CREATE_INVITE, and not while banned. This used to check only that we held the community,
|
||||
// which made minting the one moderation-free action in the app: a member the owner had just
|
||||
// banned could tap the invite button and hand out a working link to the community they were
|
||||
// removed from, and every account they invited arrived as a fresh un-banned npub.
|
||||
//
|
||||
// Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control
|
||||
// entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published
|
||||
// OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is.
|
||||
// The owner is proven by the community id (CORD-02), so they are read off the entry and can
|
||||
// mint before the session exists — the session is built asynchronously off the joined list,
|
||||
// and requiring it here would have made the owner's own invite button fail on a cold start.
|
||||
// Everyone else needs the folded roster, so no session means no invite.
|
||||
val session = account.concordSessions.sessionFor(communityId)
|
||||
val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true)
|
||||
if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null
|
||||
val invite =
|
||||
ConcordActions.inviteFor(
|
||||
communityIdHex = entry.id,
|
||||
@@ -187,10 +351,103 @@ class AccountConcordActions(
|
||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
|
||||
|
||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||
// Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk`
|
||||
// was never stored can never be refreshed, so the next Refounding orphans it and everyone
|
||||
// holding it is stranded — with nothing to have warned them. Failing the mint is the honest
|
||||
// outcome; a stored entry for a link nobody received is harmless by comparison.
|
||||
if (!publishConcordInviteList(
|
||||
ConcordInviteListDocument(
|
||||
entries =
|
||||
listOf(
|
||||
ConcordInviteListEntry(
|
||||
token = minted.token.toHexKey(),
|
||||
signerSk = minted.linkSignerPrivKey.toHexKey(),
|
||||
communityId = entry.id,
|
||||
url = minted.url,
|
||||
createdAt = TimeUtils.now(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
) {
|
||||
Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" }
|
||||
return null
|
||||
}
|
||||
|
||||
if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo)
|
||||
return minted.url
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link this account minted for [communityId] that is still live, newest first — the
|
||||
* backing list for the invite-links screen.
|
||||
*
|
||||
* Null means the list could not be read (no relay answered, or the signer refused the decrypt),
|
||||
* which the UI must show as an error rather than as "you have no links": telling a creator their
|
||||
* leaked link doesn't exist is worse than telling them we couldn't check.
|
||||
*
|
||||
* Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList]
|
||||
* already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window
|
||||
* between our revoke and the next merge.
|
||||
*/
|
||||
suspend fun listConcordInviteLinks(communityId: String): List<ConcordInviteListEntry>? {
|
||||
val list = readConcordInviteList() ?: return null
|
||||
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
|
||||
return list.entries
|
||||
.filter { it.communityId == communityId && it.token !in tombstoned }
|
||||
.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then
|
||||
* records the retirement in the kind-13303 list. Returns false if the link could not be retired.
|
||||
*
|
||||
* No community permission is checked, deliberately. The coordinate is authored by the link
|
||||
* signer, whose secret only the creator holds, so revoking is an act on your own key rather than
|
||||
* on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer
|
||||
* retire the links they had already handed out, which is precisely when they most need to.
|
||||
*
|
||||
* The wire tombstone goes first and the list second. That is the inverse of minting and it is
|
||||
* deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a
|
||||
* tombstoned token's entry terminally, so recording first and then failing to publish would
|
||||
* leave the link live with its signer gone and no way left to retire it. A failed list write is
|
||||
* recoverable — the link is already dead on the wire, and the refresh path re-mints only a
|
||||
* coordinate that still resolves Live.
|
||||
*/
|
||||
suspend fun revokeConcordInvite(
|
||||
communityId: String,
|
||||
token: String,
|
||||
): Boolean {
|
||||
if (!account.isWriteable()) return false
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return false
|
||||
val link =
|
||||
readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId }
|
||||
?: run {
|
||||
Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" }
|
||||
return false
|
||||
}
|
||||
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||
if (relays.isEmpty()) return false
|
||||
// Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a
|
||||
// tombstone no relay stored — and the list write below would then drop this entry on merge,
|
||||
// destroying the only `signer_sk` that could ever retire the link while the link stays live.
|
||||
val published =
|
||||
runCatching {
|
||||
account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays)
|
||||
}.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false)
|
||||
if (!published) return false
|
||||
|
||||
if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) {
|
||||
// The link is already dead on the wire, so this is bookkeeping we can retry rather than a
|
||||
// failed revocation. Reported as success for exactly that reason.
|
||||
Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Drop a joined Concord community from the private kind-13302 list by its id. */
|
||||
suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId))
|
||||
|
||||
@@ -253,6 +510,42 @@ class AccountConcordActions(
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
}
|
||||
|
||||
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
|
||||
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
|
||||
// unlock token forever — so without this the rotation meant to expel them hands them the new
|
||||
// keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the
|
||||
// other door into the same room.
|
||||
//
|
||||
// Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields
|
||||
// the root, and no verdict means no join. Two things make that safe to insist on rather than
|
||||
// a way to brick valid invites:
|
||||
//
|
||||
// - the plane is fetched over the SAME relays that just served the bundle, not the relay
|
||||
// list inside the bundle alone, which can be stale (a moved relay, a link minted before a
|
||||
// relay change) and would otherwise refuse a community we can plainly reach;
|
||||
// - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing
|
||||
// older ban edition fails the gate open — it re-admits the very account it exists to
|
||||
// refuse — so the one direction we must not economise on is completeness.
|
||||
val joinKeys =
|
||||
ConcordActions.controlPlaneKeys(
|
||||
communityRoot = bundle.communityRoot.hexToByteArray(),
|
||||
communityId = bundle.communityId.hexToByteArray(),
|
||||
rootEpoch = bundle.rootEpoch,
|
||||
controlPk = bundle.controlPk,
|
||||
)
|
||||
// Union, not `ifEmpty`: the relays that served the bundle are known-good for this community,
|
||||
// and the bundle's own list is the one that goes stale.
|
||||
val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays
|
||||
val planeWraps = mutableListOf<Event>()
|
||||
account.client.fetchAllPagesFromPool(
|
||||
filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) },
|
||||
) { event, _ -> planeWraps.add(event) }
|
||||
val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys)
|
||||
if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable
|
||||
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) {
|
||||
return ConcordInviteResult.Banned
|
||||
}
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
id = bundle.communityId,
|
||||
@@ -430,7 +723,17 @@ class AccountConcordActions(
|
||||
channelIdHex: String,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return
|
||||
// A ban hides every message we send, so continuing to announce that we are typing them is
|
||||
// both noise and a contradiction of what the ban told the room. Filtered on the receive side
|
||||
// too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending.
|
||||
if (session.state.value
|
||||
?.authority
|
||||
?.isBanned(account.signer.pubKey) == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
val entry = session.entry
|
||||
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
@@ -487,6 +790,49 @@ class AccountConcordActions(
|
||||
return cp
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account may take the action guarded by [bit] in [session] — and, when [target] is
|
||||
* given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal").
|
||||
*
|
||||
* Every moderation verb below funnels through this. It used to live only in the composables that
|
||||
* drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which
|
||||
* ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere
|
||||
* else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` —
|
||||
* a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced.
|
||||
*
|
||||
* Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry]
|
||||
* rather than from the fold, because the community id proves them (CORD-02) and they must stay able
|
||||
* to moderate before their Control Plane has finished folding — or through a fold a rogue has
|
||||
* damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else
|
||||
* anything.
|
||||
*/
|
||||
private fun isAuthorizedFor(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): Boolean {
|
||||
val me = account.signer.pubKey
|
||||
if (session.entry.owner.equals(me, ignoreCase = true)) return true
|
||||
val authority = session.state.value?.authority ?: return false
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone and would let a
|
||||
// banned staffer keep acting for as long as they hold the key.
|
||||
val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit)
|
||||
if (!allowed) {
|
||||
Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" }
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
/** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */
|
||||
private fun controlKeysForAction(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): ControlPlaneKeys? {
|
||||
if (!isAuthorizedFor(session, bit, target)) return null
|
||||
return controlKeysForWrite(session)
|
||||
}
|
||||
|
||||
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
|
||||
suspend fun grantConcordRole(
|
||||
communityId: String,
|
||||
@@ -495,7 +841,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
// A Grant that first makes its member staff must deliver the control_root in the same
|
||||
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
|
||||
// roles carry a Control-writing bit and we hold the secret to hand over.
|
||||
@@ -567,7 +913,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
|
||||
val existing =
|
||||
session.state.value
|
||||
@@ -609,7 +955,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
@@ -657,7 +1003,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
@@ -670,7 +1016,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
@@ -701,10 +1047,22 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
val state = session.state.value ?: return false
|
||||
val authority = state.authority
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN)
|
||||
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
|
||||
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
|
||||
// shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same
|
||||
// ban-aware predicate), but that is a race against banlist propagation, not a check.
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN)
|
||||
if (!iCanBan) return false
|
||||
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
|
||||
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
|
||||
// Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin
|
||||
// cannot Refound a peer admin out of the community any more than they could ban one. The owner
|
||||
// short-circuits, as everywhere else, because canActOn starts at hasPermission.
|
||||
if (!authority.isOwner(account.signer.pubKey) &&
|
||||
removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) }
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// A Refounding writes the current plane (the pre-rotation bans) and the new one (the
|
||||
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
|
||||
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
|
||||
@@ -735,7 +1093,7 @@ class AccountConcordActions(
|
||||
.apply {
|
||||
removeAll(removedLower)
|
||||
removeAll(authority.bannedMembers())
|
||||
}.toList()
|
||||
}.let { candidates -> boundRecipients(candidates, authority) }
|
||||
|
||||
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
|
||||
val entry = session.entry
|
||||
@@ -762,6 +1120,7 @@ class AccountConcordActions(
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = staff,
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = entry.owner,
|
||||
)
|
||||
|
||||
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
|
||||
@@ -774,10 +1133,60 @@ class AccountConcordActions(
|
||||
|
||||
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and
|
||||
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
|
||||
adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
|
||||
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
|
||||
|
||||
// 6. Move every link we minted to the new epoch. Without this the Refounding orphans them,
|
||||
// and a member it left out — no rekey blob, no message to miss — has no way back at all.
|
||||
// Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so
|
||||
// reading it here would hand us the epoch we just left and re-mint every link onto it.
|
||||
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
|
||||
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps the Refounding recipient set, keeping the members whose standing we can actually vouch
|
||||
* for when there are too many.
|
||||
*
|
||||
* `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are
|
||||
* unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every
|
||||
* author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway
|
||||
* npub someone posts from, or simply announces, becomes one more mandatory blob in the next
|
||||
* Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only
|
||||
* hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`.
|
||||
*
|
||||
* The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be
|
||||
* padded from outside. The remainder fills the budget, and anything dropped is **logged rather
|
||||
* than silently truncated** — a dropped member is stranded on the dead epoch and their only way
|
||||
* back is a recovery path that needs to know it happened.
|
||||
*/
|
||||
private fun boundRecipients(
|
||||
candidates: Set<HexKey>,
|
||||
authority: AuthorityResolver,
|
||||
): List<HexKey> {
|
||||
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
|
||||
|
||||
// The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it
|
||||
// cannot be padded from outside, and dropping an admin to make room for a stranger inverts
|
||||
// the point of the cap.
|
||||
val vouched = authority.roleHolders() + authority.staffMembers()
|
||||
val kept = LinkedHashSet<HexKey>()
|
||||
candidates.filterTo(kept) { it in vouched }
|
||||
for (candidate in candidates) {
|
||||
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
|
||||
kept.add(candidate)
|
||||
}
|
||||
val dropped = candidates.size - kept.size
|
||||
if (dropped > 0) {
|
||||
Log.w("Concord") {
|
||||
"Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " +
|
||||
"(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " +
|
||||
"stranded on the prior epoch"
|
||||
}
|
||||
}
|
||||
return kept.toList()
|
||||
}
|
||||
|
||||
// Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered
|
||||
// in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not
|
||||
// adopted — and re-published — twice on successive revision ticks.
|
||||
@@ -796,39 +1205,16 @@ class AccountConcordActions(
|
||||
newEpoch: Long,
|
||||
newControlPk: ByteArray? = null,
|
||||
newControlRoot: ByteArray? = null,
|
||||
) {
|
||||
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
|
||||
// The epoch we're leaving is banked with the address it was folded at, so its Control
|
||||
// Plane stays subscribable for the anti-rollback floor (a split epoch's address can
|
||||
// never be re-derived, only remembered — CORD-02 §2).
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
|
||||
val next =
|
||||
ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
owner = entry.owner,
|
||||
ownerSalt = entry.ownerSalt,
|
||||
root = newRoot.toHexKey(),
|
||||
rootEpoch = newEpoch,
|
||||
// A rotation that delivered no control material is a legacy, pre-split one
|
||||
// (CORD-06 §3): the new epoch keeps folding at the legacy address, and the
|
||||
// stale prior-epoch values must NOT be carried into it.
|
||||
controlPk = newControlPk?.toHexKey(),
|
||||
controlRoot = newControlRoot?.toHexKey(),
|
||||
heldRoots = held,
|
||||
privateChannels = entry.privateChannels,
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
addedAt = entry.addedAt,
|
||||
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
|
||||
// out of would be unrecoverable.
|
||||
inviteRef = entry.inviteRef,
|
||||
excludedAtEpoch = entry.excludedAtEpoch,
|
||||
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
|
||||
// must survive our rotation write, or we delete their data on every rekey.
|
||||
residue = entry.residue,
|
||||
)
|
||||
): ConcordCommunityListEntry? {
|
||||
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null
|
||||
// The rewrite itself — banking the leaving epoch's address for the anti-rollback floor,
|
||||
// dropping stale control material on a legacy rotation, preserving invite_ref and residue —
|
||||
// is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and
|
||||
// the Guestbook re-announce below are Android's.
|
||||
val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot)
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next))
|
||||
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -874,7 +1260,17 @@ class AccountConcordActions(
|
||||
// who has themselves been banned could still rotate the whole community.
|
||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
||||
if (!authorized) continue
|
||||
adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
|
||||
// Move our own links onto the epoch we just adopted. Rotating is not the only way to end
|
||||
// up on a new epoch — being re-keyed is the common one — and a link creator who is merely
|
||||
// re-keyed would otherwise leave every link they handed out pointing at the dead root,
|
||||
// which is exactly the orphaning this branch exists to stop. Stranded recovery reads the
|
||||
// bundle's epoch, so a link nobody re-mints is a member nobody can recover.
|
||||
adopted?.let { next ->
|
||||
val moved = refreshConcordInviteLinks(next)
|
||||
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,39 +1292,16 @@ class AccountConcordActions(
|
||||
*/
|
||||
internal suspend fun drainConcordStaffGrants() {
|
||||
if (!account.isWriteable()) return
|
||||
val me = account.signer.pubKey.lowercase()
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val entry = session.entry
|
||||
// Already staff at this epoch, or a legacy community with no split to join.
|
||||
val heldControlPk = entry.controlPk
|
||||
if (entry.controlRoot != null || heldControlPk == null) continue
|
||||
val state = session.state.value ?: continue
|
||||
// Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing.
|
||||
if (!state.authority.isStaff(me)) continue
|
||||
|
||||
val myGrantCoordinate =
|
||||
ConcordKeyDerivation
|
||||
.grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray())
|
||||
.toHexKey()
|
||||
val delivered =
|
||||
session
|
||||
.controlEditions()
|
||||
.filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate }
|
||||
// Newest first: a re-issued Grant (a lost key, a head superseded before we
|
||||
// fetched it) carries the fresher wrap.
|
||||
.sortedByDescending { it.version }
|
||||
.firstNotNullOfOrNull { edition ->
|
||||
val wrap = ConcordJson.decodeOrNull<GrantEntity>(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null
|
||||
val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null
|
||||
if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null
|
||||
// Fails closed: a secret that doesn't derive to the pk we hold is dropped,
|
||||
// never adopted — we will not split ourselves off from the plane's readers.
|
||||
if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null
|
||||
opened.controlRoot
|
||||
} ?: continue
|
||||
// The whole decision — are we staff, does a Grant carry a wrap, does it open, name our
|
||||
// epoch, and derive to the control_pk we hold — is shared with `amy` in
|
||||
// [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's.
|
||||
val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue
|
||||
|
||||
account.sendMyPublicAndPrivateOutbox(
|
||||
account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())),
|
||||
account.concordChannelList.follow(entry.withControlRoot(delivered)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -986,7 +1359,29 @@ class AccountConcordActions(
|
||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
|
||||
// A removed member holds the link's unlock token forever, so without this the sweep
|
||||
// walks them straight back into the epoch they were rotated out of — see A2 in
|
||||
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
|
||||
// one whose Control Plane we can still fold.
|
||||
//
|
||||
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
|
||||
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
|
||||
// — so a banned member's own client would have hit that window on cold start and
|
||||
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
|
||||
// means no recovery; the next sweep retries once the roster is known.
|
||||
val authority =
|
||||
account.concordSessions
|
||||
.sessionFor(entry.id)
|
||||
?.state
|
||||
?.value
|
||||
?.authority
|
||||
if (authority == null) {
|
||||
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
|
||||
lastConcordRecoveryCheck.remove(entry.id)
|
||||
continue
|
||||
}
|
||||
val bannedHere = authority.isBanned(account.signer.pubKey)
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
|
||||
@@ -1009,7 +1404,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
|
||||
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
@@ -1027,7 +1422,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
@@ -1043,7 +1438,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Carry the standing definition forward and change only the name. A ChannelEntity built from
|
||||
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
|
||||
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
|
||||
@@ -1066,7 +1461,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Same as rename: preserve the standing flags so a tombstone does not also silently
|
||||
// reclassify the channel it retires.
|
||||
val standing =
|
||||
|
||||
@@ -54,6 +54,15 @@ sealed interface ConcordInviteResult {
|
||||
*/
|
||||
data object Expired : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The link opens, but this community's roster has banned us (CORD-04).
|
||||
*
|
||||
* A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the
|
||||
* URL and its unlock token forever — so honouring the link alone would hand the new keys to the
|
||||
* very account the rotation expelled.
|
||||
*/
|
||||
data object Banned : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The bundle event was found but could not be opened with the link's token —
|
||||
* typically because it was minted by a newer/incompatible Concord client whose
|
||||
|
||||
@@ -479,7 +479,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
@Volatile
|
||||
var lnurlEndpointResolver: LnurlEndpointResolver? = null
|
||||
|
||||
val relayHints = HintIndexer()
|
||||
override val relayHints = HintIndexer()
|
||||
|
||||
/**
|
||||
* Cashu mint URL directory, populated passively as
|
||||
@@ -679,7 +679,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
|
||||
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
|
||||
override fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
|
||||
|
||||
fun load(keys: List<String>): List<User> = keys.mapNotNull(::checkGetOrCreateUser)
|
||||
|
||||
|
||||
+23
-1
@@ -62,6 +62,28 @@ class IndexerRelayListState(
|
||||
|
||||
suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
/**
|
||||
* Same resolution as [normalizeIndexerRelayListWithBackup] but non-suspending, for use as the
|
||||
* [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it
|
||||
* never asks the signer, so it cannot block or hit a NIP-46 round trip.
|
||||
*
|
||||
* At login `indexerListNote.event` is usually still null and this resolves through
|
||||
* `settings.backupIndexRelayList`, restored from LocalPreferences — so an account with public
|
||||
* indexer relays gets its own relays immediately instead of the defaults.
|
||||
*/
|
||||
fun normalizeIndexerRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
|
||||
|
||||
/**
|
||||
* The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup]
|
||||
* substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the
|
||||
* one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read
|
||||
* this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff
|
||||
* what the user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same
|
||||
* reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an
|
||||
* `emptySet()` seed left a window where `.value` contradicted the contract above.
|
||||
*/
|
||||
val flow =
|
||||
getIndexerRelayListFlow()
|
||||
.map { normalizeIndexerRelayListWithBackup(it.note) }
|
||||
@@ -70,7 +92,7 @@ class IndexerRelayListState(
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
normalizeIndexerRelayListPrecached(indexerListNote),
|
||||
)
|
||||
|
||||
val flowNoDefaults =
|
||||
|
||||
+26
-1
@@ -62,6 +62,31 @@ class SearchRelayListState(
|
||||
|
||||
suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
/**
|
||||
* Same resolution as [normalizeSearchRelayListWithBackup] but non-suspending, for use as the
|
||||
* [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it
|
||||
* never asks the signer, so it cannot block or hit a NIP-46 round trip.
|
||||
*
|
||||
* At login `searchListNote.event` is usually still null and this resolves through
|
||||
* `settings.backupSearchRelayList`, restored from LocalPreferences — so an account with public
|
||||
* search relays gets its own relays immediately instead of the defaults. Accounts whose relays
|
||||
* are exclusively private fall back to [DefaultSearchRelayList] until the first decrypt lands.
|
||||
*/
|
||||
fun normalizeSearchRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
|
||||
|
||||
/**
|
||||
* The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup]
|
||||
* substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the
|
||||
* one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can
|
||||
* rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the
|
||||
* user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means
|
||||
* the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed
|
||||
* left a window where `.value` contradicted the "never empty" contract above and search
|
||||
* silently queried nothing. That window is unbounded for a NIP-46 signer whose list has
|
||||
* private entries, since the first emission waits on a remote decrypt.
|
||||
*/
|
||||
val flow =
|
||||
getSearchRelayListFlow()
|
||||
.map { normalizeSearchRelayListWithBackup(it.note) }
|
||||
@@ -70,7 +95,7 @@ class SearchRelayListState(
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
normalizeSearchRelayListPrecached(searchListNote),
|
||||
)
|
||||
|
||||
val flowNoDefaults =
|
||||
|
||||
+26
-1
@@ -57,7 +57,27 @@ class DataStoreNappletStorage(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
dataStore.edit { it[keyOf(coordinate, key)] = value }
|
||||
dataStore.edit { preferences ->
|
||||
val prefix = prefixOf(coordinate)
|
||||
val target = keyOf(coordinate, key)
|
||||
val currentBytes =
|
||||
preferences
|
||||
.asMap()
|
||||
.entries
|
||||
.asSequence()
|
||||
.filter { it.key.name.startsWith(prefix) }
|
||||
.sumOf { (storedKey, storedValue) ->
|
||||
storedKey.name
|
||||
.removePrefix(prefix)
|
||||
.encodeToByteArray()
|
||||
.size +
|
||||
((storedValue as? String)?.encodeToByteArray()?.size ?: 0)
|
||||
}
|
||||
val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0)
|
||||
val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size
|
||||
require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." }
|
||||
preferences[target] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(
|
||||
@@ -87,4 +107,9 @@ class DataStoreNappletStorage(
|
||||
coordinate: String,
|
||||
key: String,
|
||||
) = stringPreferencesKey(prefixOf(coordinate) + key)
|
||||
|
||||
companion object {
|
||||
/** NAP-STORAGE's recommended per-napplet UTF-8 quota. */
|
||||
const val MAX_STORAGE_BYTES = 512 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
|
||||
@@ -93,7 +95,11 @@ class NappletBrokerService : Service() {
|
||||
|
||||
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
|
||||
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions()
|
||||
private val liveSubscriptions = NappletLiveSubscriptions(scope)
|
||||
|
||||
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
|
||||
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
|
||||
private val resourceRequests = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
|
||||
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
|
||||
@@ -117,7 +123,7 @@ class NappletBrokerService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
liveSubscriptions.closeAll()
|
||||
identityWatch.stop()
|
||||
identityWatch.stopAll()
|
||||
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
|
||||
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
|
||||
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
|
||||
@@ -280,38 +286,57 @@ class NappletBrokerService : Service() {
|
||||
// Resolve the launch token to the trusted identity + declared set. The sandbox never states
|
||||
// its own coordinate, so a compromised :napplet process can only ever act as the napplet it
|
||||
// was launched as (it holds only its own token). An unknown token = no session; refuse.
|
||||
val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN))
|
||||
val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
|
||||
val session = NappletLaunchRegistry.resolve(launchToken)
|
||||
if (session == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
|
||||
return true
|
||||
}
|
||||
val identity = session.identity
|
||||
val declared = session.declared
|
||||
val resourceRequestKey = "$launchToken\u0000$requestId"
|
||||
if (requestType == "resource.cancel") {
|
||||
resourceRequests.remove(resourceRequestKey)?.cancel()
|
||||
return true
|
||||
}
|
||||
val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany"
|
||||
|
||||
scope.launch {
|
||||
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
|
||||
// decision (it stays wire-identical with the future desktop host). This service only supplies
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
|
||||
val requestJob =
|
||||
scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) {
|
||||
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
|
||||
// decision (it stays wire-identical with the future desktop host). This service only supplies
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> {
|
||||
reply(replyTo, requestId, outcome.payload)
|
||||
// NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup
|
||||
// snapshot succeeds, the runtime owns identity.changed delivery for this
|
||||
// trusted launch token until the broker service closes.
|
||||
if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) {
|
||||
identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) }
|
||||
}
|
||||
}
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
|
||||
}
|
||||
}
|
||||
if (tracksResourceRequest) {
|
||||
resourceRequests.put(resourceRequestKey, requestJob)?.cancel()
|
||||
requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) }
|
||||
requestJob.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
@StringRes
|
||||
fun NappletCapability.labelRes(): Int =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> R.string.napplet_cap_shell
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay
|
||||
@@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int =
|
||||
@StringRes
|
||||
fun NappletCapability.descriptionRes(): Int =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> R.string.napplet_cap_shell_desc
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
|
||||
|
||||
@@ -294,9 +294,10 @@ class NappletConsentSummary(
|
||||
context.getString(R.string.napplet_consent_pay_no_amount)
|
||||
}
|
||||
}
|
||||
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
|
||||
NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
|
||||
context.getString(R.string.napplet_consent_resource)
|
||||
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
|
||||
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
|
||||
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
|
||||
is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It
|
||||
@@ -34,31 +35,35 @@ import kotlinx.coroutines.launch
|
||||
* value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key
|
||||
* (or `""` when no account is signed in) to the caller-supplied sink.
|
||||
*
|
||||
* One watch at a time per host binding; [start] replaces any prior one. Reached only after the
|
||||
* router confirmed the applet declared the IDENTITY capability.
|
||||
* Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's
|
||||
* streams. A watch starts only after that surface successfully obtains its public-key snapshot.
|
||||
*/
|
||||
class NappletIdentityWatch(
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKey: (boundPubKey: String) -> Flow<String>,
|
||||
) {
|
||||
private var job: Job? = null
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun start(
|
||||
watchId: String,
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
stop()
|
||||
job =
|
||||
scope.launch {
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
}
|
||||
jobs.computeIfAbsent(watchId) { id ->
|
||||
scope
|
||||
.launch {
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
}.also { job ->
|
||||
job.invokeOnCompletion { jobs.remove(id, job) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
fun stopAll() {
|
||||
jobs.values.forEach { it.cancel() }
|
||||
jobs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ object NappletLaunchRegistry {
|
||||
accountPubKey: HexKey,
|
||||
): String {
|
||||
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
|
||||
sessions[token] = Session(identity, declared, accountPubKey)
|
||||
sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey)
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,20 @@ import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
|
||||
@@ -49,20 +53,18 @@ object NappletLauncher {
|
||||
manifest: NappletManifest,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
) = launch(
|
||||
context = context,
|
||||
paths = manifest.paths(),
|
||||
servers = manifest.servers(),
|
||||
authorPubKey = authorPubKey,
|
||||
identifier = identifier,
|
||||
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
|
||||
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
|
||||
requires = manifest.requires(),
|
||||
)
|
||||
) {
|
||||
val event = manifest as? Event
|
||||
if (event?.verify() != true || event.pubKey != authorPubKey) {
|
||||
Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" }
|
||||
return
|
||||
}
|
||||
buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite —
|
||||
* the broker then refuses every capability, so the site renders as inert static content.
|
||||
* Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified
|
||||
* manifest overload so raw callers cannot bypass signature/author validation.
|
||||
*/
|
||||
fun launch(
|
||||
context: Context,
|
||||
@@ -73,12 +75,26 @@ object NappletLauncher {
|
||||
aggregateHash: HexKey?,
|
||||
title: String,
|
||||
requires: List<String>,
|
||||
// nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The
|
||||
// broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless
|
||||
// of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET].
|
||||
profile: HostProfile = HostProfile.NAPPLET,
|
||||
// Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must
|
||||
// use the signature-checking manifest overload above.
|
||||
profile: HostProfile,
|
||||
) {
|
||||
if (profile != HostProfile.WEBSITE) {
|
||||
Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" }
|
||||
return
|
||||
}
|
||||
val params =
|
||||
runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) }
|
||||
.onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) }
|
||||
.getOrNull()
|
||||
?: return
|
||||
openHost(context, params)
|
||||
}
|
||||
|
||||
private fun openHost(
|
||||
context: Context,
|
||||
params: Bundle,
|
||||
) {
|
||||
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
|
||||
val intent =
|
||||
Intent(context, NappletHostActivity::class.java).apply {
|
||||
putExtras(params)
|
||||
@@ -105,6 +121,29 @@ object NappletLauncher {
|
||||
requires: List<String>,
|
||||
profile: HostProfile,
|
||||
): Bundle {
|
||||
require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." }
|
||||
return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
|
||||
}
|
||||
|
||||
private fun buildLaunchParamsTrusted(
|
||||
context: Context,
|
||||
paths: List<PathTag>,
|
||||
servers: List<String>,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
aggregateHash: HexKey?,
|
||||
title: String,
|
||||
requires: List<String>,
|
||||
profile: HostProfile,
|
||||
): Bundle {
|
||||
val effectiveAggregateHash =
|
||||
if (profile == HostProfile.NAPPLET) {
|
||||
requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) {
|
||||
"NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate."
|
||||
}
|
||||
} else {
|
||||
aggregateHash
|
||||
}
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
|
||||
// Augment the manifest's servers with the author's published Blossom list (kind:10063), if
|
||||
@@ -118,7 +157,7 @@ object NappletLauncher {
|
||||
|
||||
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's
|
||||
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
|
||||
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
|
||||
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash)
|
||||
val declared = profile.declaredCapabilities(requires)
|
||||
// Bound to the account launching it, so the surface keeps signing as that account even if the
|
||||
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
|
||||
@@ -156,7 +195,7 @@ object NappletLauncher {
|
||||
putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
|
||||
putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
|
||||
putString(NappletHostContract.EXTRA_IDENTIFIER, identifier)
|
||||
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
|
||||
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash)
|
||||
putString(NappletHostContract.EXTRA_TITLE, title)
|
||||
putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
|
||||
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
|
||||
@@ -170,4 +209,34 @@ object NappletLauncher {
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
|
||||
/** Signature-checking entry point for embedded NIP-5D surfaces. */
|
||||
fun buildLaunchParams(
|
||||
context: Context,
|
||||
manifest: NappletManifest,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
): Bundle? {
|
||||
val event = manifest as? Event
|
||||
if (event?.verify() != true || event.pubKey != authorPubKey) {
|
||||
Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" }
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
buildLaunchParamsTrusted(
|
||||
context = context,
|
||||
paths = manifest.paths(),
|
||||
servers = manifest.servers(),
|
||||
authorPubKey = authorPubKey,
|
||||
identifier = identifier,
|
||||
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
|
||||
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
|
||||
requires = manifest.requires(),
|
||||
profile = HostProfile.NAPPLET,
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private const val TAG = "NappletLauncher"
|
||||
}
|
||||
|
||||
+49
-5
@@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* signatures still came from the old one. [open] is reached only after the broker authorized the
|
||||
* subscription (RELAY consent).
|
||||
*/
|
||||
class NappletLiveSubscriptions {
|
||||
class NappletLiveSubscriptions(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
@@ -53,6 +59,20 @@ class NappletLiveSubscriptions {
|
||||
val client: INostrClient,
|
||||
) {
|
||||
val eoseSent = AtomicBoolean(false)
|
||||
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
|
||||
var deliveryJob: Job? = null
|
||||
}
|
||||
|
||||
private sealed interface Delivery {
|
||||
data class RelayEvent(
|
||||
val event: Event,
|
||||
) : Delivery
|
||||
|
||||
data object Eose : Delivery
|
||||
|
||||
data class Closed(
|
||||
val reason: String,
|
||||
) : Delivery
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +97,20 @@ class NappletLiveSubscriptions {
|
||||
// can't collide with the subscription it's replacing.
|
||||
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
|
||||
liveSubs[nappletSubId] = sub
|
||||
sub.deliveryJob =
|
||||
scope.launch {
|
||||
for (delivery in sub.deliveries) {
|
||||
if (liveSubs[nappletSubId] !== sub) break
|
||||
when (delivery) {
|
||||
is Delivery.RelayEvent ->
|
||||
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
|
||||
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
|
||||
}
|
||||
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
@@ -85,7 +119,9 @@ class NappletLiveSubscriptions {
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
|
||||
) {
|
||||
sub.deliveries.trySend(Delivery.RelayEvent(event))
|
||||
}
|
||||
|
||||
// A subscription fans out to several relays; collapse their EOSEs into the single
|
||||
// relay.eose the SDK expects (fired when the first relay finishes its stored events).
|
||||
@@ -93,14 +129,16 @@ class NappletLiveSubscriptions {
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message))
|
||||
) {
|
||||
sub.deliveries.trySend(Delivery.Closed(message))
|
||||
}
|
||||
}
|
||||
|
||||
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
|
||||
@@ -109,12 +147,18 @@ class NappletLiveSubscriptions {
|
||||
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
|
||||
fun close(nappletSubId: String) {
|
||||
val sub = liveSubs.remove(nappletSubId) ?: return
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
|
||||
/** Tears down every open subscription (service teardown). */
|
||||
fun closeAll() {
|
||||
liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } }
|
||||
liveSubs.values.forEach { sub ->
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
liveSubs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
|
||||
|
||||
/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */
|
||||
internal object NappletRelayCleartext {
|
||||
suspend fun forDelivery(
|
||||
event: Event,
|
||||
signer: NostrSigner,
|
||||
): Event? = forDelivery(event, signer.pubKey, signer::decrypt)
|
||||
|
||||
internal suspend fun forDelivery(
|
||||
event: Event,
|
||||
userPubKey: HexKey,
|
||||
decrypt: suspend (String, HexKey) -> String,
|
||||
): Event? {
|
||||
if (!isEncrypted(event)) return event
|
||||
|
||||
val peer =
|
||||
when {
|
||||
event.pubKey == userPubKey -> event.recipientPubKey()
|
||||
event.isAddressedTo(userPubKey) -> event.pubKey
|
||||
else -> null
|
||||
} ?: return null
|
||||
val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null
|
||||
|
||||
// NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and
|
||||
// signature fields so callers can still correlate it, while making clear that this object
|
||||
// must never be republished as a signed event after its content projection has changed.
|
||||
return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig)
|
||||
}
|
||||
|
||||
internal fun isEncrypted(event: Event): Boolean =
|
||||
event is PrivateDmEvent ||
|
||||
EncryptedInfo.isNIP04(event.content) ||
|
||||
isNip44V2(event.content)
|
||||
|
||||
private fun isNip44V2(content: String): Boolean =
|
||||
content.length >= MIN_NIP44_V2_LENGTH &&
|
||||
runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess
|
||||
|
||||
private fun Event.recipientPubKey(): HexKey? =
|
||||
tags.firstNotNullOfOrNull { tag ->
|
||||
tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" }
|
||||
}
|
||||
|
||||
private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey }
|
||||
|
||||
private const val MIN_NIP44_V2_LENGTH = 132
|
||||
}
|
||||
+3
-1
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentSummary
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNotificationStore
|
||||
import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext
|
||||
import com.vitorpamplona.amethyst.napplet.buildConnectInfo
|
||||
import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
@@ -265,7 +266,8 @@ class AccountNappletGateways(
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
val limit = filters.mapNotNull { it.limit }.maxOrNull()
|
||||
return limit?.let { merged.take(it) } ?: merged
|
||||
val limited = limit?.let { merged.take(it) } ?: merged
|
||||
return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+276
-59
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
|
||||
|
||||
import android.util.Base64
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -38,10 +39,27 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fetches a resource URL on an applet's behalf — the applet has no direct network
|
||||
@@ -63,41 +81,100 @@ class NappletResourceFetcher(
|
||||
private val account: Account,
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
) {
|
||||
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
|
||||
/** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource? =
|
||||
): NappletResourceResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
|
||||
when {
|
||||
url.startsWith("data:") -> decodeDataUrl(url)
|
||||
url.startsWith("https://") -> {
|
||||
runCatching {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (!r.isSuccessful) return@withContext null
|
||||
val body = r.body.bytes()
|
||||
val type = r.header("Content-Type") ?: "application/octet-stream"
|
||||
NappletResource(body, type)
|
||||
}
|
||||
}.getOrNull()
|
||||
url.startsWith("nostr:") ->
|
||||
resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.")
|
||||
url.startsWith("https://") || url.startsWith("blossom:") -> {
|
||||
// Route like the applet's own page: locked napplets stay on Tor. The derived
|
||||
// client removes ambient cookies/auth and validates DNS before every hop.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate)))
|
||||
if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client)
|
||||
}
|
||||
url.startsWith("blossom:") -> fetchBlossom(url, client)
|
||||
url.startsWith("nostr:") -> resolveNostr(url)
|
||||
else -> null
|
||||
else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.cache(null)
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.dns(
|
||||
Dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
}
|
||||
},
|
||||
).addNetworkInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.removeHeader("Authorization")
|
||||
.removeHeader("Cookie")
|
||||
.removeHeader("Proxy-Authorization")
|
||||
.build(),
|
||||
)
|
||||
}.build()
|
||||
|
||||
private suspend fun fetchHttps(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResourceResult {
|
||||
var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.")
|
||||
repeat(MAX_REDIRECTS + 1) { hop ->
|
||||
try {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(current)
|
||||
.get()
|
||||
.build(),
|
||||
).await()
|
||||
.use { response ->
|
||||
if (response.isRedirect) {
|
||||
if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.")
|
||||
current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.")
|
||||
return@repeat
|
||||
}
|
||||
if (response.code == 404) return failure(ERROR_NOT_FOUND)
|
||||
if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.")
|
||||
if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE)
|
||||
return classify(body)
|
||||
}
|
||||
} catch (e: BlockedResourceException) {
|
||||
return failure(ERROR_BLOCKED, e.message)
|
||||
} catch (_: InterruptedIOException) {
|
||||
return failure(ERROR_TIMEOUT)
|
||||
} catch (_: Exception) {
|
||||
return failure(ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
}
|
||||
|
||||
private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) }
|
||||
|
||||
private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() }
|
||||
|
||||
/**
|
||||
* Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed`
|
||||
* carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to
|
||||
@@ -155,65 +232,205 @@ class NappletResourceFetcher(
|
||||
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
|
||||
* serves it.
|
||||
*/
|
||||
private fun fetchBlossom(
|
||||
private suspend fun fetchBlossom(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResource? {
|
||||
val hash =
|
||||
url
|
||||
.removePrefix("blossom://")
|
||||
.removePrefix("blossom:")
|
||||
.substringBefore('/')
|
||||
.substringBefore('?')
|
||||
.trim()
|
||||
.lowercase()
|
||||
if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null
|
||||
): NappletResourceResult {
|
||||
if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase()
|
||||
if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
|
||||
val servers =
|
||||
account.blossomServers
|
||||
.getBlossomServersList()
|
||||
?.servers()
|
||||
.orEmpty()
|
||||
var sawHashMismatch = false
|
||||
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
|
||||
val bytes =
|
||||
runCatching {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(candidate)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (r.isSuccessful) r.body.bytes() else null
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
if (StaticSiteResolver.verify(bytes, hash)) {
|
||||
return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream")
|
||||
when (val fetched = fetchHttps(candidate, client)) {
|
||||
is NappletResourceResult.Success -> {
|
||||
if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) {
|
||||
sawHashMismatch = true
|
||||
continue
|
||||
}
|
||||
return fetched
|
||||
}
|
||||
is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched
|
||||
}
|
||||
}
|
||||
return null
|
||||
if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.")
|
||||
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
|
||||
}
|
||||
|
||||
private suspend fun Call.await(): Response =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
if (continuation.isActive) continuation.resumeWith(Result.failure(e))
|
||||
}
|
||||
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWith(Result.success(response))
|
||||
} else {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
|
||||
private fun decodeDataUrl(url: String): NappletResource? {
|
||||
private fun decodeDataUrl(url: String): NappletResourceResult {
|
||||
val comma = url.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.")
|
||||
val meta = url.substring("data:".length, comma)
|
||||
val data = url.substring(comma + 1)
|
||||
if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE)
|
||||
val isBase64 = meta.endsWith(";base64")
|
||||
val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" }
|
||||
val declaredType =
|
||||
meta
|
||||
.removeSuffix(";base64")
|
||||
.substringBefore(';')
|
||||
.ifEmpty { "text/plain" }
|
||||
.lowercase()
|
||||
val bytes =
|
||||
if (isBase64) {
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.")
|
||||
} else {
|
||||
URLDecoder.decode(data, "UTF-8").encodeToByteArray()
|
||||
runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.")
|
||||
}
|
||||
return NappletResource(bytes, contentType)
|
||||
if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
return classify(bytes, declaredType)
|
||||
}
|
||||
|
||||
private fun classify(
|
||||
bytes: ByteArray,
|
||||
declaredType: String? = null,
|
||||
): NappletResourceResult {
|
||||
if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.")
|
||||
val sniffed = sniffContentType(bytes)
|
||||
val type =
|
||||
when {
|
||||
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
|
||||
declaredType == "application/json" && isJson(bytes) -> "application/json"
|
||||
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
|
||||
else -> null
|
||||
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
|
||||
return success(NappletResource(bytes, type))
|
||||
}
|
||||
|
||||
private fun looksLikeSvg(bytes: ByteArray): Boolean {
|
||||
val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase()
|
||||
return prefix.contains("<svg")
|
||||
}
|
||||
|
||||
private fun isJson(bytes: ByteArray): Boolean = runCatching { Json.parseToJsonElement(bytes.decodeToString()) }.isSuccess
|
||||
|
||||
private fun isPlainText(bytes: ByteArray): Boolean =
|
||||
runCatching {
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
}.isSuccess && bytes.none { it == 0.toByte() }
|
||||
|
||||
private fun success(resource: NappletResource): NappletResourceResult = NappletResourceResult.Success(resource)
|
||||
|
||||
private fun failure(
|
||||
error: String,
|
||||
message: String? = null,
|
||||
): NappletResourceResult = NappletResourceResult.Failure(error, message)
|
||||
|
||||
private fun readBounded(input: java.io.InputStream): ByteArray? {
|
||||
input.use { source ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = source.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > MAX_RESOURCE_BYTES) return null
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true
|
||||
|
||||
internal fun isPublicAddress(address: InetAddress): Boolean {
|
||||
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) {
|
||||
return false
|
||||
}
|
||||
val bytes = address.address
|
||||
if (bytes.size == 4) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
val second = bytes[1].toInt() and 0xff
|
||||
// Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify.
|
||||
if (first == 0 || first >= 224) return false
|
||||
if (first == 100 && second in 64..127) return false
|
||||
if (first == 192 && second == 0) return false
|
||||
if (first == 198 && second in 18..19) return false
|
||||
if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false
|
||||
if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false
|
||||
} else if (bytes.size == 16) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local
|
||||
if (
|
||||
first == 0x20 &&
|
||||
(bytes[1].toInt() and 0xff) == 0x01 &&
|
||||
(bytes[2].toInt() and 0xff) == 0x0d &&
|
||||
(bytes[3].toInt() and 0xff) == 0xb8
|
||||
) {
|
||||
return false // 2001:db8::/32 documentation range
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L
|
||||
private const val FETCH_TIMEOUT_SECONDS = 30L
|
||||
private const val MAX_REDIRECTS = 5
|
||||
private const val MIME_PREFIX_BYTES = 8 * 1024
|
||||
private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024
|
||||
private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:"
|
||||
const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024
|
||||
private const val ERROR_INVALID_REQUEST = "invalid-request"
|
||||
private const val ERROR_NOT_FOUND = "not-found"
|
||||
private const val ERROR_BLOCKED = "blocked-by-policy"
|
||||
private const val ERROR_TIMEOUT = "timeout"
|
||||
private const val ERROR_TOO_LARGE = "too-large"
|
||||
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
|
||||
private const val ERROR_DECODE_FAILED = "decode-failed"
|
||||
private const val ERROR_NETWORK = "network-error"
|
||||
private val SHA256 = Regex("^[0-9a-f]{64}$")
|
||||
private val ALLOWED_SNIFFED_TYPES =
|
||||
setOf(
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"audio/ogg",
|
||||
"video/mp4",
|
||||
)
|
||||
}
|
||||
|
||||
private class BlockedResourceException(
|
||||
message: String,
|
||||
) : java.io.IOException(message)
|
||||
}
|
||||
|
||||
+10
@@ -373,6 +373,16 @@ class NotificationRelayService : Service() {
|
||||
if (fresh.isNotEmpty()) lastBreakdown = fresh
|
||||
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it
|
||||
// sits in the shade's Silent section next to the low-importance content kinds
|
||||
// (reactions, reposts) — and Android 16 sweeps everything ungrouped in a section into
|
||||
// one aggregate bundle whose summary inherits FLAG_ONGOING_EVENT from any child that
|
||||
// has it, making the whole bundle un-swipeable. Giving this one a group of its own
|
||||
// would not help: a group with a summary but no children, or a child with no summary,
|
||||
// is force-grouped just the same. What keeps content notifications out of that bundle
|
||||
// is that they always post their own group summary (see NotificationUtils), which
|
||||
// leaves this the only ungrouped silent notification we post — one is below the
|
||||
// threshold, so no bundle is formed and nothing gets stapled to it.
|
||||
return NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.always_on_notif_title))
|
||||
|
||||
+31
-5
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelAndPrune
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelChildlessGroupSummaries
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
@@ -54,13 +56,35 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
intent: Intent,
|
||||
) {
|
||||
val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0)
|
||||
|
||||
// Whatever the action, the user is done with this notification, so record it before
|
||||
// doing anything else. An enrichment window may still be open on the event (up to
|
||||
// 25s from the first post), and it re-posts the notification every time metadata
|
||||
// lands — without this the notification the user just dealt with comes back, and the
|
||||
// enricher keeps a relay subscription and a wakelock alive for it until the window
|
||||
// elapses. Replies mark it after the send succeeds instead, so a failure leaves the
|
||||
// notification to enrich and retry.
|
||||
val eventId = intent.getStringExtra(NotificationUtils.KEY_EVENT_ID)
|
||||
if (intent.action != NotificationUtils.REPLY_ACTION &&
|
||||
intent.action != NotificationUtils.PUBLIC_REPLY_ACTION &&
|
||||
intent.action != NotificationUtils.MARMOT_REPLY_ACTION
|
||||
) {
|
||||
eventId?.let { NotificationUtils.markDismissed(it) }
|
||||
}
|
||||
|
||||
val notificationManager =
|
||||
ContextCompat.getSystemService(context, NotificationManager::class.java)
|
||||
as NotificationManager
|
||||
|
||||
when (intent.action) {
|
||||
NotificationUtils.MARK_READ_ACTION -> {
|
||||
notificationManager.cancel(notificationId)
|
||||
notificationManager.cancelAndPrune(notificationId)
|
||||
}
|
||||
|
||||
// The user swiped the notification away. It is already gone; all that is left
|
||||
// is to take its group summary with it when it was the last child.
|
||||
NotificationUtils.DISMISS_ACTION -> {
|
||||
notificationManager.cancelChildlessGroupSummaries(alreadyGone = notificationId)
|
||||
}
|
||||
|
||||
NotificationUtils.REPLY_ACTION -> {
|
||||
@@ -78,7 +102,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
if (members.isEmpty()) return
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
sendReply(accountNpub, members, replyText)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +119,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
sendPublicReply(accountNpub, targetEventId, replyText)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +138,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
|
||||
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
|
||||
}
|
||||
}
|
||||
@@ -124,6 +148,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
private fun runOnRelay(
|
||||
notificationManager: NotificationManager,
|
||||
notificationId: Int,
|
||||
eventId: String?,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
val pendingResult = goAsync()
|
||||
@@ -138,7 +163,8 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
try {
|
||||
block()
|
||||
notificationManager.cancel(notificationId)
|
||||
eventId?.let { NotificationUtils.markDismissed(it) }
|
||||
notificationManager.cancelAndPrune(notificationId)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
|
||||
|
||||
+145
-23
@@ -70,8 +70,16 @@ object NotificationUtils {
|
||||
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
|
||||
const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION"
|
||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||
const val DISMISS_ACTION = "com.vitorpamplona.amethyst.DISMISS_ACTION"
|
||||
const val KEY_REPLY_TEXT = "key_reply_text"
|
||||
const val KEY_NOTIFICATION_ID = "key_notification_id"
|
||||
|
||||
/**
|
||||
* Hex id of the event this notification was posted for, carried on every action
|
||||
* and on the delete intent so the receiver can mark it dismissed. Distinct from
|
||||
* [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to.
|
||||
*/
|
||||
const val KEY_EVENT_ID = "key_event_id"
|
||||
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
||||
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
||||
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
|
||||
@@ -82,16 +90,27 @@ object NotificationUtils {
|
||||
const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION"
|
||||
private const val REPLY_SUMMARY_ID_BASE = 0x50000
|
||||
|
||||
// Event ids the user has just read/dismissed in-app. The enrichment path
|
||||
// re-posts a notification as metadata arrives; without this guard a
|
||||
// notification the user already dismissed would be resurrected seconds later
|
||||
// when its author's kind:0 lands. Keyed by the event id string (not the
|
||||
// hashCode) so distinct events can't collide. Entries self-expire after a
|
||||
// window comfortably longer than the 25s enrichment window.
|
||||
/**
|
||||
* Every group key this object posts under starts with this. Used to tell our own
|
||||
* summaries apart from the ones the system creates when it force-groups us (those
|
||||
* live under `userId|pkg|g:Aggregate_…`), so the cleanup below never fights the
|
||||
* platform over a bundle it owns.
|
||||
*/
|
||||
private const val OWN_GROUP_PREFIX = "com.vitorpamplona.amethyst."
|
||||
|
||||
// Event ids the user is done with. The enrichment path re-posts a notification
|
||||
// as metadata arrives; without this guard a notification the user already got
|
||||
// rid of would be resurrected seconds later when its author's kind:0 lands, and
|
||||
// the enricher would go on holding a relay window and a wakelock open for it.
|
||||
// Every way a user can be done with a notification has to record here — reading
|
||||
// the event in-app, swiping the notification away, "mark as read", and replying
|
||||
// inline — or that path leaks the resurrection. Keyed by the event id string
|
||||
// (not the hashCode) so distinct events can't collide. Entries self-expire after
|
||||
// a window comfortably longer than the 25s enrichment window.
|
||||
private const val DISMISS_GUARD_MS = 90_000L
|
||||
private val recentlyDismissed = ConcurrentHashMap<String, Long>()
|
||||
|
||||
private fun markDismissed(eventId: String) {
|
||||
fun markDismissed(eventId: String) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
recentlyDismissed[eventId] = now + DISMISS_GUARD_MS
|
||||
if (recentlyDismissed.size > 256) {
|
||||
@@ -218,6 +237,7 @@ object NotificationUtils {
|
||||
.setPriority(category.priority())
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setGroup(groupKey)
|
||||
.setDeleteIntent(dismissIntent(applicationContext, notId, id))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setWhen(time * 1000)
|
||||
@@ -236,11 +256,11 @@ object NotificationUtils {
|
||||
}
|
||||
|
||||
if (inlineReply != null) {
|
||||
builder.addAction(publicReplyAction(applicationContext, notId, inlineReply))
|
||||
builder.addAction(publicReplyAction(applicationContext, notId, id, inlineReply))
|
||||
}
|
||||
|
||||
notify(notId, builder.build())
|
||||
sendGroupSummary(category, groupKey, summaryId, applicationContext)
|
||||
sendGroupSummary(category, groupKey, summaryId, time, applicationContext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -335,20 +355,21 @@ object NotificationUtils {
|
||||
.setPriority(category.priority())
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setGroup(groupKey)
|
||||
.setDeleteIntent(dismissIntent(applicationContext, notId, id))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
when (replyAction) {
|
||||
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction))
|
||||
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction))
|
||||
null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) }
|
||||
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction))
|
||||
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction))
|
||||
null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, id, it)) }
|
||||
}
|
||||
|
||||
if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId))
|
||||
if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId, id))
|
||||
|
||||
notify(notId, builder.build())
|
||||
sendGroupSummary(category, groupKey, summaryId, applicationContext)
|
||||
sendGroupSummary(category, groupKey, summaryId, time, applicationContext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -370,6 +391,38 @@ object NotificationUtils {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when the user swipes this notification away (or hits "Clear all"), so the
|
||||
* group summary can follow its last child out.
|
||||
*
|
||||
* We can't rely on the shade to take the summary with it: SystemUI hides a group
|
||||
* with a single child and renders that child at the top level
|
||||
* (`ShadeListBuilder.MIN_CHILDREN_FOR_GROUP`), and once promoted the child no
|
||||
* longer counts as "the only child in its group", so dismissing it leaves our
|
||||
* summary behind. A childless summary is not harmless — SystemUI promotes it into
|
||||
* the shade on its own, and the system force-groups it
|
||||
* (`GroupHelper.isGroupSummaryWithoutChildren`) into the same aggregate bundle we
|
||||
* post summaries to stay out of.
|
||||
*/
|
||||
private fun dismissIntent(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
): PendingIntent {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = DISMISS_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
notId + 2,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
private fun replyRemoteInput(applicationContext: Context): RemoteInput =
|
||||
RemoteInput
|
||||
.Builder(KEY_REPLY_TEXT)
|
||||
@@ -399,12 +452,14 @@ object NotificationUtils {
|
||||
private fun dmReplyAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
action: ReplyAction.Dm,
|
||||
): NotificationCompat.Action {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
this.action = REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, action.accountNpub)
|
||||
putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers)
|
||||
}
|
||||
@@ -414,12 +469,14 @@ object NotificationUtils {
|
||||
private fun marmotReplyAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
action: ReplyAction.Marmot,
|
||||
): NotificationCompat.Action {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
this.action = MARMOT_REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, action.accountNpub)
|
||||
putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId)
|
||||
action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) }
|
||||
@@ -431,12 +488,14 @@ object NotificationUtils {
|
||||
private fun publicReplyAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
target: InlineReplyTarget,
|
||||
): NotificationCompat.Action {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = PUBLIC_REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, target.accountNpub)
|
||||
putExtra(KEY_TARGET_EVENT_ID, target.targetEventId)
|
||||
}
|
||||
@@ -446,11 +505,13 @@ object NotificationUtils {
|
||||
private fun markReadAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
): NotificationCompat.Action {
|
||||
val markReadIntent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = MARK_READ_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
}
|
||||
val markReadPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
@@ -549,16 +610,33 @@ object NotificationUtils {
|
||||
// Group summaries, dedup, dismissal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Posts (or refreshes) our own summary for [groupKey].
|
||||
*
|
||||
* The summary goes up with the **first** child, not once a second one shows up.
|
||||
* Android 16 counts a group child whose summary is missing as ungrouped
|
||||
* (`GroupHelper.isGroupChildWithoutSummary`) and force-groups it into the
|
||||
* package's per-section aggregate bundle, next to every other ungrouped
|
||||
* notification in the same shade section. The bar is low: `config_autoGroupAtCount`
|
||||
* is 2, so a single summary-less child plus one other ungrouped notification is a
|
||||
* bundle. The always-on relay service is exactly that other notification — ongoing
|
||||
* and IMPORTANCE_LOW, it shares the Silent section with our two IMPORTANCE_LOW
|
||||
* kinds (reactions and reposts), so one lone repost would end up bundled with it.
|
||||
* The bundle then refuses to swipe away, because the system's aggregate summary
|
||||
* inherits FLAG_ONGOING_EVENT from any child carrying it — and the service
|
||||
* notification always does.
|
||||
*
|
||||
* Providing the summary from the start keeps the group ours and the system leaves
|
||||
* it alone. It costs nothing visually: the shade hides any group with fewer than
|
||||
* two children and shows the child on its own.
|
||||
*/
|
||||
private fun NotificationManager.sendGroupSummary(
|
||||
category: NotificationCategory,
|
||||
groupKey: String,
|
||||
summaryId: Int,
|
||||
time: Long,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId }
|
||||
|
||||
if (activeCount < 2) return
|
||||
|
||||
val summaryBuilder =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, category.channelId(applicationContext))
|
||||
@@ -566,8 +644,16 @@ object NotificationUtils {
|
||||
.setColor(category.color)
|
||||
.setGroup(groupKey)
|
||||
.setGroupSummary(true)
|
||||
// The children do the alerting. Without this the summary would buzz on
|
||||
// its own the moment it starts going up alongside the first child.
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
// Pinned to the child's event time rather than left to default to "now".
|
||||
// The summary is re-posted on every one of the enrichment path's re-renders,
|
||||
// and a fresh timestamp each time would keep re-sorting the group in the
|
||||
// shade while the user is looking at it.
|
||||
.setWhen(time * 1000)
|
||||
.setStyle(
|
||||
NotificationCompat
|
||||
.InboxStyle()
|
||||
@@ -604,17 +690,53 @@ object NotificationUtils {
|
||||
// items), so bail out before touching anything when nothing is posted for it.
|
||||
if (activeNotifications.none { it.id == notId }) return
|
||||
|
||||
cancel(notId)
|
||||
cancelChildlessGroupSummaries()
|
||||
cancelAndPrune(notId)
|
||||
}
|
||||
|
||||
private fun NotificationManager.cancelChildlessGroupSummaries() {
|
||||
/**
|
||||
* Cancels [notId] and drops the group summary it leaves behind, if it was the last
|
||||
* child. Use this instead of a bare [NotificationManager.cancel] for anything we
|
||||
* posted through [postStandard] / [postConversation] — every one of those is a
|
||||
* group child with a summary above it.
|
||||
*/
|
||||
fun NotificationManager.cancelAndPrune(notId: Int) {
|
||||
cancel(notId)
|
||||
cancelChildlessGroupSummaries(alreadyGone = notId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops our summaries that no longer have any children.
|
||||
*
|
||||
* [alreadyGone] is the id of a notification cancelled moments ago: both
|
||||
* [NotificationManager.cancel] and [NotificationManager.notify] are asynchronous,
|
||||
* so [NotificationManager.activeNotifications] can still be listing it and would
|
||||
* otherwise keep its summary alive forever.
|
||||
*
|
||||
* Only summaries under [OWN_GROUP_PREFIX] are touched. The system's own aggregate
|
||||
* summaries also carry FLAG_GROUP_SUMMARY and show up in this list; cancelling one
|
||||
* only makes the platform rebuild it.
|
||||
*/
|
||||
fun NotificationManager.cancelChildlessGroupSummaries(alreadyGone: Int? = null) {
|
||||
val active: Array<StatusBarNotification> = activeNotifications
|
||||
|
||||
// Collect the groups that still have a child in one pass, then cancel the
|
||||
// summaries not in that set. Every child now ships with a summary, so this list
|
||||
// is about twice as long as it used to be and the pairwise scan it replaces grew
|
||||
// four-fold. Membership is decided by the summary flag rather than by comparing
|
||||
// ids, which is also what makes it correct when a child's id happens to equal the
|
||||
// summary's.
|
||||
val groupsWithChildren = HashSet<String>(active.size)
|
||||
for (child in active) {
|
||||
if (child.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) continue
|
||||
if (child.id == alreadyGone) continue
|
||||
child.notification.group?.let { groupsWithChildren.add(it) }
|
||||
}
|
||||
|
||||
for (summary in active) {
|
||||
if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue
|
||||
val group = summary.notification.group ?: continue
|
||||
val hasChildren = active.any { it.id != summary.id && it.notification.group == group }
|
||||
if (!hasChildren) cancel(summary.id)
|
||||
if (!group.startsWith(OWN_GROUP_PREFIX)) continue
|
||||
if (group !in groupsWithChildren) cancel(summary.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
/**
|
||||
* Amethyst variant of [SingleSubNoEoseCacheEoseManager] that restores single-account
|
||||
* attribution for [AccountScopedQuery] keys.
|
||||
*
|
||||
* The commons base is account-agnostic (attribution defaults to null) so it can live in
|
||||
* commonMain. Query states that carry an [Account] (home feed, channels, notifications, …)
|
||||
* subclass this so their single-account REQs still show up attributed in "Active Relay
|
||||
* Subscriptions".
|
||||
*
|
||||
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
|
||||
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
|
||||
* other under "not attributed" despite both being built from a single account's data.
|
||||
*/
|
||||
abstract class AccountScopedSingleSubNoEoseCacheEoseManager<T>(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<T>,
|
||||
invalidateAfterEose: Boolean = false,
|
||||
) : SingleSubNoEoseCacheEoseManager<T>(client, allKeys, invalidateAfterEose) {
|
||||
override fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
|
||||
}
|
||||
+2
-119
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
fun pickRelaysToLoadUsers(
|
||||
users: Set<User>,
|
||||
@@ -68,6 +68,7 @@ fun pickRelaysToLoadUsers(
|
||||
|
||||
return pickRelaysToLoadUsers(
|
||||
users,
|
||||
LocalCache.relayHints,
|
||||
indexRelays - cannotConnectRelays,
|
||||
homeRelays - cannotConnectRelays,
|
||||
searchRelays - cannotConnectRelays,
|
||||
@@ -77,121 +78,3 @@ fun pickRelaysToLoadUsers(
|
||||
hasTried,
|
||||
)
|
||||
}
|
||||
|
||||
fun pickRelaysToLoadUsers(
|
||||
users: Set<User>,
|
||||
indexRelays: Set<NormalizedRelayUrl>,
|
||||
homeRelays: Set<NormalizedRelayUrl>,
|
||||
searchRelays: Set<NormalizedRelayUrl>,
|
||||
connected: Set<NormalizedRelayUrl>,
|
||||
commonRelays: Set<NormalizedRelayUrl>,
|
||||
cannotConnectRelays: Set<NormalizedRelayUrl>,
|
||||
hasTried: EOSEAccountFast<User>,
|
||||
): Map<NormalizedRelayUrl, Set<HexKey>> =
|
||||
mapOfSet {
|
||||
users.forEachIndexed { _, key ->
|
||||
val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays
|
||||
|
||||
val outbox = key.authorRelayList()?.writeRelaysNorm()
|
||||
|
||||
if (!outbox.isNullOrEmpty()) {
|
||||
// If there is a home, get from it.
|
||||
|
||||
// if it tried all outbox relays, stop.
|
||||
// the UserWatch will take over from here.
|
||||
val leftToTry = (outbox - tried)
|
||||
leftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
// if not, tries hints first.
|
||||
val hints = key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex)
|
||||
|
||||
val leftToTryOnHints = hints - tried
|
||||
|
||||
leftToTryOnHints.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
// if there are only a few hints, broadens the search
|
||||
if (leftToTryOnHints.size < 3) {
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val indexRelaysLeftToTry =
|
||||
(indexRelays - tried).sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val homeRelaysLeftToTry =
|
||||
(homeRelays - tried).sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}
|
||||
|
||||
// picks one at random to avoid overloading these relays
|
||||
if (users.size > 300) {
|
||||
if (indexRelaysLeftToTry.size >= 2) {
|
||||
add(indexRelaysLeftToTry[0], key.pubkeyHex)
|
||||
add(indexRelaysLeftToTry[1], key.pubkeyHex)
|
||||
} else if (indexRelaysLeftToTry.size == 1) {
|
||||
add(indexRelaysLeftToTry.first(), key.pubkeyHex)
|
||||
}
|
||||
|
||||
homeRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
indexRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
homeRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
if (indexRelaysLeftToTry.size < 2) {
|
||||
val searchRelaysLeftToTry = searchRelays - tried
|
||||
|
||||
searchRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
val connectedRelaysLeftToTry =
|
||||
(connected - tried)
|
||||
.sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}.take(100)
|
||||
|
||||
// picks one at random to avoid overloading these relays
|
||||
if (users.size > 300) {
|
||||
connectedRelaysLeftToTry.take(20).forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
connectedRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
if (searchRelaysLeftToTry.size < 2) {
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val allRelaysLeftToTry =
|
||||
(commonRelays - tried)
|
||||
.sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}.take(100)
|
||||
|
||||
allRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.AccountScopedSingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
class ChannelLoaderSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ChannelFinderQueryState>,
|
||||
) : SingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
) : AccountScopedSingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
override fun updateFilter(keys: List<ChannelFinderQueryState>): List<RelayBasedFilter> = filterMissingChannelsById(keys)
|
||||
|
||||
override fun distinct(key: ChannelFinderQueryState) = key.channel
|
||||
|
||||
+20
-20
@@ -21,30 +21,30 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Back-compat aliases: the per-note event finder moved to commons
|
||||
* (`com.vitorpamplona.amethyst.commons.relayClient.event`). Existing Android call
|
||||
* sites that reference these by their old names resolve here.
|
||||
*/
|
||||
typealias EventFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler
|
||||
|
||||
typealias EventFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
|
||||
|
||||
/**
|
||||
* Android convenience overload: pulls the account + shared event-finder data source
|
||||
* out of [accountViewModel] and delegates to the commons subscription. `Account`
|
||||
* is-a `UserFinderAccount`, so no adaptation is needed.
|
||||
*/
|
||||
@Composable
|
||||
fun EventFinderFilterAssemblerSubscription(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) = EventFinderFilterAssemblerSubscription(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
|
||||
|
||||
@Composable
|
||||
fun EventFinderFilterAssemblerSubscription(
|
||||
note: Note,
|
||||
account: Account,
|
||||
dataSource: EventFinderFilterAssembler,
|
||||
) {
|
||||
// different screens get different states
|
||||
// even if they are tracking the same tag.
|
||||
val state =
|
||||
remember(note, account) {
|
||||
EventFinderQueryState(note, account)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
) = EventFinderFilterAssemblerSubscription(
|
||||
note,
|
||||
accountViewModel.account,
|
||||
accountViewModel.dataSources().eventFinder,
|
||||
)
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.service.relayClient.reqCommand.user
|
||||
|
||||
/**
|
||||
* Back-compat aliases: the per-user metadata finder moved to commons
|
||||
* (`com.vitorpamplona.amethyst.commons.relayClient.user`). Existing Android call
|
||||
* sites that reference these by their old names resolve here.
|
||||
*/
|
||||
typealias UserFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
|
||||
|
||||
typealias UserFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
+3
-3
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
@@ -37,7 +37,7 @@ fun filterByAddress(
|
||||
val list =
|
||||
mapOfSet {
|
||||
if (note.event == null) {
|
||||
potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl ->
|
||||
potentialRelaysToFindAddress(LocalCache, note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, note.address)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -38,16 +38,16 @@ fun filterByEvent(
|
||||
val list =
|
||||
mapOfSet {
|
||||
if (note !is AddressableNote && note.event == null) {
|
||||
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
|
||||
potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, note.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
// loads threading that is event-based
|
||||
note.replyTo?.forEach { parentNote ->
|
||||
if (parentNote !is AddressableNote && note.event == null) {
|
||||
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, note.idHex)
|
||||
if (parentNote !is AddressableNote && parentNote.event == null) {
|
||||
potentialRelaysToFindEvent(LocalCache, parentNote).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, parentNote.idHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
typealias EOSERelayList = com.vitorpamplona.amethyst.commons.relays.EOSERelayList
|
||||
typealias SincePerRelayMap = com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime
|
||||
typealias EOSEAccountFast<T> = com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast<T>
|
||||
|
||||
open class EOSEByKey<U : Any>(
|
||||
cacheSize: Int = 200,
|
||||
@@ -113,60 +114,3 @@ open class EOSEAccountKey<U : Any>(
|
||||
time: Long,
|
||||
) = addOrUpdate(user, listCode, relayUrl, time)
|
||||
}
|
||||
|
||||
class EOSEAccountFast<T : Any>(
|
||||
cacheSize: Int = 20,
|
||||
) {
|
||||
private val users: LruCache<T, EOSERelayList> = LruCache(cacheSize)
|
||||
private val lock = Any()
|
||||
|
||||
fun addOrUpdate(
|
||||
user: T,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val relayList = users[user]
|
||||
if (relayList == null) {
|
||||
val newList = EOSERelayList()
|
||||
users.put(user, newList)
|
||||
|
||||
newList.addOrUpdate(relayUrl, time)
|
||||
} else {
|
||||
relayList.addOrUpdate(relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeEveryoneBut(list: Set<T>) {
|
||||
synchronized(lock) {
|
||||
users.snapshot().forEach {
|
||||
if (it.key !in list) {
|
||||
users.remove(it.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDataFor(user: T) {
|
||||
synchronized(lock) {
|
||||
users.remove(user)
|
||||
}
|
||||
}
|
||||
|
||||
fun since(key: T): SincePerRelayMap? =
|
||||
synchronized(lock) {
|
||||
users[key]?.relayList?.toMutableMap()
|
||||
}
|
||||
|
||||
fun sinceRelaySet(key: T): Set<NormalizedRelayUrl>? =
|
||||
synchronized(lock) {
|
||||
users[key]?.relayList?.keys?.toSet()
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
user: T,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) = addOrUpdate(user, relayUrl, time)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
|
||||
@@ -138,6 +141,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
|
||||
@@ -341,6 +345,15 @@ fun AppNavigation(
|
||||
CompositionLocalProvider(
|
||||
LocalScreenLayout provides screenLayout,
|
||||
LocalTabReselectCoordinator provides tabReselectCoordinator,
|
||||
// Provide the shared finder CompositionLocals so any commons composable that
|
||||
// uses the no-arg observeUser*/EventFinderFilterAssemblerSubscription(note)
|
||||
// overloads works when rendered on Android (they error() if unprovided). Android's
|
||||
// own UI uses the AccountViewModel overloads and doesn't strictly need these, but
|
||||
// providing them removes the runtime trap for shared composables reaching the
|
||||
// logged-in tree. (The :napplet process never renders these composables.)
|
||||
LocalUserFinder provides accountViewModel.dataSources().userFinder,
|
||||
LocalUserFinderAccount provides accountViewModel.account,
|
||||
LocalEventFinder provides accountViewModel.dataSources().eventFinder,
|
||||
) {
|
||||
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -747,6 +760,14 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.ConcordInviteLinks> {
|
||||
ConcordInviteLinksScreen(
|
||||
communityId = it.communityId,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.ConcordEdit> {
|
||||
ConcordEditScreen(
|
||||
communityId = it.communityId,
|
||||
|
||||
@@ -121,7 +121,28 @@ class Nav(
|
||||
}
|
||||
// Mark this entry as a tab root: hides the back arrow in canPop
|
||||
// and skips the horizontal slide in composableFromEnd.
|
||||
controller.getBackStackEntry(route).savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true
|
||||
// saveState/restoreState are keyed by DESTINATION, and every pinned tab of one kind shares a
|
||||
// single destination — all web apps are `Route.WebApp/{url}`, all pinned chats their own one
|
||||
// pattern. So the restore above can hand back a *sibling* tab's saved entry: with two web apps
|
||||
// pinned, tapping the second one landed on the first one's URL, and the lookup below then threw
|
||||
// `No destination with route …WebApp/<url> is on the NavController's back stack`.
|
||||
//
|
||||
// When the entry we asked for isn't there, take the tab fresh (no restoreState, and no
|
||||
// launchSingleTop — the top is the sibling we do not want to reuse). Its saved scroll/ViewModel
|
||||
// state is not recoverable in that case, but the user lands on the tab they tapped. Tabs whose
|
||||
// destination nothing else shares still restore normally, which is what this is here for.
|
||||
val entry =
|
||||
runCatching { controller.getBackStackEntry(route) }.getOrNull()
|
||||
?: run {
|
||||
controller.navigate(route) {
|
||||
popUpTo(Route.Home) {
|
||||
inclusive = false
|
||||
saveState = true
|
||||
}
|
||||
}
|
||||
runCatching { controller.getBackStackEntry(route) }.getOrNull()
|
||||
}
|
||||
entry?.savedStateHandle?.set(BOTTOM_NAV_ROOT_KEY, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -830,6 +830,10 @@ sealed class Route {
|
||||
val communityId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ConcordInviteLinks(
|
||||
val communityId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable object ConcordCreate : Route()
|
||||
|
||||
// Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow.
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
|
||||
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a
|
||||
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
|
||||
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
|
||||
// resolution on low-density screens. Deriving the size from the available column width keeps
|
||||
|
||||
+43
-17
@@ -170,10 +170,14 @@ fun ConcordChannelListScreen(
|
||||
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
|
||||
// control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3),
|
||||
// so the affordance waits for the key too.
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned
|
||||
// moderator kept seeing every control here. The editions they authored were dropped by everyone's
|
||||
// fold, which made these buttons silently no-op — worse than absent, and the same trap this file
|
||||
// already avoids for the Roles… menu.
|
||||
val canManageChannels =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
@@ -242,10 +246,19 @@ fun ConcordChannelListScreen(
|
||||
val canEdit =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
// Minting an invite hands out a working key to the community, so it takes
|
||||
// CREATE_INVITE like any other privileged action. This button used to be the one
|
||||
// control on the screen with no gate at all.
|
||||
val canInvite =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE)
|
||||
} == true
|
||||
|
||||
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
|
||||
SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title))
|
||||
}
|
||||
@@ -254,22 +267,24 @@ fun ConcordChannelListScreen(
|
||||
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
if (canInvite) {
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
|
||||
@@ -279,6 +294,17 @@ fun ConcordChannelListScreen(
|
||||
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// Deliberately not gated on CREATE_INVITE, unlike minting: the links listed
|
||||
// there are this account's own, authored by link-signer keys only we hold.
|
||||
// Gating on the bit would mean a demoted admin could no longer retire the
|
||||
// links they had already handed out — exactly when that matters most.
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
nav.nav(Route.ConcordInviteLinks(communityId))
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||
|
||||
/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */
|
||||
private sealed interface LinksState {
|
||||
data object Loading : LinksState
|
||||
|
||||
data class Loaded(
|
||||
val links: List<ConcordInviteListEntry>,
|
||||
) : LinksState
|
||||
|
||||
/**
|
||||
* The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering
|
||||
* "no links yet" here would tell a creator that the link they came to kill does not exist.
|
||||
*/
|
||||
data object Unreadable : LinksState
|
||||
}
|
||||
|
||||
/**
|
||||
* Every invite link this account minted for one community, with the ability to retire one
|
||||
* (CORD-05 §2).
|
||||
*
|
||||
* The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives —
|
||||
* so this shows only links *this account* minted, from any of its devices. Another admin's links are
|
||||
* invisible here and un-revokable from here, because the secret that authors their coordinate was
|
||||
* never ours. That is a property of the protocol, not a gap in the screen.
|
||||
*
|
||||
* Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is
|
||||
* bookkeeping the user never sees), so there is no cache to observe.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConcordInviteLinksScreen(
|
||||
communityId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = LocalClipboard.current
|
||||
|
||||
var state by remember(communityId) { mutableStateOf<LinksState>(LinksState.Loading) }
|
||||
var reloads by remember(communityId) { mutableIntStateOf(0) }
|
||||
var confirming by remember { mutableStateOf<ConcordInviteListEntry?>(null) }
|
||||
var revoking by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(communityId, reloads) {
|
||||
state = LinksState.Loading
|
||||
state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable
|
||||
}
|
||||
|
||||
val communityName =
|
||||
remember(account, communityId) {
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId }
|
||||
?.name
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold)
|
||||
if (communityName.isNotBlank()) {
|
||||
Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when (val current = state) {
|
||||
is LinksState.Loading ->
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable))
|
||||
|
||||
is LinksState.Loaded ->
|
||||
if (current.links.isEmpty()) {
|
||||
CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty))
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
items(current.links, key = { it.token }) { link ->
|
||||
InviteLinkRow(
|
||||
link = link,
|
||||
enabled = !revoking,
|
||||
onCopy = { scope.launch { clipboard.setText(link.url) } },
|
||||
onRevoke = { confirming = link },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirming?.let { link ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!revoking) confirming = null },
|
||||
title = { Text(stringRes(R.string.concord_invite_revoke_title)) },
|
||||
text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !revoking,
|
||||
onClick = {
|
||||
revoking = true
|
||||
scope.launch {
|
||||
try {
|
||||
val ok = account.concord.revokeConcordInvite(communityId, link.token)
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.concord_invite_links_title,
|
||||
if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed,
|
||||
)
|
||||
// Re-read either way: on success the link is gone from the list, and on
|
||||
// failure the list is the only thing that can say whether it changed.
|
||||
reloads++
|
||||
} finally {
|
||||
revoking = false
|
||||
confirming = null
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !revoking, onClick = { confirming = null }) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(
|
||||
padding: PaddingValues,
|
||||
message: String,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
message,
|
||||
// This Box sits on the bare window background, so LocalContentColor is still the M3
|
||||
// default black — see the sibling invite screen, where that made the text invisible.
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InviteLinkRow(
|
||||
link: ConcordInviteListEntry,
|
||||
enabled: Boolean,
|
||||
onCopy: () -> Unit,
|
||||
onRevoke: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 8.dp)) {
|
||||
// The token prefix is what tells two links to the same community apart; their URLs share
|
||||
// a long prefix, so they are useless as labels until well past where the row wraps.
|
||||
Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
IconButton(enabled = enabled, onClick = { menuOpen = true }) {
|
||||
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options))
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_to_clipboard)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onCopy()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onRevoke()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -124,6 +124,8 @@ fun ConcordInviteScreen(
|
||||
RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false)
|
||||
is ConcordInviteResult.Revoked ->
|
||||
RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false)
|
||||
is ConcordInviteResult.Banned ->
|
||||
RedeemState.Failed(R.string.concord_invite_failed_banned, canRetry = false)
|
||||
is ConcordInviteResult.Expired ->
|
||||
RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false)
|
||||
is ConcordInviteResult.NotReachable ->
|
||||
@@ -161,6 +163,10 @@ fun ConcordInviteScreen(
|
||||
Text(
|
||||
stringRes(R.string.concord_redeeming_invite),
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
// Explicit: this Column sits on the bare window background with no Surface
|
||||
// above it, so LocalContentColor is still the M3 default black — which renders
|
||||
// every one of these labels invisible in the dark theme.
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
@@ -170,6 +176,7 @@ fun ConcordInviteScreen(
|
||||
Text(
|
||||
stringRes(failed.messageRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (failed.canRetry) {
|
||||
|
||||
+4
-1
@@ -133,7 +133,10 @@ fun ConcordMembersScreen(
|
||||
}
|
||||
|
||||
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
|
||||
// hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban /
|
||||
// Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn,
|
||||
// which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md.
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true
|
||||
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
|
||||
|
||||
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
|
||||
|
||||
+3
-3
@@ -20,12 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -93,7 +93,7 @@ fun filterHashtagLabels(
|
||||
val target = LocalCache.getNoteIfExists(targetId)
|
||||
if (target?.event == null) {
|
||||
val targetNote = LocalCache.getOrCreateNote(targetId)
|
||||
potentialRelaysToFindEvent(targetNote).ifEmpty { relays }.forEach { relayUrl ->
|
||||
potentialRelaysToFindEvent(LocalCache, targetNote).ifEmpty { relays }.forEach { relayUrl ->
|
||||
add(relayUrl, targetId)
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
|
||||
internal fun NappletCapability.symbol(): MaterialSymbol =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> MaterialSymbols.Tune
|
||||
NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle
|
||||
NappletCapability.KEYS -> MaterialSymbols.Key
|
||||
NappletCapability.RELAY -> MaterialSymbols.Public
|
||||
|
||||
+23
-9
@@ -49,7 +49,15 @@ import com.google.zxing.qrcode.encoder.Encoder
|
||||
import com.google.zxing.qrcode.encoder.QRCode
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
|
||||
const val QR_MARGIN_PX = 100f
|
||||
/**
|
||||
* The quiet zone around the code, in **modules** — the QR spec's minimum of 4.
|
||||
*
|
||||
* It was a fixed 100px per side, which does not scale: at a small draw size those 200px ate most of
|
||||
* the canvas, so a long payload (a Concord invite link, an nprofile) rendered as a postage stamp
|
||||
* floating in white. Expressed in modules the zone stays proportional, so the code fills whatever
|
||||
* box it is given at every size while remaining scannable.
|
||||
*/
|
||||
const val QR_QUIET_ZONE_MODULES = 4f
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@@ -78,13 +86,16 @@ fun QrCodeDrawer(
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
// Calculate the height and width of each column/row
|
||||
val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height
|
||||
val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width
|
||||
// Solve for the module size with the quiet zone measured in modules, so the whole code
|
||||
// (zone included) is exactly as wide as the canvas.
|
||||
val rowHeight = size.height / (qrCode.matrix.height + QR_QUIET_ZONE_MODULES * 2f)
|
||||
val columnWidth = size.width / (qrCode.matrix.width + QR_QUIET_ZONE_MODULES * 2f)
|
||||
val radius = CornerRadius(20f)
|
||||
|
||||
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
|
||||
// of the number of rows/columns to the width and height
|
||||
drawQrCodeFinders(
|
||||
quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES,
|
||||
sideLength = size.width,
|
||||
finderPatternSize =
|
||||
Size(
|
||||
@@ -97,6 +108,7 @@ fun QrCodeDrawer(
|
||||
|
||||
// Draw data bits (encoded data part)
|
||||
drawAllQrCodeDataBits(
|
||||
quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES,
|
||||
bytes = qrCode.matrix,
|
||||
size =
|
||||
Size(
|
||||
@@ -119,7 +131,7 @@ private fun createQrCode(contents: String): QRCode {
|
||||
ErrorCorrectionLevel.Q,
|
||||
mapOf(
|
||||
EncodeHintType.CHARACTER_SET to "UTF-8",
|
||||
EncodeHintType.MARGIN to QR_MARGIN_PX,
|
||||
EncodeHintType.MARGIN to QR_QUIET_ZONE_MODULES,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q,
|
||||
),
|
||||
)
|
||||
@@ -132,6 +144,7 @@ fun newPath(withPath: Path.() -> Unit) =
|
||||
}
|
||||
|
||||
fun DrawScope.drawAllQrCodeDataBits(
|
||||
quietZonePx: Float,
|
||||
bytes: ByteMatrix,
|
||||
size: Size,
|
||||
color: Color,
|
||||
@@ -182,8 +195,8 @@ fun DrawScope.drawAllQrCodeDataBits(
|
||||
Rect(
|
||||
offset =
|
||||
Offset(
|
||||
x = QR_MARGIN_PX + x * size.width,
|
||||
y = QR_MARGIN_PX + y * size.height,
|
||||
x = quietZonePx + x * size.width,
|
||||
y = quietZonePx + y * size.height,
|
||||
),
|
||||
size = newSize,
|
||||
),
|
||||
@@ -212,6 +225,7 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f
|
||||
* @param finderPatternSize [Size] of each finder patten, based on the QR code spec
|
||||
*/
|
||||
internal fun DrawScope.drawQrCodeFinders(
|
||||
quietZonePx: Float,
|
||||
sideLength: Float,
|
||||
finderPatternSize: Size,
|
||||
cornerRadius: CornerRadius,
|
||||
@@ -219,11 +233,11 @@ internal fun DrawScope.drawQrCodeFinders(
|
||||
) {
|
||||
setOf(
|
||||
// Draw top left finder pattern.
|
||||
Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX),
|
||||
Offset(x = quietZonePx, y = quietZonePx),
|
||||
// Draw top right finder pattern.
|
||||
Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX),
|
||||
Offset(x = sideLength - (quietZonePx + finderPatternSize.width), y = quietZonePx),
|
||||
// Draw bottom finder pattern.
|
||||
Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)),
|
||||
Offset(x = quietZonePx, y = sideLength - (quietZonePx + finderPatternSize.height)),
|
||||
).forEach { offset ->
|
||||
drawQrCodeFinder(
|
||||
topLeft = offset,
|
||||
|
||||
+9
-8
@@ -21,11 +21,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
@@ -37,14 +38,14 @@ fun filterMissingEventsForThread(
|
||||
val missingEvents =
|
||||
mapOfSet {
|
||||
if (threadInfo.root.event == null && threadInfo.root !is AddressableNote) {
|
||||
potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
potentialRelaysToFindEvent(LocalCache, threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
add(relayUrl, threadInfo.root.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
threadInfo.allNotes.forEach {
|
||||
if (it !is AddressableNote && it.event == null) {
|
||||
potentialRelaysToFindEvent(it).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
potentialRelaysToFindEvent(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
add(relayUrl, it.idHex)
|
||||
}
|
||||
}
|
||||
@@ -59,14 +60,14 @@ fun filterMissingEventsForThread(
|
||||
// note's aTag idHex into the hex-keyed event-hint index, which throws
|
||||
// on the non-hex string and kills the whole filter build — leaving a
|
||||
// thread opened on an uncached naddr permanently unfetched.
|
||||
potentialRelaysToFindAddress(rootNote).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
potentialRelaysToFindAddress(LocalCache, rootNote).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
add(relayUrl, rootNote.address)
|
||||
}
|
||||
}
|
||||
|
||||
threadInfo.allNotes.forEach {
|
||||
if (it is AddressableNote && it.event == null) {
|
||||
potentialRelaysToFindAddress(it).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
potentialRelaysToFindAddress(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl ->
|
||||
add(relayUrl, it.address)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3977,6 +3977,8 @@
|
||||
<string name="resource_usage_send_section">Sdílet s vývojáři</string>
|
||||
<string name="resource_usage_send_explanation">Pokud se zdá, že Amethyst spotřebovává baterii nebo data, můžete tento report odeslat vývojářům v šifrované DM. Obsahuje pouze čísla na této obrazovce a technická počítadla za nimi — žádné příspěvky, kontakty ani podrobnosti o prohlížení. Nic se neodešle, dokud v obrazovce zprávy neklepnete na Odeslat.</string>
|
||||
<string name="resource_usage_send_button">Odeslat report přes DM</string>
|
||||
<string name="resource_usage_copy_button">Kopírovat</string>
|
||||
<string name="resource_usage_share_button">Sdílet</string>
|
||||
<string name="resource_usage_alert_title">Zjištěno vysoké využití prostředků</string>
|
||||
<string name="resource_usage_alert_message">Amethyst nedávno spotřeboval více, než se očekávalo: %1$s. Chcete vývojářům odeslat report o využití v šifrované DM? Před odesláním čehokoli uvidíte celý report.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s mobilních dat na pozadí za jeden den</string>
|
||||
|
||||
@@ -1949,6 +1949,7 @@
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Stöbern</string>
|
||||
<string name="relay_purpose_media">Medien</string>
|
||||
<string name="relay_purpose_tags">Hashtags</string>
|
||||
<string name="relay_purpose_topics">Themen</string>
|
||||
<string name="relay_purpose_thread">Unterhaltung</string>
|
||||
<string name="relay_purpose_search">Suche</string>
|
||||
@@ -1979,8 +1980,10 @@
|
||||
<string name="relay_explain_geohash_chats">Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen.</string>
|
||||
<string name="relay_explain_live_chat">Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst.</string>
|
||||
<string name="relay_purpose_dm_inbox">DM-Posteingang</string>
|
||||
<string name="relay_purpose_your_wallet">Wallet</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Nutzap-Posteingang</string>
|
||||
<string name="relay_purpose_mint_directory">Mint-Verzeichnis</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Community-Chats</string>
|
||||
<string name="relay_purpose_community_feeds">Community-Feeds</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
@@ -2017,6 +2020,7 @@
|
||||
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item>
|
||||
<item quantity="other">%1$d Filter sind noch nicht zugeordnet</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Keinem Konto zugeordnet</string>
|
||||
<string name="active_subs_no_entity">Alle</string>
|
||||
<string name="active_subs_scope_global">Jeder</string>
|
||||
@@ -2226,13 +2230,17 @@
|
||||
<string name="settings_section_home_tabs">Sichtbare Tabs</string>
|
||||
<string name="settings_section_home_content_types">Inhalte im Feed</string>
|
||||
<string name="home_content_type_text_notes">Textnotizen</string>
|
||||
<string name="home_content_type_reposts">Reposts</string>
|
||||
<string name="home_content_type_comments">Kommentare & Antworten</string>
|
||||
<string name="home_content_type_pictures">Bilder</string>
|
||||
<string name="home_content_type_videos">Videos</string>
|
||||
<string name="home_content_type_shorts">Kurzvideos</string>
|
||||
<string name="home_content_type_articles">Artikel</string>
|
||||
<string name="home_content_type_wiki">Wiki-Seiten</string>
|
||||
<string name="home_content_type_highlights">Highlights</string>
|
||||
<string name="home_content_type_polls">Umfragen</string>
|
||||
<string name="home_content_type_classifieds">Kleinanzeigen</string>
|
||||
<string name="home_content_type_torrents">Torrents</string>
|
||||
<string name="home_content_type_voice">Sprachnachrichten</string>
|
||||
<string name="home_content_type_live_activities">Live-Aktivitäten</string>
|
||||
<string name="home_content_type_ephemeral_chat">Ephemere Chats</string>
|
||||
@@ -2242,6 +2250,7 @@
|
||||
<string name="home_content_type_attestations">Attestierungen</string>
|
||||
<string name="home_content_type_nips">NIP-Entwürfe</string>
|
||||
<string name="home_content_type_music">Musik & Audio</string>
|
||||
<string name="home_content_type_podcasts">Podcasts</string>
|
||||
<string name="home_content_type_fundraisers">Spendenaktionen</string>
|
||||
<string name="settings_section_reminders">Erinnerungen</string>
|
||||
<string name="wallet_connect">Wallet Verbindung</string>
|
||||
@@ -3597,6 +3606,7 @@
|
||||
<string name="git_repo_settings_topics">Themen (durch Komma getrennt)</string>
|
||||
<string name="git_repo_settings_save">Speichern</string>
|
||||
<string name="git_repositories">Git Repositories</string>
|
||||
<string name="highlights">Highlights</string>
|
||||
<string name="git_repositories_search_open">Repositories filtern</string>
|
||||
<string name="git_repositories_search_close">Filter schließen</string>
|
||||
<string name="git_repositories_search_placeholder">Nach Name, Thema, Host, Betreuer filtern…</string>
|
||||
@@ -3831,6 +3841,8 @@
|
||||
<string name="resource_usage_send_section">Mit den Entwicklern teilen</string>
|
||||
<string name="resource_usage_send_explanation">Wenn Amethyst Akku oder Daten zu verbrauchen scheint, kannst du diesen Bericht in einer verschlüsselten DM an die Entwickler senden. Er enthält nur die Zahlen auf diesem Bildschirm und die technischen Zähler dahinter — keine Beiträge, Kontakte oder Browserdetails. Es wird nichts gesendet, bis du im Nachrichtenbildschirm auf Senden tippst.</string>
|
||||
<string name="resource_usage_send_button">Bericht per DM senden</string>
|
||||
<string name="resource_usage_copy_button">Kopieren</string>
|
||||
<string name="resource_usage_share_button">Teilen</string>
|
||||
<string name="resource_usage_alert_title">Hohe Ressourcennutzung erkannt</string>
|
||||
<string name="resource_usage_alert_message">Amethyst hat in letzter Zeit mehr als erwartet verbraucht: %1$s. Möchtest du einen Nutzungsbericht in einer verschlüsselten DM an die Entwickler senden? Du siehst den vollständigen Bericht, bevor etwas gesendet wird.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s mobile Daten im Hintergrund an einem Tag</string>
|
||||
@@ -4636,6 +4648,7 @@
|
||||
<string name="new_conversation_location_pro_2">Bitchat-kompatibel; erreicht Nutzer in der Nähe auf denselben Relays.</string>
|
||||
<string name="new_conversation_location_con_1">Öffentlich und flüchtig — kein Verlauf, jeder in der Zelle kann es lesen.</string>
|
||||
<!-- Buzz workflow run board -->
|
||||
<string name="buzz_job_board_title">Backlog</string>
|
||||
<string name="buzz_workflow_runs_title">Workflow-Läufe</string>
|
||||
<string name="buzz_agent_work_title">Agentenarbeit</string>
|
||||
<string name="buzz_workflow_new_run">Neuer Lauf</string>
|
||||
@@ -4646,6 +4659,7 @@
|
||||
<string name="buzz_workflow_gate_needs_you">Benötigt deine Genehmigung</string>
|
||||
<string name="buzz_workflow_gate_awaiting">Wartet auf Genehmigung</string>
|
||||
<string name="buzz_workflow_no_description">(keine Beschreibung)</string>
|
||||
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
|
||||
<string name="buzz_workflow_by">von</string>
|
||||
<string name="buzz_workflow_waiting_on">wartet auf</string>
|
||||
<string name="buzz_workflow_readonly_approver">Du bist der Genehmigende, aber diese Anmeldung kann keine Entscheidung signieren.</string>
|
||||
@@ -4681,11 +4695,13 @@
|
||||
<string name="buzz_workflow_no_defs_hint">Noch keine Workflows. Öffne das Menü oben und wähle „Neue Definition…“, um einen zu erstellen, und löse ihn dann aus.</string>
|
||||
<string name="buzz_workflow_task_label">Was soll er tun?</string>
|
||||
<string name="buzz_workflow_trigger_run">Lauf auslösen</string>
|
||||
<string name="buzz_workflow_picker_label">Workflow</string>
|
||||
<string name="buzz_workflow_picker_empty">Noch keine Workflows definiert</string>
|
||||
<string name="buzz_workflow_picker_choose">Workflow auswählen</string>
|
||||
<string name="buzz_workflow_new_definition">Neue Definition…</string>
|
||||
<string name="buzz_workflow_def_title">Neue Workflow-Definition</string>
|
||||
<string name="buzz_workflow_def_desc">Benennt ihn für den Kanal und veröffentlicht sein YAML-Rezept (kind-30620). Ein echtes Buzz-Relay führt das YAML aus. Selbst gehostet führt der Runner seinen konfigurierten Befehl aus — hier benennt und katalogisiert die Definition den Lauf nur.</string>
|
||||
<string name="buzz_workflow_def_name">Name</string>
|
||||
<string name="buzz_workflow_def_name_hint">build-und-test</string>
|
||||
<string name="buzz_workflow_def_yaml">YAML-Rezept</string>
|
||||
<string name="buzz_workflow_def_publish_failed">Die Definition konnte nicht veröffentlicht werden — prüfe, ob du in diesem Workspace posten kannst.</string>
|
||||
|
||||
@@ -1941,8 +1941,13 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d relé</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d relés</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Navegação</string>
|
||||
<string name="relay_purpose_media">Mídia</string>
|
||||
<string name="relay_purpose_tags">Hashtags</string>
|
||||
<string name="relay_purpose_topics">Tópicos</string>
|
||||
<string name="relay_purpose_thread">Conversa</string>
|
||||
<string name="relay_purpose_search">Pesquisa</string>
|
||||
@@ -1976,6 +1981,7 @@
|
||||
<string name="relay_purpose_your_wallet">Carteira</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Caixa de entrada de nutzaps</string>
|
||||
<string name="relay_purpose_mint_directory">Diretório de mints</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Chats de comunidades</string>
|
||||
<string name="relay_purpose_community_feeds">Feeds de comunidades</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
@@ -2004,10 +2010,15 @@
|
||||
<item quantity="one">%1$d filtro</item>
|
||||
<item quantity="other">%1$d filtros</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d Relé</item>
|
||||
<item quantity="other">%1$d Relés</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtro ainda não foi atribuído</item>
|
||||
<item quantity="other">%1$d filtros ainda não foram atribuídos</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Não atribuído a nenhuma conta</string>
|
||||
<string name="active_subs_no_entity">Tudo</string>
|
||||
<string name="active_subs_scope_global">Todos</string>
|
||||
@@ -2227,6 +2238,7 @@
|
||||
<string name="home_content_type_highlights">Destaques</string>
|
||||
<string name="home_content_type_polls">Enquetes</string>
|
||||
<string name="home_content_type_classifieds">Classificados</string>
|
||||
<string name="home_content_type_torrents">Torrents</string>
|
||||
<string name="home_content_type_voice">Mensagens de voz</string>
|
||||
<string name="home_content_type_live_activities">Atividades ao vivo</string>
|
||||
<string name="home_content_type_ephemeral_chat">Chats efêmeros</string>
|
||||
@@ -2236,6 +2248,7 @@
|
||||
<string name="home_content_type_attestations">Atestações</string>
|
||||
<string name="home_content_type_nips">Rascunhos de NIP</string>
|
||||
<string name="home_content_type_music">Música e áudio</string>
|
||||
<string name="home_content_type_podcasts">Podcasts</string>
|
||||
<string name="home_content_type_fundraisers">Arrecadações</string>
|
||||
<string name="settings_section_reminders">Lembretes</string>
|
||||
<string name="wallet_connect">Conectar Carteira</string>
|
||||
@@ -3826,6 +3839,8 @@
|
||||
<string name="resource_usage_send_section">Compartilhar com os desenvolvedores</string>
|
||||
<string name="resource_usage_send_explanation">Se o Amethyst parece consumir bateria ou dados, você pode enviar este relatório aos desenvolvedores em uma DM criptografada. Ele contém apenas os números desta tela e os contadores técnicos por trás deles — sem publicações, contatos ou detalhes de navegação. Nada é enviado até você tocar em Enviar na tela de mensagem.</string>
|
||||
<string name="resource_usage_send_button">Enviar relatório por DM</string>
|
||||
<string name="resource_usage_copy_button">Copiar</string>
|
||||
<string name="resource_usage_share_button">Compartilhar</string>
|
||||
<string name="resource_usage_alert_title">Alto uso de recursos detectado</string>
|
||||
<string name="resource_usage_alert_message">O Amethyst consumiu mais do que o esperado recentemente: %1$s. Deseja enviar um relatório de uso aos desenvolvedores em uma DM criptografada? Você verá o relatório completo antes de qualquer envio.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s de dados de celular em segundo plano em um dia</string>
|
||||
@@ -4631,6 +4646,7 @@
|
||||
<string name="new_conversation_location_pro_2">Compatível com Bitchat; alcança usuários próximos nos mesmos relays.</string>
|
||||
<string name="new_conversation_location_con_1">Público e efêmero — sem histórico, qualquer um na célula pode ler.</string>
|
||||
<!-- Buzz workflow run board -->
|
||||
<string name="buzz_job_board_title">Backlog</string>
|
||||
<string name="buzz_workflow_runs_title">Execuções de fluxo de trabalho</string>
|
||||
<string name="buzz_agent_work_title">Trabalho do agente</string>
|
||||
<string name="buzz_workflow_new_run">Nova execução</string>
|
||||
|
||||
@@ -3839,6 +3839,8 @@
|
||||
<string name="resource_usage_send_section">Dela med utvecklarna</string>
|
||||
<string name="resource_usage_send_explanation">Om Amethyst verkar dra batteri eller data kan du skicka den här rapporten till utvecklarna i ett krypterat DM. Den innehåller bara siffrorna på den här skärmen och de tekniska räknarna bakom dem — inga inlägg, kontakter eller surfdetaljer. Ingenting skickas förrän du trycker på Skicka i meddelandeskärmen.</string>
|
||||
<string name="resource_usage_send_button">Skicka rapport via DM</string>
|
||||
<string name="resource_usage_copy_button">Kopiera</string>
|
||||
<string name="resource_usage_share_button">Dela</string>
|
||||
<string name="resource_usage_alert_title">Hög resursanvändning upptäckt</string>
|
||||
<string name="resource_usage_alert_message">Amethyst förbrukade mer än väntat nyligen: %1$s. Vill du skicka en användningsrapport till utvecklarna i ett krypterat DM? Du får se hela rapporten innan något skickas.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s mobildata i bakgrunden på en dag</string>
|
||||
|
||||
@@ -321,6 +321,18 @@
|
||||
<string name="concord_invite_failed_invalid">This invite link is invalid or can\'t be opened with this account.</string>
|
||||
<string name="concord_invite_failed_incompatible">This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link.</string>
|
||||
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
|
||||
<string name="concord_invite_failed_banned">This community has removed you. The link still works, but its member list does not admit you.</string>
|
||||
<string name="concord_invite_links_title">Invite links</string>
|
||||
<string name="concord_invite_links_action">Invite links…</string>
|
||||
<string name="concord_invite_links_created">Created %1$s</string>
|
||||
<string name="concord_invite_links_empty">You haven\'t created any invite links for this community yet. Links other admins created are managed on their own devices.</string>
|
||||
<string name="concord_invite_links_unreadable">Your invite links couldn\'t be loaded, so none can be revoked right now. Check your connection and try again.</string>
|
||||
<string name="concord_invite_revoke_action">Revoke link</string>
|
||||
<string name="concord_invite_revoke_title">Revoke this link?</string>
|
||||
<string name="concord_invite_revoke_explainer">Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can\'t be undone.</string>
|
||||
<string name="concord_invite_revoke_confirm">Revoke</string>
|
||||
<string name="concord_invite_revoked_ok">Invite link revoked.</string>
|
||||
<string name="concord_invite_revoked_failed">The link couldn\'t be revoked. Check your connection and try again.</string>
|
||||
<string name="concord_invite_failed_expired">This invite link has expired and can no longer be used. Ask for a fresh link.</string>
|
||||
<string name="concord_invite_preview_unknown_name">Community name is only revealed after you join</string>
|
||||
<string name="concord_invite_preview_explainer">Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join.</string>
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards the non-suspending seed that [SearchRelayListState.flow] is initialized with.
|
||||
*
|
||||
* The seed exists so `.value` is never empty before the first async emission lands (see the
|
||||
* KDoc on `flow`). For it to be an improvement over just using the curated defaults, reading an
|
||||
* account's *public* relays must work with no signer involvement at all — otherwise a NIP-46
|
||||
* account would still block. These tests pin that property.
|
||||
*/
|
||||
class PrecachedRelayListSeedTest {
|
||||
private val relayA = RelayUrlNormalizer.normalize("wss://relay.example.com")
|
||||
private val relayB = RelayUrlNormalizer.normalize("wss://other.example.com")
|
||||
|
||||
/**
|
||||
* The load-bearing claim: public relay tags are readable synchronously. `cachedRelays` is
|
||||
* non-suspending, so if this returned empty the seed would silently degrade to the defaults
|
||||
* for every account.
|
||||
*/
|
||||
@Test
|
||||
fun cachedRelays_readsPublicRelaysWithoutDecrypting() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val event = SearchRelayListEvent.create(relays = listOf(relayA, relayB), signer = signer)
|
||||
|
||||
val relays = SearchRelayListDecryptionCache(signer).cachedRelays(event)
|
||||
|
||||
assertEquals(setOf(relayA, relayB), relays)
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind:10007 with no relays must read as empty here, so the seed's `?.ifEmpty { null }`
|
||||
* arm hands over to the curated defaults rather than seeding an empty set.
|
||||
*/
|
||||
@Test
|
||||
fun cachedRelays_emptyListReadsEmptySoTheSeedCanFallBack() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val event = SearchRelayListEvent.create(relays = emptyList(), signer = signer)
|
||||
|
||||
val relays = SearchRelayListDecryptionCache(signer).cachedRelays(event)
|
||||
|
||||
assertTrue(relays.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading another account's list must not blow up or leak a decrypt attempt — the private
|
||||
* cache refuses to build for a foreign pubkey, so only the public tags come back.
|
||||
*/
|
||||
@Test
|
||||
fun cachedRelays_foreignAuthorStillYieldsPublicRelays() =
|
||||
runTest {
|
||||
val author = NostrSignerInternal(KeyPair())
|
||||
val reader = NostrSignerInternal(KeyPair())
|
||||
val event = SearchRelayListEvent.create(relays = listOf(relayA), signer = author)
|
||||
|
||||
val relays = SearchRelayListDecryptionCache(reader).cachedRelays(event)
|
||||
|
||||
assertEquals(setOf(relayA), relays)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class NappletLauncherTest {
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val manifest = mockk<NappletManifest>()
|
||||
private val author = "aa".repeat(32)
|
||||
|
||||
@Test
|
||||
fun manifestLaunchRejectsNonEventManifest() {
|
||||
NappletLauncher.launch(context, manifest, author, "demo")
|
||||
|
||||
verify(exactly = 0) { context.startActivity(any<Intent>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun embeddedBuildLaunchParamsRejectsNonEventManifest() {
|
||||
assertNull(NappletLauncher.buildLaunchParams(context, manifest, author, "demo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawLaunchRejectsNappletProfile() {
|
||||
every { context.startActivity(any<Intent>()) } returns Unit
|
||||
|
||||
NappletLauncher.launch(
|
||||
context = context,
|
||||
paths = emptyList(),
|
||||
servers = emptyList(),
|
||||
authorPubKey = author,
|
||||
identifier = "demo",
|
||||
aggregateHash = null,
|
||||
title = "Demo",
|
||||
requires = emptyList(),
|
||||
profile = HostProfile.NAPPLET,
|
||||
)
|
||||
|
||||
verify(exactly = 0) { context.startActivity(any<Intent>()) }
|
||||
}
|
||||
}
|
||||
+92
-14
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -73,11 +74,6 @@ class NappletProtocolJsonTest {
|
||||
assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesShellSupports() {
|
||||
assertEquals(NappletRequest.ShellSupports("relay"), NappletProtocolJson.decodeRequest("""{"type":"shell.supports","id":"1","domain":"relay"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesPublishFromAnUnsignedTemplateInTheEventField() {
|
||||
// @napplet/shim carries the unsigned template in the `event` field. The shell signs it.
|
||||
@@ -165,13 +161,22 @@ class NappletProtocolJsonTest {
|
||||
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}"""))
|
||||
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k","scope":"instance"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesValueResourceUpload() {
|
||||
assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"type":"value.payInvoice","invoice":"lnbc1"}"""))
|
||||
assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info"}"""))
|
||||
assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","url":"https://x"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","urls":["https://x","data:text/plain,hi"]}"""),
|
||||
)
|
||||
// "SGk=" is base64 for "Hi"; shell.html inlines the request Blob as request.dataBase64.
|
||||
val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk=","mimeType":"text/plain","filename":"a.txt"}}""") as NappletRequest.UploadBlob
|
||||
assertEquals("text/plain", up.contentType)
|
||||
@@ -184,6 +189,7 @@ class NappletProtocolJsonTest {
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}"""))
|
||||
// keys.signEvent is not a real domain method (keys = keyboard actions, not signing).
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}"""))
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"identity.futureMethod","id":"1"}"""))
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}"""))
|
||||
}
|
||||
|
||||
@@ -211,7 +217,9 @@ class NappletProtocolJsonTest {
|
||||
assertEquals("s1", ev["subId"]?.jsonPrimitive?.content)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
ev["event"]
|
||||
ev["result"]
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
@@ -233,13 +241,6 @@ class NappletProtocolJsonTest {
|
||||
assertEquals("pk", o["pubkey"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesSupported() {
|
||||
val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("shell.supports", NappletResponse.Supported(true))).jsonObject
|
||||
assertEquals("shell.supports.result", o["type"]?.jsonPrimitive?.content)
|
||||
assertTrue(o["supported"]!!.jsonPrimitive.boolean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesPublishedEventAndEvents() {
|
||||
// relay.publish resolves to the signed event (matching upstream NostrEvent return).
|
||||
@@ -257,6 +258,18 @@ class NappletProtocolJsonTest {
|
||||
|
||||
val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject
|
||||
assertEquals(1, events["events"]?.jsonArray?.size)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
events["events"]
|
||||
?.jsonArray
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -269,6 +282,71 @@ class NappletProtocolJsonTest {
|
||||
assertEquals(2, keys["keys"]?.jsonArray?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesResourceInfoBulkItemsAndTypedErrors() {
|
||||
val info =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
NappletProtocolJson.encodeResponse(
|
||||
"resource.info",
|
||||
NappletResponse.ResourceInfo(listOf("https"), 10L * 1024L * 1024L, 16),
|
||||
),
|
||||
).jsonObject
|
||||
assertEquals(
|
||||
"https",
|
||||
info["info"]
|
||||
?.jsonObject
|
||||
?.get("schemes")
|
||||
?.jsonArray
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("scheme")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
|
||||
val items =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
NappletProtocolJson.encodeResponse(
|
||||
"resource.bytesMany",
|
||||
NappletResponse.ResourceItems(
|
||||
listOf(
|
||||
NappletResponse.ResourceItem("https://x", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")),
|
||||
NappletResponse.ResourceItem("https://y", error = "not-found"),
|
||||
),
|
||||
),
|
||||
),
|
||||
).jsonObject["items"]
|
||||
?.jsonArray
|
||||
assertEquals(
|
||||
"SGk=",
|
||||
items
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("bytes")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"not-found",
|
||||
items
|
||||
?.get(1)
|
||||
?.jsonObject
|
||||
?.get("error")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
|
||||
val failure =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.ResourceFailure("blocked-by-policy", "private target")),
|
||||
).jsonObject
|
||||
assertEquals("resource.bytes.error", failure["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("blocked-by-policy", failure["error"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesBytesAsBase64WithMime() {
|
||||
val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class NappletRelayCleartextTest {
|
||||
@Test
|
||||
fun plaintextPassesThroughWithoutDecrypting() =
|
||||
runTest {
|
||||
val event = event(content = "hello")
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
|
||||
error("plaintext must not be decrypted")
|
||||
}
|
||||
|
||||
assertSame(event, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundNip04IsProjectedAsCleartext() =
|
||||
runTest {
|
||||
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { ciphertext, peer ->
|
||||
assertEquals(NIP04, ciphertext)
|
||||
assertEquals(AUTHOR, peer)
|
||||
"secret"
|
||||
}
|
||||
|
||||
assertEquals("secret", result?.content)
|
||||
assertEquals(event.id, result?.id)
|
||||
assertEquals(event.sig, result?.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outboundEncryptedEventUsesItsRecipientAsPeer() =
|
||||
runTest {
|
||||
val event = event(pubKey = USER, content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, peer ->
|
||||
assertEquals(RECIPIENT, peer)
|
||||
"sent secret"
|
||||
}
|
||||
|
||||
assertEquals("sent secret", result?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptedEventForAnotherUserIsWithheld() =
|
||||
runTest {
|
||||
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
|
||||
error("unrelated ciphertext must not be offered to the signer")
|
||||
}
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptionFailureWithholdsCiphertext() =
|
||||
runTest {
|
||||
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
|
||||
error("signer refused")
|
||||
}
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
private fun event(
|
||||
pubKey: String = AUTHOR,
|
||||
content: String,
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
) = Event("id", pubKey, 1L, 4, tags, content, "sig")
|
||||
|
||||
companion object {
|
||||
private const val USER = "user"
|
||||
private const val AUTHOR = "author"
|
||||
private const val RECIPIENT = "recipient"
|
||||
private const val NIP04 = "ciphertext-that-is-long-enough?iv=123456789012345678901234"
|
||||
}
|
||||
}
|
||||
+27
-26
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
@@ -114,17 +115,31 @@ class NappletSdkConformanceTest {
|
||||
// RelayQueryResultMessage: { type:'relay.query.result', id, events, error? }
|
||||
val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent())))
|
||||
assertEquals(1, o["events"]?.jsonArray?.size)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
o["events"]
|
||||
?.jsonArray
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayEventAndEosePushesMatchTheSdk() {
|
||||
// RelayEventMessage (PUSH): { type:'relay.event', subId, event }
|
||||
// RelayEventMessage (PUSH): { type:'relay.event', subId, result:{event, sidecar?} }
|
||||
val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject
|
||||
assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("s1", ev["subId"]?.jsonPrimitive?.content)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
ev["event"]
|
||||
ev["result"]
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
@@ -175,7 +190,11 @@ class NappletSdkConformanceTest {
|
||||
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","id":"1","key":"k","value":"v"}"""))
|
||||
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","id":"1","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k","scope":"instance"}"""),
|
||||
)
|
||||
|
||||
// StorageGetResultMessage.value, StorageKeysResultMessage.keys
|
||||
assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value"))
|
||||
@@ -186,7 +205,12 @@ class NappletSdkConformanceTest {
|
||||
|
||||
@Test
|
||||
fun resourceBytesRequestAndResultMatch() {
|
||||
assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info","id":"0"}"""))
|
||||
assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","id":"1","url":"https://x"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","id":"2","urls":["https://x","data:text/plain,hi"]}"""),
|
||||
)
|
||||
// The host emits base64 bytes + mime; shell.html rebuilds the Blob the SDK expects.
|
||||
val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))
|
||||
assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content)
|
||||
@@ -203,29 +227,6 @@ class NappletSdkConformanceTest {
|
||||
assertTrue(result("relay.query", NappletResponse.Failed("boom")).containsKey("error"))
|
||||
}
|
||||
|
||||
// ---------- shell handshake (ShellReadyMessage / ShellInitMessage) ----------
|
||||
|
||||
@Test
|
||||
fun shellInitAdvertisesTheCapabilityEnvironment() {
|
||||
// shell.ready is answered by the host (not the codec) with this shell.init env, which the
|
||||
// SDK caches and answers shell.supports() from locally. ShellInitMessage:
|
||||
// { type:'shell.init', capabilities:{ domains, protocols }, services }.
|
||||
val o = json.parseToJsonElement(NappletProtocolJson.encodeShellInit(listOf("shell", "relay"), listOf("shell", "relay"))).jsonObject
|
||||
assertEquals("shell.init", o["type"]?.jsonPrimitive?.content)
|
||||
assertEquals(
|
||||
2,
|
||||
o["capabilities"]
|
||||
?.jsonObject
|
||||
?.get("domains")
|
||||
?.jsonArray
|
||||
?.size,
|
||||
)
|
||||
assertTrue(o["capabilities"]?.jsonObject?.containsKey("protocols") == true)
|
||||
assertEquals(2, o["services"]?.jsonArray?.size)
|
||||
// shell.ready stays a host-layer message — the codec doesn't treat it as a broker request.
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"shell.ready"}"""))
|
||||
}
|
||||
|
||||
// ---------- keys (keyboard/command actions) ----------
|
||||
|
||||
@Test
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.napplet.gateways
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.net.InetAddress
|
||||
|
||||
class NappletResourceFetcherPolicyTest {
|
||||
@Test
|
||||
fun blocksPrivateSpecialAndLocalAddresses() {
|
||||
val blocked =
|
||||
listOf(
|
||||
"0.0.0.0",
|
||||
"10.0.0.1",
|
||||
"100.64.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"172.16.0.1",
|
||||
"192.0.0.1",
|
||||
"192.0.2.1",
|
||||
"192.168.1.1",
|
||||
"198.18.0.1",
|
||||
"198.51.100.1",
|
||||
"203.0.113.1",
|
||||
"224.0.0.1",
|
||||
"::1",
|
||||
"2001:db8::1",
|
||||
"fc00::1",
|
||||
"fe80::1",
|
||||
)
|
||||
|
||||
blocked.forEach {
|
||||
assertFalse(it, NappletResourceFetcher.isPublicAddress(InetAddress.getByName(it)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun permitsPublicAddresses() {
|
||||
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("1.1.1.1")))
|
||||
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsOnlyCredentialFreeHttpsUrls() {
|
||||
assertTrue(NappletResourceFetcher.isSafeHttpsResourceUrl("https://example.com/a"))
|
||||
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("http://example.com/a"))
|
||||
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("https://user:secret@example.com/a"))
|
||||
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("file:///etc/passwd"))
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -20,11 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
||||
@@ -669,6 +669,7 @@ also carried on-relay as an encrypted kind:13302.
|
||||
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). |
|
||||
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. |
|
||||
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. |
|
||||
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
|
||||
| `amy concord join URL` | Redeem an invite link and save the community. |
|
||||
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
|
||||
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). |
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. |
|
||||
| CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. |
|
||||
| Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. |
|
||||
| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 13 sub-verbs (create/list/import/channels/send/read/invite/join/roles/role/grant/ban/unban) over shared `commons` `ConcordActions`; secrets in `concord.json`. |
|
||||
| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 17 sub-verbs (create/list/import/channels/send/read/invite/revoke/join/recover/rekey/roles/role/grant/ban/unban/refound) over shared `commons` `ConcordActions`; secrets in `concord.json`. |
|
||||
| NIP-5A nsites + NIP-5D napplets | ✅ | `NsiteCommands` + `NappletCommands` — fetch/publish/serve/list with sha256 + aggregate-hash verification and `requires` capability reporting. |
|
||||
| Podcasting 2.0 / podstr (`amy podcast20`) | ✅ | `Podcast20Commands` — kind:30078 metadata, 30054 episodes, 30055 trailers, list. |
|
||||
| Follows-of-follows (`amy fof get/list/sync`) | ✅ | `FofCommand` — single-hop social proof from the local store (`wot` kept as deprecation alias). |
|
||||
|
||||
@@ -582,12 +582,15 @@ class Context(
|
||||
diagnoseSlow: Boolean = false,
|
||||
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
|
||||
pendingOnAuthRequired: Boolean = false,
|
||||
/** Per-relay terminal reason, so a caller can tell an empty answer from no answer. */
|
||||
doneOut: MutableMap<NormalizedRelayUrl, String>? = null,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> =
|
||||
client.fetchAllWithHooks(
|
||||
filters = filters,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
pendingOnAuthRequired = pendingOnAuthRequired,
|
||||
deadOut = deadOut,
|
||||
doneOut = doneOut,
|
||||
onTimeout =
|
||||
if (diagnoseSlow) {
|
||||
{ stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) }
|
||||
|
||||
@@ -869,6 +869,7 @@ private fun printUsage() {
|
||||
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
|
||||
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages
|
||||
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
|
||||
| concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone)
|
||||
| concord join URL redeem an invite link and save the community
|
||||
|
|
||||
|Local event store (shared, under `<data-dir>/shared/`):
|
||||
|
||||
@@ -28,9 +28,22 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -55,12 +68,23 @@ object ConcordCommands {
|
||||
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50);
|
||||
| [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane
|
||||
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
|
||||
| concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9
|
||||
| tombstone at its coordinate, then tombstones
|
||||
| it in your invite list so it stays retired
|
||||
| concord join URL redeem an invite link and save the community
|
||||
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
|
||||
| open our blob and adopt the new epoch
|
||||
| concord recover [COMMUNITY] re-resolve the joined-through invite link and
|
||||
| follow a Refounding we were left out of
|
||||
| (CORD-06); refuses if that epoch banned us
|
||||
| concord roles COMMUNITY list live roles + current banlist (CORD-04)
|
||||
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK)
|
||||
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
|
||||
| concord ban COMMUNITY USER ban a member
|
||||
| concord unban COMMUNITY USER unban a member
|
||||
| concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the
|
||||
| control_root) so removed members lose every
|
||||
| key — the hard removal a ban cannot give
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun dispatch(
|
||||
@@ -70,7 +94,7 @@ object ConcordCommands {
|
||||
route(
|
||||
"concord",
|
||||
tail,
|
||||
"concord <create|list|import|channels|send|read|invite|join|roles|role|grant|ban|unban>",
|
||||
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound>",
|
||||
help = USAGE,
|
||||
routes =
|
||||
mapOf(
|
||||
@@ -81,12 +105,16 @@ object ConcordCommands {
|
||||
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
|
||||
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
|
||||
"invite" to { rest -> invite(dataDir, rest) },
|
||||
"revoke" to { rest -> revoke(dataDir, rest) },
|
||||
"join" to { rest -> join(dataDir, rest) },
|
||||
"recover" to { rest -> recover(dataDir, rest) },
|
||||
"rekey" to { rest -> rekey(dataDir, rest) },
|
||||
"roles" to { rest -> ConcordModCommands.roles(dataDir, rest) },
|
||||
"role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) },
|
||||
"grant" to { rest -> ConcordModCommands.grant(dataDir, rest) },
|
||||
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
|
||||
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
|
||||
"refound" to { rest -> ConcordModCommands.refound(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -208,7 +236,10 @@ object ConcordCommands {
|
||||
controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "",
|
||||
generalChannelId = prior?.generalChannelId ?: "",
|
||||
relays = e.relays,
|
||||
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") },
|
||||
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") },
|
||||
// Survives every merge: losing the anchor makes the NEXT exclusion
|
||||
// unrecoverable, so a list entry without one must not clear ours.
|
||||
inviteRef = e.inviteRef ?: prior?.inviteRef ?: "",
|
||||
),
|
||||
)
|
||||
mapOf(
|
||||
@@ -241,6 +272,33 @@ object ConcordCommands {
|
||||
// (CORD-05 §1); omitted for a legacy community, which has none to carry.
|
||||
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null })
|
||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
|
||||
// Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose
|
||||
// `signer_sk` was never stored can never be refreshed, so the next Refounding orphans
|
||||
// it and every holder is stranded. Better to mint nothing than to hand out a link that
|
||||
// is already doomed.
|
||||
val recorded =
|
||||
publishInviteList(
|
||||
ctx,
|
||||
ConcordInviteListDocument(
|
||||
entries =
|
||||
listOf(
|
||||
ConcordInviteListEntry(
|
||||
token = minted.token.toHexKey(),
|
||||
signerSk = minted.linkSignerPrivKey.toHexKey(),
|
||||
communityId = sc.communityId,
|
||||
url = minted.url,
|
||||
createdAt = TimeUtils.now(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!recorded) {
|
||||
return Output.error(
|
||||
"invite_unrecordable",
|
||||
"could not record the link signer in your invite list (kind 13303), so this link could never be refreshed after a Refounding — not minting it",
|
||||
)
|
||||
}
|
||||
|
||||
val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc))
|
||||
RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it }
|
||||
|
||||
@@ -255,6 +313,92 @@ object ConcordCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy concord revoke <community> <token|url>` — retires one link this account minted.
|
||||
*
|
||||
* Two records have to agree for a link to be gone, and they fail differently, so the order is
|
||||
* deliberate. The wire tombstone (`vsk=9` at the link's own coordinate) is what actually stops
|
||||
* a join, and publishing it needs the `signer_sk` that only the kind-13303 Invite List holds.
|
||||
* The list tombstone is bookkeeping: it stops a later Refounding from re-minting the link.
|
||||
*
|
||||
* So the wire goes first and the list second. The reverse order would delete the entry — a
|
||||
* merge drops a tombstoned token's entry terminally — and if the publish then failed, the link
|
||||
* would stay live with its `signer_sk` gone and no way left to retire it. A failed list write
|
||||
* is recoverable by comparison: the link is already dead on the wire, and the refresh path
|
||||
* re-mints only a coordinate that still resolves Live, so it will not resurrect this one.
|
||||
*/
|
||||
private suspend fun revoke(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val link = args.positional(1, "token|url")
|
||||
args.rejectUnknown()
|
||||
|
||||
// Accept either the shareable URL (what a creator actually has to hand) or the bare token.
|
||||
val token =
|
||||
ConcordActions
|
||||
.parseInviteLink(link)
|
||||
?.fragment
|
||||
?.token
|
||||
?.toHexKey() ?: link.lowercase()
|
||||
if (!TOKEN_HEX.matches(token)) {
|
||||
return Output.error("bad_args", "expected an invite URL or a 32-hex-character link token, got '$link'").let { 2 }
|
||||
}
|
||||
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
val list =
|
||||
readInviteList(ctx)
|
||||
?: return Output.error("invite_list_unreadable", "could not read your invite list (kind 13303), so the link signer needed to revoke is unknown — refusing to guess")
|
||||
|
||||
val entry = list.entries.firstOrNull { it.token == token }
|
||||
if (entry == null) {
|
||||
return if (list.tombstones.any { it.token == token }) {
|
||||
Output.error("already_revoked", "this link was already revoked; its signer_sk is gone from the list, so there is nothing left to re-publish")
|
||||
} else {
|
||||
Output.error("not_found", "no link with token $token in your invite list — only the account that minted a link can revoke it")
|
||||
}
|
||||
}
|
||||
if (entry.communityId != sc.communityId) {
|
||||
return Output.error("wrong_community", "that link belongs to community ${entry.communityId}, not '$handle' (${sc.communityId})")
|
||||
}
|
||||
|
||||
val tombstone = ConcordActions.revokeBundleAt(entry.signerSk.hexToByteArray(), TimeUtils.now())
|
||||
val ack = ctx.publish(tombstone, relaysFor(ctx, sc))
|
||||
RawEventSupport.publishGuard(ack, tombstone.id)?.let { return it }
|
||||
|
||||
val recorded =
|
||||
publishInviteList(
|
||||
ctx,
|
||||
ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = sc.communityId))),
|
||||
)
|
||||
if (!recorded) {
|
||||
System.err.println(
|
||||
"[concord] the link is revoked on the wire but the tombstone could not be recorded in your invite list (kind 13303); re-run this command once your outbox relays are reachable",
|
||||
)
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"revoked" to true,
|
||||
"token" to token,
|
||||
"community_id" to sc.communityId,
|
||||
"link_signer" to entry.signerPubKeyHex(),
|
||||
"tombstone_event_id" to tombstone.id,
|
||||
"tombstoned_in_list" to recorded,
|
||||
) + RawEventSupport.ackFields(ack),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** A link token is 16 bytes on the wire, so 32 hex characters once stored in the list. */
|
||||
private val TOKEN_HEX = Regex("^[0-9a-f]{32}$")
|
||||
|
||||
private suspend fun join(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
@@ -268,9 +412,47 @@ object ConcordCommands {
|
||||
ctx.prepare()
|
||||
val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays())
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
|
||||
// Resolve the coordinate per CORD-05 §2 rather than opening whatever happens to decrypt:
|
||||
// the newest event wins, so a vsk=9 tombstone retires the link even when a stale but
|
||||
// still-openable copy is also present. Opening the first wrap that decrypts would let a
|
||||
// relay that kept the old version hand out a link its creator revoked — and it cannot
|
||||
// tell the user which of "revoked", "expired" or "gone" they are looking at.
|
||||
val bundle =
|
||||
wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
|
||||
?: return Output.error("not_found", "no valid bundle for this link").let { 1 }
|
||||
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
|
||||
is InviteBundleStatus.Live -> status.invite
|
||||
is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined")
|
||||
InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator")
|
||||
InviteBundleStatus.Unreadable -> return Output.error("incompatible", "something is published at this link's coordinate, but it is not a bundle this client can open")
|
||||
InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays")
|
||||
}
|
||||
|
||||
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
|
||||
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
|
||||
// unlock token forever — so without this check the rotation that was supposed to expel
|
||||
// them hands them the new keys instead. `recover` has always been ban-gated; `join` is
|
||||
// the other door into the same room.
|
||||
//
|
||||
// Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable
|
||||
// after the bundle yields the root, which is why the check lives here rather than before.
|
||||
val joinKeys =
|
||||
ConcordActions.controlPlaneKeys(
|
||||
communityRoot = bundle.communityRoot.hexToByteArray(),
|
||||
communityId = bundle.communityId.hexToByteArray(),
|
||||
rootEpoch = bundle.rootEpoch,
|
||||
controlPk = bundle.controlPk,
|
||||
)
|
||||
val joinRelays = normalize(bundle.relays).ifEmpty { relays }
|
||||
val joinEditions =
|
||||
ConcordActions.controlEditions(
|
||||
ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second },
|
||||
joinKeys,
|
||||
)
|
||||
if (joinEditions.isEmpty()) {
|
||||
return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join")
|
||||
}
|
||||
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(ctx.signer.pubKey)) {
|
||||
return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)")
|
||||
}
|
||||
|
||||
ConcordStore(dataDir.concordFile).upsert(
|
||||
StoredCommunity(
|
||||
@@ -284,6 +466,9 @@ object ConcordCommands {
|
||||
// community is still pre-split and folds at the legacy address.
|
||||
controlPk = bundle.controlPk ?: "",
|
||||
relays = bundle.relays,
|
||||
// The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving
|
||||
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
|
||||
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
|
||||
),
|
||||
)
|
||||
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
|
||||
@@ -316,6 +501,267 @@ object ConcordCommands {
|
||||
controlRoot = sc.controlRoot.ifBlank { null },
|
||||
)
|
||||
|
||||
/**
|
||||
* Adopts the `control_root` a staff-making Grant delivered to us (CORD-04 §3), persisting it to
|
||||
* the local store and returning the now-writable keys — or null when nothing was delivered.
|
||||
*
|
||||
* The decision itself is [ConcordReceive.deliveredControlRoot], shared with Amethyst: it fails
|
||||
* closed unless our own fold seats us as staff, the wrap opens under the granter↔member pairwise
|
||||
* key, it names this epoch, and the secret derives to exactly the `control_pk` we already hold.
|
||||
*
|
||||
* Local-only on purpose: Amethyst republishes the kind-13302 list on adoption so a user's other
|
||||
* devices follow, and doing that here would need amy to rebuild and sign the whole list. A CLI
|
||||
* adoption therefore unblocks *this* account's writes; other devices adopt from their own fold.
|
||||
*/
|
||||
suspend fun adoptDeliveredControlRoot(
|
||||
ctx: Context,
|
||||
dataDir: DataDir,
|
||||
sc: StoredCommunity,
|
||||
editions: List<ControlEdition>,
|
||||
): Pair<StoredCommunity, ControlPlaneKeys>? {
|
||||
val entry = entryFor(sc)
|
||||
val authority = AuthorityResolver.resolve(editions, sc.owner)
|
||||
val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null
|
||||
val updated = sc.copy(controlRoot = delivered)
|
||||
ConcordStore(dataDir.concordFile).upsert(updated)
|
||||
return updated to controlPlaneKeysFor(updated)
|
||||
}
|
||||
|
||||
/** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */
|
||||
fun entryFor(sc: StoredCommunity) =
|
||||
ConcordCommunityListEntry(
|
||||
id = sc.communityId,
|
||||
owner = sc.owner,
|
||||
ownerSalt = sc.ownerSalt,
|
||||
root = sc.root,
|
||||
rootEpoch = sc.rootEpoch,
|
||||
controlPk = sc.controlPk.ifBlank { null },
|
||||
controlRoot = sc.controlRoot.ifBlank { null },
|
||||
heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) },
|
||||
relays = sc.relays,
|
||||
name = sc.name,
|
||||
inviteRef = sc.inviteRef.ifBlank { null },
|
||||
)
|
||||
|
||||
/** Folds [entry] back into the stored shape after a rotation is adopted. */
|
||||
fun storedFrom(
|
||||
sc: StoredCommunity,
|
||||
entry: ConcordCommunityListEntry,
|
||||
) = sc.copy(
|
||||
root = entry.root,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
controlPk = entry.controlPk ?: "",
|
||||
controlRoot = entry.controlRoot ?: "",
|
||||
heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") },
|
||||
relays = entry.relays,
|
||||
name = entry.name.ifBlank { sc.name },
|
||||
inviteRef = entry.inviteRef ?: sc.inviteRef,
|
||||
)
|
||||
|
||||
/**
|
||||
* `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2).
|
||||
*
|
||||
* A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a
|
||||
* member simply left out of the rekey receives nothing and sits on the dead epoch forever while
|
||||
* everyone else moves on. There is no message to miss, which is why the rekey drain cannot help.
|
||||
* The way back is the invite link the membership was joined through: the community keeps
|
||||
* re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly
|
||||
* higher** epoch than ours proves we were left behind — and carries the new root.
|
||||
*
|
||||
* Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and
|
||||
* scriptable rather than a background loop.
|
||||
*
|
||||
* The ban gate is the point of care. A removed member keeps the link's unlock token forever, so
|
||||
* without it this walks them straight back into the epoch they were rotated out of. It reads the
|
||||
* banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails
|
||||
* closed**: a community whose plane will not fold yields no verdict and is skipped, never
|
||||
* recovered.
|
||||
*/
|
||||
private suspend fun recover(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positionalOrNull(0)
|
||||
args.rejectUnknown()
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val targets =
|
||||
if (handle != null) {
|
||||
listOf(store.find(handle) ?: return notFound(handle))
|
||||
} else {
|
||||
store.load()
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
for (sc in targets) {
|
||||
val inviteRef = sc.inviteRef.ifBlank { null }
|
||||
if (inviteRef == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref")
|
||||
continue
|
||||
}
|
||||
val parsed = ConcordActions.parseInviteLink(inviteRef)
|
||||
if (parsed == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref")
|
||||
continue
|
||||
}
|
||||
val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() }
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
|
||||
// Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
|
||||
if (bundle == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle")
|
||||
continue
|
||||
}
|
||||
|
||||
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict,
|
||||
// no recovery — the gate fails closed rather than assuming "not banned".
|
||||
val cp = controlPlaneKeysFor(sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val editions = ConcordActions.controlEditions(controlWraps, cp)
|
||||
if (editions.isEmpty()) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded")
|
||||
continue
|
||||
}
|
||||
val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey)
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere)
|
||||
if (merged == null) {
|
||||
results +=
|
||||
mapOf(
|
||||
"community_id" to sc.communityId,
|
||||
"name" to sc.name,
|
||||
"recovered" to false,
|
||||
"reason" to if (bannedHere) "banned" else "already_current",
|
||||
"root_epoch" to sc.rootEpoch,
|
||||
)
|
||||
continue
|
||||
}
|
||||
store.upsert(storedFrom(sc, merged))
|
||||
results +=
|
||||
mapOf(
|
||||
"community_id" to sc.communityId,
|
||||
"name" to sc.name,
|
||||
"recovered" to true,
|
||||
"from_epoch" to sc.rootEpoch,
|
||||
"root_epoch" to merged.rootEpoch,
|
||||
)
|
||||
}
|
||||
Output.emit(mapOf("communities" to results))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `concord rekey [COMMUNITY]` — follow a Refounding we WERE re-keyed for (CORD-06).
|
||||
*
|
||||
* The normal counterpart to [recover]: a retained member gets a per-recipient blob on the next
|
||||
* epoch's base-rekey plane, and opening it yields the new root. Amethyst drains this on its
|
||||
* revision tick; amy has no tick, so it is a verb. Without it a Refounding launched from the CLI
|
||||
* strands every other CLI member even though their blob is sitting on the relay.
|
||||
*
|
||||
* The rotator is authorized against the roster of the epoch being **left** — `hasPermission`,
|
||||
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed:
|
||||
* a plane that will not fold yields no verdict and the community is skipped.
|
||||
*/
|
||||
private suspend fun rekey(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positionalOrNull(0)
|
||||
args.rejectUnknown()
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val targets = if (handle != null) listOf(store.find(handle) ?: return notFound(handle)) else store.load()
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
for (sc in targets) {
|
||||
val relays = relaysFor(ctx, sc)
|
||||
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val received =
|
||||
ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
|
||||
if (received == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
if (received.newEpoch <= sc.rootEpoch) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
// Authorize the rotator against the epoch we are LEAVING — the last plane we can fold.
|
||||
val cp = controlPlaneKeysFor(sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val editions = ConcordActions.controlEditions(controlWraps, cp)
|
||||
if (editions.isEmpty()) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
|
||||
continue
|
||||
}
|
||||
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator)
|
||||
continue
|
||||
}
|
||||
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
store.upsert(storedFrom(sc, adopted))
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
|
||||
}
|
||||
Output.emit(mapOf("communities" to results))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This account's CORD-05 Invite List (kind 13303) — the creator's private, self-encrypted record
|
||||
* of every link they minted, so a rotation can refresh those links instead of orphaning them.
|
||||
* Empty when none was ever published.
|
||||
*/
|
||||
suspend fun readInviteList(ctx: Context): ConcordInviteListDocument? {
|
||||
val relays = ctx.outboxRelays()
|
||||
if (relays.isEmpty()) return null
|
||||
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey))
|
||||
// Terminal reasons, not just events: a drain returns nothing both when a relay served us and
|
||||
// had nothing AND when nobody answered. Reading the second as "no list yet" is how the
|
||||
// read-merge-write below wipes the signer_sk of every link it failed to read.
|
||||
val reasons = mutableMapOf<NormalizedRelayUrl, String>()
|
||||
val newest =
|
||||
ctx
|
||||
.drain(relays.associateWith { listOf(filter) }, doneOut = reasons)
|
||||
// Filter by kind BEFORE picking the newest — a stray event at this coordinate would
|
||||
// otherwise make the list read as unreadable and refuse every later write.
|
||||
.mapNotNull { it.second as? ConcordInviteListEvent }
|
||||
.maxByOrNull { it.createdAt }
|
||||
?: return if (reasons.anyRelayServed()) ConcordInviteListDocument.EMPTY else null
|
||||
return newest.decrypt(ctx.signer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges [patch] into the published list and republishes it, returning whether it landed.
|
||||
*
|
||||
* Read-merge-write, and **aborts rather than overwriting** when the read fails: kind 13303 is
|
||||
* replaceable, so writing a patch-only document over a list we could not read deletes every
|
||||
* other link's `signer_sk`. Those secrets cannot be regenerated, and losing one orphans its
|
||||
* link at the next rotation, stranding everyone holding that URL.
|
||||
*
|
||||
* Account-scoped, like the coordinate itself — (13303, me, "") is one list for every community,
|
||||
* so reading or writing it on a single community's relays would fork it.
|
||||
*/
|
||||
suspend fun publishInviteList(
|
||||
ctx: Context,
|
||||
patch: ConcordInviteListDocument,
|
||||
): Boolean {
|
||||
val relays = ctx.outboxRelays()
|
||||
if (relays.isEmpty()) return false
|
||||
val base = readInviteList(ctx) ?: return false
|
||||
val event = ConcordInviteListEvent.create(ctx.signer, ConcordInviteList.merge(base, patch), TimeUtils.now())
|
||||
return ctx.publish(event, relays).values.any { it.accepted }
|
||||
}
|
||||
|
||||
fun notFound(handle: String): Int {
|
||||
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
|
||||
return 1
|
||||
|
||||
@@ -28,11 +28,14 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -51,7 +54,7 @@ object ConcordModCommands {
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val (_, editions) = load(ctx, sc)
|
||||
val (_, editions) = load(ctx, sc, dataDir)
|
||||
val state = ConcordCommunityState.fold(editions, sc.owner)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
@@ -92,7 +95,7 @@ object ConcordModCommands {
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val (cp, editions) = load(ctx, sc)
|
||||
val (cp, editions) = load(ctx, sc, dataDir)
|
||||
writeGuard(cp)?.let { return it }
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire())
|
||||
@@ -119,7 +122,8 @@ object ConcordModCommands {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val member = ctx.requireUserHex(userRef)
|
||||
val (cp, editions) = load(ctx, sc)
|
||||
val loaded = load(ctx, sc, dataDir)
|
||||
val (cp, editions) = loaded
|
||||
writeGuard(cp)?.let { return it }
|
||||
// A Grant that first makes its member staff must carry the write secret in the same
|
||||
// edition (CORD-04 §3); ConcordModeration wraps it pairwise when the granted roles
|
||||
@@ -134,7 +138,10 @@ object ConcordModCommands {
|
||||
current = editions,
|
||||
createdAt = TimeUtils.now(),
|
||||
owner = sc.owner,
|
||||
controlRoot = sc.controlRoot.ifBlank { null }?.hexToByteArray(),
|
||||
controlRoot =
|
||||
loaded.community.controlRoot
|
||||
.ifBlank { null }
|
||||
?.hexToByteArray(),
|
||||
epoch = sc.rootEpoch,
|
||||
)
|
||||
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
|
||||
@@ -170,7 +177,7 @@ object ConcordModCommands {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val member = ctx.requireUserHex(userRef)
|
||||
val (cp, editions) = load(ctx, sc)
|
||||
val (cp, editions) = load(ctx, sc, dataDir)
|
||||
writeGuard(cp)?.let { return it }
|
||||
val cid = sc.communityId.hexToByteArray()
|
||||
val wrap =
|
||||
@@ -186,11 +193,283 @@ object ConcordModCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The drained Control Plane: the community as stored *after* any adoption, its keys, and the
|
||||
* editions to chain onto. [community] matters because adopting a delivered `control_root`
|
||||
* rewrites the stored record — a caller that kept the pre-load copy would then fail to pass the
|
||||
* secret on in its own Grant (CORD-04 §3).
|
||||
*/
|
||||
private class LoadedControl(
|
||||
val community: StoredCommunity,
|
||||
val keys: ControlPlaneKeys,
|
||||
val editions: List<ControlEdition>,
|
||||
) {
|
||||
operator fun component1() = keys
|
||||
|
||||
operator fun component2() = editions
|
||||
}
|
||||
|
||||
/**
|
||||
* `concord refound COMMUNITY --remove USER[,USER…]` — a CORD-06 Refounding: the hard removal.
|
||||
*
|
||||
* A ban only strips standing; the removed member keeps every key they ever held, so the room is
|
||||
* only truly closed to them by rotating the `community_root` (and, since CORD-02 §2, a fresh
|
||||
* `control_root` beside it, so a demoted staffer's retained secret dies with the epoch). The
|
||||
* compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey
|
||||
* blob; nobody else can follow.
|
||||
*
|
||||
* Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned
|
||||
* BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same
|
||||
* rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out.
|
||||
*
|
||||
* **The recipient set is a floor, not a census.** It is the roster ∪ Guestbook ∪ the authors of
|
||||
* every channel message we can decrypt ∪ ourselves, minus the removed and already-banned — the
|
||||
* same union Amethyst builds, because a member who only ever posted holds no role and leaves no
|
||||
* Guestbook motion, and omitting them silently expels them. A member with no trace at all still
|
||||
* cannot be re-keyed; `concord recover` is how they get back.
|
||||
*/
|
||||
suspend fun refound(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound <community> --remove USER[,USER…]").let { 2 }
|
||||
args.rejectUnknown()
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val removed =
|
||||
removeArg
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { ctx.requireUserHex(it).lowercase() }
|
||||
.toSet()
|
||||
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
|
||||
|
||||
val loaded = load(ctx, sc, dataDir)
|
||||
val (cp, editions) = loaded
|
||||
val state = ConcordCommunityState.fold(editions, sc.owner)
|
||||
val authority = state.authority
|
||||
val me = ctx.signer.pubKey
|
||||
|
||||
if (!ConcordReceive.isAuthorizedRotator(authority, me)) {
|
||||
return Output.error("forbidden", "this account cannot refound: a Refounding takes BAN (or ownership), and a banned holder is refused (CORD-06)")
|
||||
}
|
||||
if (removed.any { authority.isOwner(it) }) {
|
||||
return Output.error("forbidden", "the owner is never a valid removal target (CORD-04 §3)")
|
||||
}
|
||||
// An admin cannot Refound a peer admin out any more than they could ban one.
|
||||
if (!authority.isOwner(me) && removed.any { !authority.canActOn(me, it, ConcordPermissions.BAN) }) {
|
||||
return Output.error("forbidden", "you do not outrank every member you are removing (CORD-04 §3, equal cannot act on equal)")
|
||||
}
|
||||
// A Refounding writes the current plane (the pre-rotation bans) and the new one, so on a
|
||||
// split epoch it takes the current control_root (CORD-02 §2).
|
||||
writeGuard(cp)?.let { return it }
|
||||
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
|
||||
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
|
||||
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
|
||||
var chain = editions
|
||||
val banWraps = mutableListOf<Event>()
|
||||
for (target in removed) {
|
||||
val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner)
|
||||
val ack = ctx.publish(banWrap, relays)
|
||||
if (ack.values.none { it.accepted }) {
|
||||
return Output.error("ban_not_published", "the pre-rotation ban for $target was not accepted by any relay; refusing to refound with a banlist that would not survive")
|
||||
}
|
||||
banWraps += banWrap
|
||||
chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp))
|
||||
}
|
||||
|
||||
// 2. Everyone we are keeping. See the note above on why this reaches past the roster.
|
||||
val candidates =
|
||||
(rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me)
|
||||
.mapTo(HashSet()) { it.lowercase() }
|
||||
.apply {
|
||||
removeAll(removed)
|
||||
removeAll(authority.bannedMembers().map { it.lowercase() }.toSet())
|
||||
}
|
||||
val recipients = boundRecipients(candidates, authority)
|
||||
|
||||
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
|
||||
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
|
||||
val newRoot = RandomInstance.bytes(32)
|
||||
val newControlRoot = RandomInstance.bytes(32)
|
||||
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just
|
||||
// published. Re-draining alone would race the relay's indexing, and a relay that has not
|
||||
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
|
||||
// whose roster never banned the member we are removing.
|
||||
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val controlWraps = (drained + banWraps).distinctBy { it.id }
|
||||
val build =
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = ctx.signer,
|
||||
communityId = sc.communityId,
|
||||
priorRoot = sc.root.hexToByteArray(),
|
||||
newRoot = newRoot,
|
||||
newControlRoot = newControlRoot,
|
||||
rootEpoch = sc.rootEpoch,
|
||||
priorControlWraps = controlWraps,
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = authority.staffMembers(),
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = sc.owner,
|
||||
)
|
||||
|
||||
// 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it).
|
||||
build.controlWraps.forEach { ctx.publish(it, relays) }
|
||||
build.rekeyWraps.forEach { ctx.publish(it, relays) }
|
||||
|
||||
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
|
||||
// epoch we are leaving for the anti-rollback floor.
|
||||
val adopted =
|
||||
ConcordReceive.withAdoptedRoot(
|
||||
ConcordCommands.entryFor(loaded.community),
|
||||
newRoot,
|
||||
build.newEpoch,
|
||||
build.newControlKeys.address.hexToByteArray(),
|
||||
newControlRoot,
|
||||
)
|
||||
val stored = ConcordCommands.storedFrom(loaded.community, adopted)
|
||||
ConcordStore(dataDir.concordFile).upsert(stored)
|
||||
|
||||
// 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new
|
||||
// epoch. This is the liveness half of stranded recovery (A2): a member this Refounding
|
||||
// left out has no rekey blob and no message to miss, so re-resolving their link is the
|
||||
// only way back — and it only works if the bundle moves with the community instead of
|
||||
// being orphaned at a dead epoch. Minting a fresh link would not help them; the link
|
||||
// they hold is the one that must move.
|
||||
//
|
||||
// Safe for every link because recovery is ban-gated at the epoch being left, and step 1
|
||||
// banned everyone being removed — so a removed member's own `recover` is refused even
|
||||
// though their link now resolves.
|
||||
val now = TimeUtils.now()
|
||||
var refreshed = 0
|
||||
val list = ConcordCommands.readInviteList(ctx)
|
||||
val tombstoned = list?.tombstones?.mapTo(HashSet()) { it.token } ?: emptySet<String>()
|
||||
for (link in list?.entries.orEmpty()) {
|
||||
if (link.communityId != stored.communityId) continue
|
||||
// An elapsed or retired link can no longer be joined, so re-posting it would only
|
||||
// resurrect a dead URL at a live epoch (CORD-05).
|
||||
if (link.isExpired(now) || link.token in tombstoned) continue
|
||||
runCatching {
|
||||
val token = link.token.hexToByteArray()
|
||||
// Refresh from the link's CURRENT bundle so its own fields — expiry, channel
|
||||
// grants, icon, label — survive the rotation, and so a coordinate whose newest
|
||||
// event is a revocation tombstone is left revoked instead of being re-opened.
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
|
||||
val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching
|
||||
val moved =
|
||||
live.invite.copy(
|
||||
communityRoot = stored.root,
|
||||
rootEpoch = stored.rootEpoch,
|
||||
controlPk = stored.controlPk.ifBlank { null },
|
||||
relays = stored.relays,
|
||||
)
|
||||
ctx.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
|
||||
refreshed++
|
||||
}
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"community_id" to sc.communityId,
|
||||
"removed" to removed.toList(),
|
||||
"from_epoch" to sc.rootEpoch,
|
||||
"root_epoch" to build.newEpoch,
|
||||
"recipients" to recipients.size,
|
||||
"control_wraps" to build.controlWraps.size,
|
||||
"rekey_wraps" to build.rekeyWraps.size,
|
||||
"invites_refreshed" to refreshed,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many recipients one Refounding will re-key, mirroring Amethyst's own cap.
|
||||
*
|
||||
* Two thirds of the recipient union — Guestbook joins and observed channel authors — are
|
||||
* attacker-writable: any key can announce a join or post once. Without a bound, padding those
|
||||
* sets inflates the cost of the only hard removal Concord has until rotating becomes
|
||||
* impractical, so the attack raises the price of its own remedy (B4 in the soft-ban audit).
|
||||
*/
|
||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||
|
||||
/**
|
||||
* Caps [candidates], keeping the members whose standing is owner-rooted and therefore cannot be
|
||||
* padded from outside. Anything dropped is reported rather than silently truncated — a dropped
|
||||
* member is stranded on the dead epoch and their only way back is `concord recover`.
|
||||
*/
|
||||
private fun boundRecipients(
|
||||
candidates: Set<String>,
|
||||
authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver,
|
||||
): List<String> {
|
||||
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
|
||||
val vouched = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() }
|
||||
val kept = LinkedHashSet<String>()
|
||||
candidates.filterTo(kept) { it in vouched }
|
||||
for (candidate in candidates) {
|
||||
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
|
||||
kept.add(candidate)
|
||||
}
|
||||
val dropped = candidates.size - kept.size
|
||||
if (dropped > 0) {
|
||||
System.err.println("[concord] refounding recipient set trimmed to ${kept.size} of ${candidates.size}: $dropped member(s) will be stranded on the prior epoch")
|
||||
}
|
||||
return kept.toList()
|
||||
}
|
||||
|
||||
/** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */
|
||||
private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set<String> = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() }
|
||||
|
||||
/** Live Guestbook membership at this epoch (joins minus later leaves, CORD-02 §5). */
|
||||
private suspend fun guestbookMembersOf(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
): Set<String> =
|
||||
runCatching {
|
||||
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() }
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
/**
|
||||
* Authors of every channel message we can decrypt. Most members never send a Guestbook motion,
|
||||
* so without this a Refounding silently expels everyone who had only ever posted.
|
||||
*/
|
||||
private suspend fun channelAuthorsOf(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
state: ConcordCommunityState,
|
||||
): Set<String> {
|
||||
val out = HashSet<String>()
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
for ((channelIdHex, _) in state.channels) {
|
||||
runCatching {
|
||||
val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(key.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Drain the control plane and return its keys + current editions to chain onto. */
|
||||
private suspend fun load(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
): Pair<ControlPlaneKeys, List<ControlEdition>> {
|
||||
dataDir: DataDir? = null,
|
||||
): LoadedControl {
|
||||
val cp = ConcordCommands.controlPlaneKeysFor(sc)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
// Concord relays serve the plane's kind-1059 only to a connection AUTHed as the stream
|
||||
@@ -198,7 +477,18 @@ object ConcordModCommands {
|
||||
// that secret is staff-only (CORD-02 §2), and a member simply has nothing to register.
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
return cp to ConcordActions.controlEditions(wraps, cp)
|
||||
val editions = ConcordActions.controlEditions(wraps, cp)
|
||||
|
||||
// A promotion to staff delivers the Control Plane write key inside the Grant itself
|
||||
// (CORD-04 §3), so the fold that seats the role is also when the key arrives. Amethyst
|
||||
// drains this on its revision tick; amy has no tick, so the fold a command already does is
|
||||
// the moment to adopt — otherwise a CLI-promoted staffer holds a rank it can never write
|
||||
// under. Same shared, fail-closed check both clients use.
|
||||
if (dataDir != null && !cp.canWrite) {
|
||||
val adopted = ConcordCommands.adoptDeliveredControlRoot(ctx, dataDir, sc, editions)
|
||||
if (adopted != null) return LoadedControl(adopted.first, adopted.second, ConcordActions.controlEditions(wraps, adopted.second))
|
||||
}
|
||||
return LoadedControl(sc, cp, editions)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,11 @@ data class StoredCommunity(
|
||||
// Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch <n>`
|
||||
// re-derive a prior epoch's Chat Plane to reach pre-refounding history. Populated by `import`.
|
||||
val heldRoots: List<StoredHeldRoot> = emptyList(),
|
||||
// The bare `<naddr>#<fragment>` invite this membership was joined through — the stranded-recovery
|
||||
// anchor (CORD-05/06). A Refounding carries no recipient list, so a member simply left out of the
|
||||
// rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct
|
||||
// invite or a community joined before amy stored it.
|
||||
val inviteRef: String = "",
|
||||
)
|
||||
|
||||
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
|
||||
@@ -56,6 +61,12 @@ data class StoredHeldRoot(
|
||||
val root: String = "",
|
||||
/** That epoch's Control Plane address; blank for a legacy, pre-split epoch (CORD-02 §5). */
|
||||
val controlPk: String = "",
|
||||
/**
|
||||
* That epoch's staff write key, banked only if we held it. A relay that gates the prior epoch's
|
||||
* Control Plane on NIP-42 AUTH as the stream key will not serve those wraps without it — and
|
||||
* those wraps are what rebuild the anti-rollback floor.
|
||||
*/
|
||||
val controlRoot: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,7 @@ they are shared across the GUI apps. Treat as GUI-shared, not strictly headless.
|
||||
### Relay client
|
||||
| Package | UI? | Purpose |
|
||||
|----------------|-----|---------|
|
||||
| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) |
|
||||
| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The canonical **per-visible loading** entry points live here: `relayClient/user/` (`observeUser*` — kind-0 metadata) and `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*` — reactions/zaps/reposts). See the `relay-client` skill. |
|
||||
| `relays` | no | Low-level EOSE/relay-timing bookkeeping (`EOSECache`, `EOSERelayList`). |
|
||||
|
||||
### Platform abstractions (`expect`/`actual`)
|
||||
|
||||
@@ -297,6 +297,10 @@ val verifyKmpPurity by tasks.registering {
|
||||
"Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual",
|
||||
"java.util.UUID" to "use kotlin.uuid.Uuid",
|
||||
"kotlin.jvm.Synchronized" to "use KmpLock.withLock {}",
|
||||
// The bare call, not just the annotation: `synchronized(lock) {}` resolves
|
||||
// from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and
|
||||
// only fails at the iOS compile step. Catch it here instead.
|
||||
"synchronized(" to "`synchronized` is JVM-only — use KmpLock.withLock {}",
|
||||
"kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile",
|
||||
)
|
||||
val offenders =
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Trusted napplet shell page (served on the shell origin https://napplet.local at /__shell__).
|
||||
It hosts the untrusted applet in a sandboxed iframe pointed at the applet's OWN per-applet origin
|
||||
(a distinct napplet.local subdomain, injected as __APP_ORIGIN__) and relays capability messages
|
||||
between the applet (window.postMessage) and the native bridge (__nappletBridge), which is
|
||||
origin-restricted to this shell page only. The applet is cross-origin to the shell, so it can never
|
||||
reach the bridge or read the shell DOM. The iframe carries allow-same-origin so the applet has real,
|
||||
isolated storage on its own origin — which is safe precisely because that origin is never the
|
||||
shell's: the applet is same-origin only with itself.
|
||||
It hosts untrusted content and relays capability messages between the child (window.postMessage)
|
||||
and the origin-restricted native bridge (__nappletBridge). For NIP-5D the child receives verified,
|
||||
prelude-injected bytes through srcdoc and has only sandbox="allow-scripts" (opaque origin). NIP-5A
|
||||
websites retain Amethyst's separate-origin website posture and are navigated normally.
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
@@ -19,7 +16,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="app" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>
|
||||
<iframe id="app" sandbox="__APP_SANDBOX__" referrerpolicy="no-referrer"></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
var iframe = document.getElementById('app');
|
||||
@@ -70,11 +67,22 @@
|
||||
delete msg.bytes;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (msg && msg.type === 'resource.bytesMany.result' && Array.isArray(msg.items)) {
|
||||
msg.items.forEach(function (item) {
|
||||
if (!item || !item.ok || typeof item.bytes !== 'string') return;
|
||||
try {
|
||||
var bin = atob(item.bytes), u = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
item.blob = new Blob([u], { type: item.mime || '' });
|
||||
delete item.bytes;
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
iframe.contentWindow.postMessage(msg, '*');
|
||||
};
|
||||
}
|
||||
|
||||
iframe.src = '__APP_ORIGIN__/';
|
||||
__APP_BOOTSTRAP__
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
// Subscription pushes are keyed by subId, not a request id.
|
||||
if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') {
|
||||
var sub = subs[msg.subId]; if (!sub) return;
|
||||
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); }
|
||||
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.result); }
|
||||
else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); }
|
||||
else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); }
|
||||
return;
|
||||
@@ -91,7 +91,7 @@
|
||||
// identity.changed push: the active user's key changed (account switch / connect / disconnect).
|
||||
if (msg.type === 'identity.changed') { identityHandlers.slice().forEach(function(h){ try { h(msg.pubkey); } catch (_) {} }); return; }
|
||||
if (!msg.id) return;
|
||||
var p = pending[msg.id]; if (!p) return; delete pending[msg.id];
|
||||
var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; if (p.cleanup) p.cleanup();
|
||||
if (msg.ok) p.resolve(msg);
|
||||
else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); }
|
||||
}
|
||||
@@ -101,16 +101,31 @@
|
||||
window.addEventListener('message', function(e){ if (e.source !== parent) return; onIncoming(e.data); });
|
||||
}
|
||||
function field(promise, name){ return promise.then(function(m){ return m[name]; }); }
|
||||
function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; }
|
||||
function resourceCall(type, fields, opts){
|
||||
var signal = opts && opts.signal;
|
||||
if (signal && signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
return new Promise(function(resolve, reject){
|
||||
var env = { type: type }; for (var k in fields) env[k] = fields[k];
|
||||
var id = send(env), onAbort;
|
||||
var cleanup = function(){ if (signal && onAbort) signal.removeEventListener('abort', onAbort); };
|
||||
pending[id] = { resolve: resolve, reject: reject, cleanup: cleanup };
|
||||
if (signal) {
|
||||
onAbort = function(){
|
||||
if (!pending[id]) return;
|
||||
delete pending[id]; cleanup();
|
||||
post('resource.cancel', { id: id });
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
function normFilters(filters){ return { filters: Array.isArray(filters) ? filters : [filters || {}] }; }
|
||||
function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
|
||||
var napplet = {
|
||||
shell: {
|
||||
// supports() is synchronous in @napplet/shim; we expose a sync proxy backed by an async check.
|
||||
supports: function(domain, protocol){ return field(call('shell.supports', { domain: domain, protocol: protocol }), 'supported'); },
|
||||
ready: function(){ return Promise.resolve({}); },
|
||||
onReady: function(cb){ if (typeof cb === 'function') cb({}); return { close: function(){} }; },
|
||||
services: []
|
||||
},
|
||||
// Build the methods Amethyst actually implements, then project only the explicit domains the
|
||||
// trusted host authorized for this launch. Domain-object presence is the NIP-5D availability
|
||||
// signal; there is deliberately no legacy window.napplet.shell capability probe.
|
||||
var available = {
|
||||
identity: {
|
||||
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
|
||||
getProfile: function(){ return field(call('identity.getProfile'), 'profile'); },
|
||||
@@ -121,15 +136,12 @@
|
||||
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); },
|
||||
getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); },
|
||||
getBadges: function(){ return field(call('identity.getBadges'), 'badges'); },
|
||||
// onChanged: the shell pushes identity.changed when the active user's key changes. The first
|
||||
// handler opens the watch (identity.watch); closing the last one stops it (identity.unwatch).
|
||||
// The runtime owns identity.changed delivery; handlers are entirely local to the shim.
|
||||
onChanged: function(handler){
|
||||
if (typeof handler !== 'function') return { close: function(){} };
|
||||
identityHandlers.push(handler);
|
||||
if (identityHandlers.length === 1) post('identity.watch');
|
||||
return { close: function(){
|
||||
var i = identityHandlers.indexOf(handler); if (i >= 0) identityHandlers.splice(i, 1);
|
||||
if (identityHandlers.length === 0) post('identity.unwatch');
|
||||
} };
|
||||
}
|
||||
},
|
||||
@@ -152,6 +164,7 @@
|
||||
var subId = 's' + (seq++);
|
||||
subs[subId] = { onEvent: onEvent, onEose: onEose };
|
||||
var env = normFilters(filters); env.subId = subId;
|
||||
if (options && options.relay) env.relay = options.relay;
|
||||
post('relay.subscribe', env);
|
||||
return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } };
|
||||
}
|
||||
@@ -161,23 +174,49 @@
|
||||
getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); },
|
||||
setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); },
|
||||
removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); },
|
||||
keys: function(){ return field(call('storage.keys'), 'keys'); }
|
||||
keys: function(){ return field(call('storage.keys'), 'keys'); },
|
||||
instance: {
|
||||
getItem: function(key){ return field(call('storage.get', { key: key, scope: 'instance' }), 'value'); },
|
||||
setItem: function(key, value){ return call('storage.set', { key: key, value: value, scope: 'instance' }).then(function(){}); },
|
||||
removeItem: function(key){ return call('storage.remove', { key: key, scope: 'instance' }).then(function(){}); },
|
||||
keys: function(){ return field(call('storage.keys', { scope: 'instance' }), 'keys'); }
|
||||
}
|
||||
},
|
||||
// value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim).
|
||||
value: {
|
||||
payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); }
|
||||
},
|
||||
resource: {
|
||||
// The shell rebuilds the Blob from the host's base64 before this resolves.
|
||||
bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); },
|
||||
bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); }
|
||||
info: function(){ return field(call('resource.info'), 'info'); },
|
||||
// The shell rebuilds Blobs from the host's base64 before these resolve.
|
||||
bytes: function(url, opts){ return field(resourceCall('resource.bytes', { url: url }, opts), 'blob'); },
|
||||
bytesMany: function(urls, opts){ return field(resourceCall('resource.bytesMany', { urls: Array.from(urls || []) }, opts), 'items'); },
|
||||
bytesAsObjectURL: function(url){
|
||||
var objectUrl = '', revoked = false;
|
||||
var handle = { url: '', revoke: function(){ if (revoked) return; revoked = true; if (objectUrl) URL.revokeObjectURL(objectUrl); } };
|
||||
var ready = available.resource.bytes(url).then(function(blob){
|
||||
if (revoked) return;
|
||||
objectUrl = URL.createObjectURL(blob); handle.url = objectUrl; return objectUrl;
|
||||
});
|
||||
Object.defineProperty(handle, 'ready', { value: ready, enumerable: false });
|
||||
return handle;
|
||||
}
|
||||
},
|
||||
upload: {
|
||||
// Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for
|
||||
// a Blob from a stock napplet). Resolves to the uploaded URL.
|
||||
blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); }
|
||||
},
|
||||
theme: {
|
||||
get: function(){ return field(call('theme.get'), 'theme'); }
|
||||
}
|
||||
};
|
||||
var requested = [];
|
||||
try { if (Array.isArray(window.__nappletDomains)) requested = window.__nappletDomains; } catch (_) {}
|
||||
var napplet = {};
|
||||
requested.forEach(function(domain){
|
||||
if (typeof domain === 'string' && Object.prototype.hasOwnProperty.call(available, domain)) napplet[domain] = available[domain];
|
||||
});
|
||||
window.napplet = Object.freeze(napplet);
|
||||
|
||||
// ---- IME agent (in-app browser only) -------------------------------------------------------
|
||||
|
||||
+52
-1
@@ -201,6 +201,16 @@ object ConcordActions {
|
||||
/** The public invite bundle for a link signer. */
|
||||
fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex))
|
||||
|
||||
/**
|
||||
* The bundles of several links at once — one REQ over every link signer instead of a round trip
|
||||
* per link, which is what a Refounding needs when it re-mints a creator's whole set.
|
||||
*
|
||||
* Partition the result by `pubKey` before classifying: [ConcordInviteBundle.classify] resolves a
|
||||
* single coordinate, so handing it a pooled set would let one link's revocation tombstone decide
|
||||
* another link's status purely by being newer.
|
||||
*/
|
||||
fun bundlesFilter(linkSignerPubKeyHexes: List<HexKey>): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes)
|
||||
|
||||
/** Pending direct invites addressed to the given member (indexed by k=3313). */
|
||||
fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString())))
|
||||
|
||||
@@ -436,6 +446,44 @@ object ConcordActions {
|
||||
relays: List<String>? = null,
|
||||
): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays)
|
||||
|
||||
/**
|
||||
* Re-publishes a bundle at an **existing** link's coordinate, carrying [invite] refreshed for the
|
||||
* current epoch (CORD-05 §1). The kind-33301 bundle is addressable and authored by the link
|
||||
* signer, so re-signing with the same [linkSignerPrivKey] and re-encrypting under the same
|
||||
* [token] replaces what is there — every holder of that link keeps working, now pointing at the
|
||||
* new root.
|
||||
*
|
||||
* This is what makes stranded recovery live: a member a Refounding left out has no rekey blob and
|
||||
* no message to miss, and re-resolving their link is the only way back — which requires the
|
||||
* community to re-mint at the *same* coordinate rather than issuing a fresh link. Minting a new
|
||||
* link leaves the old one pointing at a dead epoch forever.
|
||||
*
|
||||
* Safe to call for every live link because recovery is ban-gated at the epoch being left
|
||||
* (CORD-06, A2): a member the Refounding removed was banned on the way out, so their own
|
||||
* `recover` is refused even though their link now resolves.
|
||||
*/
|
||||
fun remintBundleAt(
|
||||
linkSignerPrivKey: ByteArray,
|
||||
token: ByteArray,
|
||||
invite: CommunityInvite,
|
||||
createdAt: Long,
|
||||
): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt)
|
||||
|
||||
/**
|
||||
* Retires an existing link by publishing a `vsk=9` revocation tombstone at its coordinate
|
||||
* (CORD-05 §2). Once this lands, every client resolving that URL gets
|
||||
* [com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus.Revoked] instead of keys.
|
||||
*
|
||||
* Publish this *before* recording the tombstone in the kind-13303 Invite List — the list entry
|
||||
* carries the only copy of the `signer_sk` this call needs, and the list merge drops a
|
||||
* tombstoned token's entry for good. Recording first and failing to publish would leave the link
|
||||
* live on the wire with no way left to retire it.
|
||||
*/
|
||||
fun revokeBundleAt(
|
||||
linkSignerPrivKey: ByteArray,
|
||||
createdAt: Long,
|
||||
): Event = ConcordInviteBundle.buildRevocation(linkSignerPrivKey, createdAt)
|
||||
|
||||
/** Parses a shareable invite URL into its pointer + private fragment. */
|
||||
fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url)
|
||||
|
||||
@@ -454,7 +502,8 @@ object ConcordActions {
|
||||
fun recoverStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle)
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch)
|
||||
|
||||
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
|
||||
fun openBundle(
|
||||
@@ -544,6 +593,7 @@ object ConcordActions {
|
||||
recipientsXOnly: List<HexKey>,
|
||||
staffXOnly: Set<HexKey>,
|
||||
createdAt: Long,
|
||||
ownerPubKey: HexKey,
|
||||
): RefoundingBuild =
|
||||
ConcordRefounding.build(
|
||||
rotatorSigner = rotatorSigner,
|
||||
@@ -557,6 +607,7 @@ object ConcordActions {
|
||||
recipientsXOnly = recipientsXOnly,
|
||||
staffXOnly = staffXOnly,
|
||||
createdAt = createdAt,
|
||||
ownerPubKey = ownerPubKey,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
|
||||
/**
|
||||
* The **receive** half of Concord's key lifecycle, as pure functions: what a client must adopt
|
||||
* when a Grant hands it the Control Plane write key (CORD-04 §3), and how an entry is rewritten
|
||||
* when a base rotation moves the community to a new epoch (CORD-06).
|
||||
*
|
||||
* These lived only in Amethyst's `AccountConcordActions`, which meant a headless client (`amy`)
|
||||
* could hold a rank it could never write under, and could not follow a Refounding at all. The
|
||||
* logic is platform-agnostic — the only Android-shaped parts were the persistence and publish,
|
||||
* which stay with the caller. Every function here decides *what* to adopt and returns it; the
|
||||
* caller owns storing it and republishing the kind-13302 list.
|
||||
*
|
||||
* Everything fails closed: an undecryptable, mis-epoched or non-deriving delivery yields null,
|
||||
* never a partially-adopted entry.
|
||||
*/
|
||||
object ConcordReceive {
|
||||
/**
|
||||
* The `control_root` a staff-making Grant delivered to the account behind [recipientSigner],
|
||||
* or null when there is nothing to adopt (CORD-04 §3).
|
||||
*
|
||||
* Gated three ways, each of which fails closed:
|
||||
* - only a Grant **our own fold honors** can deliver, so [authority] must already seat us as
|
||||
* staff — a rogue cannot feed us a key by minting an edition nobody accepts;
|
||||
* - the wrap must open under the granter↔member pairwise key, and name [entry]'s epoch,
|
||||
* because compaction re-wraps a Grant head verbatim across Refoundings and a folded head
|
||||
* can legitimately carry a wrap minted for a prior epoch;
|
||||
* - the secret must derive to exactly the `control_pk` we already hold, or adopting it would
|
||||
* split us off from the plane's readers.
|
||||
*
|
||||
* Returns null (not an error) when the entry already holds the secret, holds no `control_pk`
|
||||
* to check against (a legacy pre-split community), or when we are not staff.
|
||||
*/
|
||||
suspend fun deliveredControlRoot(
|
||||
entry: ConcordCommunityListEntry,
|
||||
editions: List<ControlEdition>,
|
||||
authority: AuthorityResolver,
|
||||
recipientSigner: NostrSigner,
|
||||
): HexKey? {
|
||||
val heldControlPk = entry.controlPk
|
||||
if (entry.controlRoot != null || heldControlPk == null) return null
|
||||
val me = recipientSigner.pubKey.lowercase()
|
||||
if (!authority.isStaff(me)) return null
|
||||
|
||||
val myGrantCoordinate =
|
||||
ConcordKeyDerivation
|
||||
.grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray())
|
||||
.toHexKey()
|
||||
|
||||
return editions
|
||||
.filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate }
|
||||
// Newest first: a re-issued Grant (a lost key, a head superseded before we fetched it)
|
||||
// carries the fresher wrap.
|
||||
.sortedByDescending { it.version }
|
||||
.firstNotNullOfOrNull { edition ->
|
||||
val wrap = ConcordJson.decodeOrNull<GrantEntity>(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null
|
||||
val opened = ControlRootWrap.openOrNull(wrap, recipientSigner, edition.author) ?: return@firstNotNullOfOrNull null
|
||||
if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null
|
||||
if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null
|
||||
opened.controlRoot.toHexKey()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [rotator] was allowed to launch the base rotation that [entry] is being moved by
|
||||
* (CORD-06). `hasPermission`, never `effectivePermissions`: the latter ignores the banlist, so
|
||||
* a banned BAN-holder could rotate the whole community out from under it.
|
||||
*/
|
||||
fun isAuthorizedRotator(
|
||||
authority: AuthorityResolver,
|
||||
rotator: HexKey,
|
||||
): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN)
|
||||
|
||||
/**
|
||||
* The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the
|
||||
* caller can diff, persist and publish it however its platform does.
|
||||
*
|
||||
* The epoch being left is banked in `heldRoots` **with the address it was folded at**, because
|
||||
* a split epoch's Control address can never be re-derived, only remembered (CORD-02 §2) — that
|
||||
* banked address is what keeps the anti-rollback floor rebuildable. A rotation that delivered
|
||||
* no control material is a legacy pre-split one (CORD-06 §3): the new epoch folds at the legacy
|
||||
* address, and the stale prior-epoch values must NOT be carried into it. `inviteRef` survives,
|
||||
* or the *next* Refounding we are left out of becomes unrecoverable; `residue` survives, or we
|
||||
* delete another client's unknown keys on every rekey.
|
||||
*/
|
||||
fun withAdoptedRoot(
|
||||
entry: ConcordCommunityListEntry,
|
||||
newRoot: ByteArray,
|
||||
newEpoch: Long,
|
||||
newControlPk: ByteArray? = null,
|
||||
newControlRoot: ByteArray? = null,
|
||||
): ConcordCommunityListEntry =
|
||||
ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
owner = entry.owner,
|
||||
ownerSalt = entry.ownerSalt,
|
||||
root = newRoot.toHexKey(),
|
||||
rootEpoch = newEpoch,
|
||||
controlPk = newControlPk?.toHexKey(),
|
||||
controlRoot = newControlRoot?.toHexKey(),
|
||||
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch },
|
||||
privateChannels = entry.privateChannels,
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
addedAt = entry.addedAt,
|
||||
inviteRef = entry.inviteRef,
|
||||
excludedAtEpoch = entry.excludedAtEpoch,
|
||||
residue = entry.residue,
|
||||
)
|
||||
}
|
||||
Vendored
+18
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.User
|
||||
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.hints.HintIndexer
|
||||
|
||||
/**
|
||||
* Cache provider interface for accessing cached Notes, Users, and Channels.
|
||||
@@ -41,6 +42,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
* - Platform-agnostic model layer
|
||||
*/
|
||||
interface ICacheProvider {
|
||||
/**
|
||||
* NIP-hints index (event/address/pubkey → relay) accumulated from consumed
|
||||
* events. Used by the shared user/event finder assemblers to discover which
|
||||
* relays are likely to hold a given user's metadata or a missing event.
|
||||
*/
|
||||
val relayHints: HintIndexer
|
||||
|
||||
/**
|
||||
* Gets a channel by Note reference.
|
||||
* Used for resolving relay hints for channel messages.
|
||||
@@ -134,5 +142,15 @@ interface ICacheProvider {
|
||||
*/
|
||||
fun getOrCreateUser(pubkey: HexKey): User?
|
||||
|
||||
/**
|
||||
* Gets or creates a User by public key hex, swallowing any failure.
|
||||
* Used by the event-finder relay-hint scan, which touches many potentially
|
||||
* malformed pubkeys and must never throw mid-scan.
|
||||
*
|
||||
* @param key The user's public key in hex format
|
||||
* @return The User (existing or newly created), or null on failure
|
||||
*/
|
||||
fun checkGetOrCreateUser(key: HexKey): User? = runCatching { getOrCreateUser(key) }.getOrNull()
|
||||
|
||||
fun justConsumeMyOwnEvent(event: Event): Boolean
|
||||
}
|
||||
|
||||
+3
@@ -486,6 +486,9 @@ class ConcordCommunitySession(
|
||||
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return
|
||||
val who = rumor.pubKey.lowercase()
|
||||
if (who == myPubKey.lowercase()) return // never show my own typing back to me
|
||||
// A banned member's messages are dropped everywhere, so their typing heartbeat must be too —
|
||||
// otherwise they sit in the "… is typing" row forever in a channel they cannot be heard in.
|
||||
if (_state.value?.authority?.isBanned(who) == true) return
|
||||
val now = TimeUtils.now()
|
||||
// Update the map and publish inside the lock so a concurrent heartbeat on another
|
||||
// channel can't publish an older snapshot last and drop this channel's typers.
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
|
||||
/** Pure NIP-5D artifact checks shared by every launch surface. */
|
||||
object NappletArtifactPolicy {
|
||||
/** Returns the runtime-computed artifact identity, or null when the manifest must not execute. */
|
||||
fun verifiedAggregateHash(
|
||||
paths: List<PathTag>,
|
||||
declaredAggregateHash: HexKey?,
|
||||
): HexKey? {
|
||||
val entry = paths.singleOrNull() ?: return null
|
||||
if (entry.path != "/index.html" || !SHA256.matches(entry.hash)) return null
|
||||
val computed = SiteAggregateHash.compute(paths)
|
||||
if (declaredAggregateHash != null && !declaredAggregateHash.equals(computed, ignoreCase = true)) return null
|
||||
return computed
|
||||
}
|
||||
|
||||
private val SHA256 = Regex("^[0-9a-fA-F]{64}$")
|
||||
}
|
||||
+49
-26
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -122,12 +123,6 @@ class NappletBroker(
|
||||
): NappletResponse {
|
||||
val capability = request.capability
|
||||
|
||||
// shell.supports is capability negotiation: always answerable, no declaration/consent.
|
||||
if (request is NappletRequest.ShellSupports) {
|
||||
val cap = NappletCapability.fromNapDomain(request.domain)
|
||||
return NappletResponse.Supported(cap != null && cap in declared)
|
||||
}
|
||||
|
||||
if (capability !in declared) {
|
||||
return NappletResponse.Denied(capability, "This napplet did not declare the '${capability.name.lowercase()}' capability.")
|
||||
}
|
||||
@@ -149,7 +144,7 @@ class NappletBroker(
|
||||
// Keyboard/command action registration is a shell-mediated UI affordance, not key
|
||||
// access — declared is enough; it never prompts.
|
||||
request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true
|
||||
// Cosmetic/negotiation capabilities (theme) never prompt.
|
||||
// Cosmetic capabilities (theme) never prompt.
|
||||
!capability.requiresConsent -> true
|
||||
// A standing allow short-circuits, except for per-use capabilities (e.g. payments).
|
||||
ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true
|
||||
@@ -219,9 +214,6 @@ class NappletBroker(
|
||||
request: NappletRequest,
|
||||
): NappletResponse =
|
||||
when (request) {
|
||||
// Negotiation is resolved in handle(); execute() is never reached for it.
|
||||
is NappletRequest.ShellSupports -> NappletResponse.Supported(true)
|
||||
|
||||
is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey)
|
||||
|
||||
is NappletRequest.ThemeGet -> {
|
||||
@@ -267,24 +259,24 @@ class NappletBroker(
|
||||
|
||||
is NappletRequest.StorageGet -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.getItem")
|
||||
NappletResponse.StorageValue(store.get(identity.coordinate, request.key))
|
||||
NappletResponse.StorageValue(store.get(storageCoordinate(identity, request.scope), request.key))
|
||||
}
|
||||
|
||||
is NappletRequest.StorageSet -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.setItem")
|
||||
store.set(identity.coordinate, request.key, request.value)
|
||||
store.set(storageCoordinate(identity, request.scope), request.key, request.value)
|
||||
NappletResponse.Done
|
||||
}
|
||||
|
||||
is NappletRequest.StorageRemove -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.removeItem")
|
||||
store.remove(identity.coordinate, request.key)
|
||||
store.remove(storageCoordinate(identity, request.scope), request.key)
|
||||
NappletResponse.Done
|
||||
}
|
||||
|
||||
is NappletRequest.StorageKeys -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.keys")
|
||||
NappletResponse.Strings(store.keys(identity.coordinate))
|
||||
NappletResponse.Strings(store.keys(storageCoordinate(identity, request.scope)))
|
||||
}
|
||||
|
||||
is NappletRequest.NotifyCreate -> {
|
||||
@@ -316,8 +308,38 @@ class NappletBroker(
|
||||
|
||||
is NappletRequest.ResourceBytes -> {
|
||||
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes")
|
||||
val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.")
|
||||
NappletResponse.Bytes(fetched.bytes, fetched.contentType)
|
||||
when (val fetched = gateway.fetch(request.url, identity.coordinate)) {
|
||||
is NappletResourceResult.Success -> NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType)
|
||||
is NappletResourceResult.Failure -> NappletResponse.ResourceFailure(fetched.error, fetched.message)
|
||||
}
|
||||
}
|
||||
|
||||
is NappletRequest.ResourceInfo -> {
|
||||
resource ?: return NappletResponse.Unsupported("resource.info")
|
||||
NappletResponse.ResourceInfo(
|
||||
schemes = listOf("data", "https", "blossom", "nostr"),
|
||||
maxBytes = RESOURCE_MAX_BYTES,
|
||||
maxUrls = RESOURCE_MAX_URLS,
|
||||
)
|
||||
}
|
||||
|
||||
is NappletRequest.ResourceBytesMany -> {
|
||||
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytesMany")
|
||||
if (request.urls.isEmpty()) return NappletResponse.ResourceFailure("invalid-request", "Resource URL list is empty.")
|
||||
if (request.urls.size > RESOURCE_MAX_URLS) return NappletResponse.ResourceFailure("too-large", "Resource URL limit exceeded.")
|
||||
NappletResponse.ResourceItems(
|
||||
request.urls.map { url ->
|
||||
when (val fetched = gateway.fetch(url, identity.coordinate)) {
|
||||
is NappletResourceResult.Success ->
|
||||
NappletResponse.ResourceItem(
|
||||
url = url,
|
||||
resource = NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType),
|
||||
)
|
||||
is NappletResourceResult.Failure ->
|
||||
NappletResponse.ResourceItem(url = url, error = fetched.error, message = fetched.message)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is NappletRequest.UploadBlob -> {
|
||||
@@ -353,6 +375,15 @@ class NappletBroker(
|
||||
tags + arrayOf(arrayOf("p", recipient))
|
||||
}
|
||||
|
||||
private fun storageCoordinate(
|
||||
identity: NappletIdentity,
|
||||
scope: NappletStorageScope,
|
||||
): String =
|
||||
when (scope) {
|
||||
NappletStorageScope.SHARED -> identity.storageCoordinate
|
||||
NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet.
|
||||
* On success, stores the chosen policy and bulk-grants all declared non-payment capabilities.
|
||||
@@ -505,16 +536,6 @@ class NappletBroker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [capability] carries a standing denial for [identity]. Push-subscription edge ops
|
||||
* (identity.watch and friends) short-circuit before [handle], so they have to apply the same
|
||||
* "a standing denial always wins" rule themselves rather than trusting the declaration alone.
|
||||
*/
|
||||
suspend fun isDenied(
|
||||
identity: NappletIdentity,
|
||||
capability: NappletCapability,
|
||||
): Boolean = ledger.decide(identity, capability) == PermissionDecision.DENY
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long (ms) a Cancel on the first-connect dialog suppresses re-prompting for the same app.
|
||||
@@ -522,5 +543,7 @@ class NappletBroker(
|
||||
* the dialog per request; short enough that a deliberate user retry seconds later prompts again.
|
||||
*/
|
||||
private const val CANCEL_REPROMPT_COOLDOWN_MS = 3_000L
|
||||
private const val RESOURCE_MAX_BYTES = 10L * 1024L * 1024L
|
||||
private const val RESOURCE_MAX_URLS = 16
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -156,6 +156,17 @@ class NappletResource(
|
||||
val contentType: String,
|
||||
)
|
||||
|
||||
sealed interface NappletResourceResult {
|
||||
data class Success(
|
||||
val resource: NappletResource,
|
||||
) : NappletResourceResult
|
||||
|
||||
data class Failure(
|
||||
val error: String,
|
||||
val message: String? = null,
|
||||
) : NappletResourceResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the broker to sandboxed resource fetching for [NappletCapability.RESOURCE]
|
||||
* (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf —
|
||||
@@ -164,13 +175,14 @@ class NappletResource(
|
||||
*
|
||||
* [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can
|
||||
* route the fetch the same way the applet's own page loads — through Tor or the open web — per that
|
||||
* applet's/site's network mode.
|
||||
* applet's/site's network mode. Failures carry the stable NAP-RESOURCE error code rather than
|
||||
* collapsing policy rejections and network failures into one nullable result.
|
||||
*/
|
||||
fun interface NappletResourceGateway {
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource?
|
||||
): NappletResourceResult
|
||||
}
|
||||
|
||||
/** A completed upload: where the blob lives plus NIP-94-ish metadata. */
|
||||
|
||||
+14
-20
@@ -25,14 +25,11 @@ package com.vitorpamplona.amethyst.commons.napplet
|
||||
* (`napplet/naps`, `@napplet/web`). A napplet declares the domains it needs via `requires` tags;
|
||||
* [fromNapDomain] maps each bare domain string to the capability the broker enforces.
|
||||
*
|
||||
* The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must
|
||||
* surface it as unknown rather than silently granting it. Domains we don't yet broker
|
||||
* (`intent`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore resolve to `null` for now.
|
||||
* The mapping is **default-deny**: an unrecognized or only partially implemented NAP domain maps
|
||||
* to `null`. Keeping a broker implementation below does not advertise conformance; only domains
|
||||
* whose current NAP contract is implemented are injected into `window.napplet`.
|
||||
*/
|
||||
enum class NappletCapability {
|
||||
/** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */
|
||||
SHELL,
|
||||
|
||||
/** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */
|
||||
IDENTITY,
|
||||
|
||||
@@ -70,13 +67,13 @@ enum class NappletCapability {
|
||||
;
|
||||
|
||||
/**
|
||||
* Whether using this capability requires user consent. Negotiation ([SHELL]) and the cosmetic,
|
||||
* read-only theme read ([THEME]) never prompt; everything else does (subject to the broker's
|
||||
* Whether using this capability requires user consent. The cosmetic, read-only theme read
|
||||
* ([THEME]) never prompts; everything else does (subject to the broker's
|
||||
* signer-self-gating and standing-grant rules). [INC] is authorized at the router edge on its
|
||||
* declaration alone, so it never reaches the consent path regardless of this flag.
|
||||
*/
|
||||
val requiresConsent: Boolean
|
||||
get() = this != SHELL && this != THEME
|
||||
get() = this != THEME
|
||||
|
||||
/**
|
||||
* Whether the user must confirm **every single use** — no standing auto-approval. True for
|
||||
@@ -96,25 +93,22 @@ enum class NappletCapability {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Maps a bare NAP domain to the capability the broker enforces, case-insensitively.
|
||||
* Returns `null` for any domain the shell does not recognize — callers MUST treat that as
|
||||
* "unknown, do not grant".
|
||||
* Maps a bare, currently supported NAP domain to the capability the broker enforces.
|
||||
* Returns `null` for unknown and partial/legacy domains — callers MUST treat that as
|
||||
* "unavailable, do not inject or grant". NIP-5D domain names are exact lowercase strings.
|
||||
*/
|
||||
fun fromNapDomain(domain: String): NappletCapability? =
|
||||
when (domain.trim().lowercase()) {
|
||||
"shell" -> SHELL
|
||||
when (domain) {
|
||||
"identity" -> IDENTITY
|
||||
"keys" -> KEYS
|
||||
"relay", "relays" -> RELAY
|
||||
"relay" -> RELAY
|
||||
"storage" -> STORAGE
|
||||
"value" -> VALUE
|
||||
"resource" -> RESOURCE
|
||||
"upload" -> UPLOAD
|
||||
"theme" -> THEME
|
||||
"notify" -> NOTIFY
|
||||
"inc" -> INC
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Exact NIP-5D domain names Amethyst currently exposes through its injection prelude. */
|
||||
val supportedNapDomains: Set<String> = setOf("identity", "relay", "storage", "resource", "theme")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -41,7 +41,15 @@ data class NappletIdentity(
|
||||
val authorPubKey: HexKey,
|
||||
val identifier: String,
|
||||
val aggregateHash: HexKey? = null,
|
||||
/** Opaque host-assigned lifetime id used only for NAP-STORAGE's instance scope. */
|
||||
val instanceId: String? = null,
|
||||
) {
|
||||
/** The ledger key: coordinate only, never the [aggregateHash], so grants survive updates. */
|
||||
val coordinate: String = "$authorPubKey:$identifier"
|
||||
|
||||
/** NAP-STORAGE shared namespace: exact publisher + dTag + verified artifact identity. */
|
||||
val storageCoordinate: String = "$coordinate:${aggregateHash.orEmpty()}"
|
||||
|
||||
/** NAP-STORAGE instance namespace, stable for this host launch and isolated from sibling launches. */
|
||||
val instanceStorageCoordinate: String = "$storageCoordinate:instance:${instanceId.orEmpty()}"
|
||||
}
|
||||
|
||||
+69
-33
@@ -41,33 +41,19 @@ object NappletWebContract {
|
||||
const val SHELL_URL = "$ORIGIN/__shell__"
|
||||
|
||||
/**
|
||||
* The applet runs on its **own per-applet origin** — a unique subdomain of [HOST] — served at the
|
||||
* origin root, NOT on the shell [ORIGIN]. Two reasons, both load-bearing:
|
||||
*
|
||||
* 1. **A real (non-opaque) origin is what gives the applet working, persistent storage.** An
|
||||
* `allow-scripts`-only opaque-origin iframe has no `localStorage`/`IndexedDB`/service worker
|
||||
* (reads throw `SecurityError`), which crash-loops essentially every SPA. A real origin with
|
||||
* `allow-same-origin` has them, scoped and isolated per applet (subdomains don't share
|
||||
* storage), so applets can't read each other's data.
|
||||
* 2. **Keeping it on a DISTINCT origin from the shell is what preserves the trust boundary.** The
|
||||
* native bridge is origin-restricted to the shell [ORIGIN]; the applet, being cross-origin,
|
||||
* still can't reach it (nor read the shell DOM) — it talks only via `postMessage`, which the
|
||||
* shell relays. `allow-same-origin` is therefore safe here precisely because the applet is
|
||||
* same-origin only with *itself*, never with the shell.
|
||||
*
|
||||
* The applet is served at its origin root because SPA bundlers (Vite, CRA, webpack, nsyte, …) emit
|
||||
* **absolute** asset URLs (`/assets/app.js`, `/fonts/x.woff2`) that resolve against the origin root.
|
||||
*
|
||||
* [appId] must be a stable, unique, DNS-label-safe token per applet (the host derives it from the
|
||||
* applet's author + identifier), so the same applet keeps its storage across launches.
|
||||
* Per-site origin retained for Amethyst's NIP-5A WEBSITE profile. NIP-5D napplets never navigate
|
||||
* here: their verified, self-contained `/index.html` is assigned through `srcdoc` and therefore
|
||||
* executes with an opaque origin in an `allow-scripts`-only sandbox.
|
||||
*/
|
||||
fun appOrigin(appId: String): String = "https://$appId.$HOST"
|
||||
|
||||
/** True for the shell host and any per-applet subdomain — i.e. everything we serve internally. */
|
||||
fun isInternalHost(host: String?): Boolean = host == HOST || (host != null && host.endsWith(".$HOST"))
|
||||
|
||||
/** Placeholder in [SHELL_HTML_PATH] the host replaces with the per-applet [appOrigin] before serving. */
|
||||
/** Placeholders in [SHELL_HTML_PATH] replaced by the host before serving the trusted shell. */
|
||||
const val APP_ORIGIN_PLACEHOLDER = "__APP_ORIGIN__"
|
||||
const val APP_SANDBOX_PLACEHOLDER = "__APP_SANDBOX__"
|
||||
const val APP_BOOTSTRAP_PLACEHOLDER = "__APP_BOOTSTRAP__"
|
||||
|
||||
/** Name of the origin-restricted native bridge the shell (and only the shell) can reach. */
|
||||
const val BRIDGE_NAME = "__nappletBridge"
|
||||
@@ -76,24 +62,72 @@ object NappletWebContract {
|
||||
* CSP for the shell document: it may inline its own bridge script/style and frame **only this
|
||||
* applet's** origin, but has no network and cannot navigate or submit anywhere.
|
||||
*/
|
||||
fun shellCsp(appOrigin: String): String =
|
||||
fun shellCsp(frameSource: String): String =
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"frame-src $appOrigin; base-uri 'none'; form-action 'none'"
|
||||
"frame-src $frameSource; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
|
||||
/**
|
||||
* CSP for the applet document. The applet has a real origin now, so `'self'` resolves to its own
|
||||
* per-applet origin and the shell origin is deliberately NOT granted. The key lever is
|
||||
* `connect-src 'none'`: the applet gets **no** direct network — every fetch goes through the
|
||||
* brokered, consent-gated `resource.bytes`.
|
||||
* Conservative NIP-5D CSP injected as the first element of the verified napplet's `head` before
|
||||
* the runtime prelude. A `srcdoc` napplet has an opaque origin, so self-hosted subresources are
|
||||
* intentionally unavailable; a conforming napplet is one self-contained `/index.html`.
|
||||
*/
|
||||
const val APP_CSP: String =
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"media-src 'self' blob: data:; " +
|
||||
"connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"img-src data: blob:; font-src data:; connect-src 'none'; worker-src 'none'; " +
|
||||
"child-src 'none'; frame-src 'none'; media-src 'none'; object-src 'none'; " +
|
||||
"manifest-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
|
||||
/**
|
||||
* Injects host-owned policy and the runtime prelude before any authored element in `head`.
|
||||
* [locked] is the NIP-5D posture; WEBSITE callers deliberately retain Amethyst's NIP-07 and
|
||||
* normal-origin behavior. Only syntactically valid, explicitly authorized NAP domains are
|
||||
* projected onto `window.napplet` by the trusted [shimJs].
|
||||
*/
|
||||
fun injectPrelude(
|
||||
html: ByteArray,
|
||||
shimJs: String,
|
||||
declaredDomains: List<String>,
|
||||
locked: Boolean,
|
||||
injectNip07: Boolean = false,
|
||||
imeProxy: Boolean = false,
|
||||
): ByteArray {
|
||||
val text = html.decodeToString()
|
||||
val policy =
|
||||
if (locked) {
|
||||
"<meta http-equiv=\"Content-Security-Policy\" content=\"$APP_CSP\">"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
|
||||
val flags =
|
||||
"<script>window.__nappletNip07=$injectNip07;" +
|
||||
(if (imeProxy) "window.__nappletImeProxy=true;" else "") +
|
||||
"</script>"
|
||||
val safeDomains =
|
||||
declaredDomains
|
||||
.filter { it.matches(NAP_DOMAIN) && it in NappletCapability.supportedNapDomains }
|
||||
.distinct()
|
||||
val domainsJson = safeDomains.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
|
||||
val prelude = "$policy$style$flags<script>window.__nappletDomains=$domainsJson;$shimJs</script>"
|
||||
val headIdx = text.indexOf("<head", ignoreCase = true)
|
||||
val injected =
|
||||
when {
|
||||
headIdx >= 0 -> {
|
||||
val close = text.indexOf('>', headIdx)
|
||||
if (close >= 0) text.substring(0, close + 1) + prelude + text.substring(close + 1) else prelude + text
|
||||
}
|
||||
else -> {
|
||||
val htmlIdx = text.indexOf("<html", ignoreCase = true)
|
||||
val htmlClose = if (htmlIdx >= 0) text.indexOf('>', htmlIdx) else -1
|
||||
if (htmlClose >= 0) {
|
||||
text.substring(0, htmlClose + 1) + "<head>$prelude</head>" + text.substring(htmlClose + 1)
|
||||
} else {
|
||||
"<head>$prelude</head>$text"
|
||||
}
|
||||
}
|
||||
}
|
||||
return injected.encodeToByteArray()
|
||||
}
|
||||
|
||||
const val SHELL_HTML_PATH = "files/napplet/shell.html"
|
||||
const val SHIM_JS_PATH = "files/napplet/shim.js"
|
||||
@@ -114,4 +148,6 @@ object NappletWebContract {
|
||||
/** The `window.napplet` client shim a host injects into the applet document. */
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
suspend fun shimJs(): ByteArray = Res.readBytes(SHIM_JS_PATH)
|
||||
|
||||
private val NAP_DOMAIN = Regex("^[a-z][a-z0-9-]*$")
|
||||
}
|
||||
|
||||
+23
-9
@@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
enum class NappletStorageScope {
|
||||
SHARED,
|
||||
INSTANCE,
|
||||
}
|
||||
|
||||
/**
|
||||
* A capability request from a napplet, after it has crossed the postMessage + IPC edge
|
||||
* and been deserialized into a typed object. Each variant declares the [capability] the
|
||||
@@ -68,14 +73,6 @@ sealed interface NappletRequest {
|
||||
override val capability get() = NappletCapability.THEME
|
||||
}
|
||||
|
||||
/** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */
|
||||
data class ShellSupports(
|
||||
val domain: String,
|
||||
val protocol: String? = null,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.SHELL
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event built from an **unsigned template**. The napplet supplies only `kind`,
|
||||
* `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`,
|
||||
@@ -198,6 +195,7 @@ sealed interface NappletRequest {
|
||||
/** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */
|
||||
data class StorageGet(
|
||||
val key: String,
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
@@ -206,6 +204,7 @@ sealed interface NappletRequest {
|
||||
data class StorageSet(
|
||||
val key: String,
|
||||
val value: String,
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
@@ -213,12 +212,15 @@ sealed interface NappletRequest {
|
||||
/** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */
|
||||
data class StorageRemove(
|
||||
val key: String,
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
/** List the keys this napplet has stored (`storage.keys`). */
|
||||
data object StorageKeys : NappletRequest {
|
||||
data class StorageKeys(
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
@@ -282,6 +284,18 @@ sealed interface NappletRequest {
|
||||
override val capability get() = NappletCapability.RESOURCE
|
||||
}
|
||||
|
||||
/** Describe the bounded schemes and limits of this shell's existing resource broker. */
|
||||
data object ResourceInfo : NappletRequest {
|
||||
override val capability get() = NappletCapability.RESOURCE
|
||||
}
|
||||
|
||||
/** Fetch several resources in input order, returning a per-URL success/error record. */
|
||||
data class ResourceBytesMany(
|
||||
val urls: List<String>,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.RESOURCE
|
||||
}
|
||||
|
||||
/** Upload a blob to the user's Blossom server (`upload.upload`). */
|
||||
data class UploadBlob(
|
||||
val bytes: ByteArray,
|
||||
|
||||
+22
-5
@@ -51,11 +51,6 @@ sealed interface NappletResponse {
|
||||
val events: List<Event>,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of `shell.supports(domain)`. */
|
||||
data class Supported(
|
||||
val supported: Boolean,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of `keys.registerAction`: the shell-assigned [actionId] and the [binding] it honored (e.g. `"Ctrl+S"`). */
|
||||
data class ActionRegistered(
|
||||
val actionId: String,
|
||||
@@ -97,6 +92,28 @@ sealed interface NappletResponse {
|
||||
override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode()
|
||||
}
|
||||
|
||||
data class ResourceInfo(
|
||||
val schemes: List<String>,
|
||||
val maxBytes: Long,
|
||||
val maxUrls: Int,
|
||||
) : NappletResponse
|
||||
|
||||
data class ResourceItem(
|
||||
val url: String,
|
||||
val resource: Bytes? = null,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ResourceItems(
|
||||
val items: List<ResourceItem>,
|
||||
) : NappletResponse
|
||||
|
||||
data class ResourceFailure(
|
||||
val error: String,
|
||||
val message: String? = null,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of an `upload.upload`; [url] is where the blob can be fetched, plus NIP-94-ish metadata. */
|
||||
data class Uploaded(
|
||||
val url: String,
|
||||
|
||||
+6
-7
@@ -18,11 +18,9 @@
|
||||
* 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.service.relayClient.eoseManagers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
@@ -85,9 +83,10 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
|
||||
/**
|
||||
* The account behind [key], when the key is account-scoped. Null for keys about other users.
|
||||
*
|
||||
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
|
||||
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
|
||||
* other under "not attributed" despite both being built from a single account's data.
|
||||
* Account-agnostic in commons: front ends that want single-account attribution override this
|
||||
* (see the amethyst `AccountScopedSingleSubNoEoseCacheEoseManager`, which reads
|
||||
* `(key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex`). The default returns null,
|
||||
* so pooled / cross-account subscriptions are filed as "not attributed".
|
||||
*/
|
||||
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
|
||||
open fun accountPubKeyOf(key: Any?): String? = null
|
||||
}
|
||||
+12
-13
@@ -18,36 +18,35 @@
|
||||
* 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.service.relayClient.reqCommand.event
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.AddressableAuthorRelayLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.NoteEventLoaderSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.EventWatcherSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// This allows multiple screen to be listening to tags, even the same tag
|
||||
@Stable
|
||||
class EventFinderQueryState(
|
||||
val note: Note,
|
||||
override val account: Account,
|
||||
) : AccountScopedQuery
|
||||
val account: UserFinderAccount,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class EventFinderFilterAssembler(
|
||||
client: INostrClient,
|
||||
cache: LocalCache,
|
||||
cache: ICacheProvider,
|
||||
userFinder: UserFinderFilterAssembler,
|
||||
) : ComposeSubscriptionManager<EventFinderQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
NoteEventLoaderSubAssembler(client, ::allKeys),
|
||||
NoteEventLoaderSubAssembler(client, cache, ::allKeys),
|
||||
EventWatcherSubAssembler(client, ::allKeys),
|
||||
AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder),
|
||||
)
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.relayClient.event
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
|
||||
|
||||
/**
|
||||
* The shared per-note event data source (reactions / zaps / reposts / replies /
|
||||
* OTS / references) for the current front end. Provided once near the composition
|
||||
* root (Android via AppModules, Desktop via its subscriptions coordinator).
|
||||
* Reading it without a provider is a programming error — the per-note observers
|
||||
* must never be reachable from a composition that has no relay client (e.g. the
|
||||
* Android `:napplet` sandbox process).
|
||||
*
|
||||
* The *account* half is reused from the user-finder: [LocalUserFinderAccount]
|
||||
* already carries the narrow relay-hint seam the event loaders need.
|
||||
*/
|
||||
val LocalEventFinder =
|
||||
staticCompositionLocalOf<EventFinderFilterAssembler> {
|
||||
error("LocalEventFinder not provided")
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to relay updates for [note]'s interactions (reactions, zaps,
|
||||
* reposts, replies, …) for as long as this composable is in composition,
|
||||
* coalesced with every other on-screen note into batched REQs by [dataSource].
|
||||
*
|
||||
* Like the user-finder, because a `LazyColumn` composes only the visible window
|
||||
* (+ a small prefetch buffer) this means "load interactions only for notes
|
||||
* currently on screen" — [LifecycleAwareKeyDataSourceSubscription] unsubscribes
|
||||
* ~30s after the row leaves composition or the app is backgrounded.
|
||||
*/
|
||||
@Composable
|
||||
fun EventFinderFilterAssemblerSubscription(
|
||||
note: Note,
|
||||
account: UserFinderAccount,
|
||||
dataSource: EventFinderFilterAssembler,
|
||||
) {
|
||||
// Different screens get their own query-state instance even when tracking
|
||||
// the same note; the assembler dedups to one REQ per note.
|
||||
val state =
|
||||
remember(note, account) {
|
||||
EventFinderQueryState(note, account)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload that reads the front end's [LocalEventFinder] and
|
||||
* [LocalUserFinderAccount] from the composition.
|
||||
*/
|
||||
@Composable
|
||||
fun EventFinderFilterAssemblerSubscription(note: Note) {
|
||||
EventFinderFilterAssemblerSubscription(
|
||||
note = note,
|
||||
account = LocalUserFinderAccount.current,
|
||||
dataSource = LocalEventFinder.current,
|
||||
)
|
||||
}
|
||||
+15
-12
@@ -18,15 +18,17 @@
|
||||
* 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.service.relayClient.reqCommand.event.loaders
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
|
||||
@@ -41,13 +43,14 @@ import kotlinx.coroutines.IO
|
||||
* relay list arrives, [EventFinderFilterAssembler] is invalidated and can query the correct relay.
|
||||
*/
|
||||
class AddressableAuthorRelayLoaderSubAssembler(
|
||||
val cache: LocalCache,
|
||||
val cache: ICacheProvider,
|
||||
val allKeys: () -> Set<EventFinderQueryState>,
|
||||
val userFinder: UserFinderFilterAssembler,
|
||||
) : IEoseManager {
|
||||
// Private monitor: @Synchronized locks on `this`, which leaves the instance's monitor
|
||||
// reachable to anything holding a reference to this assembler.
|
||||
private val lock = Any()
|
||||
// reachable to anything holding a reference to this assembler. KmpLock (not `synchronized`)
|
||||
// because this file lives in commonMain and must compile for the iOS targets too.
|
||||
private val lock = KmpLock()
|
||||
|
||||
// Only ever touched while holding [lock]. See commit() and destroy().
|
||||
private var activeSubscriptions: Set<UserFinderQueryState> = emptySet()
|
||||
@@ -69,7 +72,7 @@ class AddressableAuthorRelayLoaderSubAssembler(
|
||||
val note = key.note
|
||||
if (note is AddressableNote && note.event == null) {
|
||||
val author = cache.getOrCreateUser(note.address.pubKeyHex)
|
||||
if (author.authorRelayList() == null) {
|
||||
if (author != null && author.authorRelayList() == null) {
|
||||
needed.add(UserFinderQueryState(author, key.account))
|
||||
}
|
||||
}
|
||||
@@ -92,7 +95,7 @@ class AddressableAuthorRelayLoaderSubAssembler(
|
||||
* never call back into this class. Revisit if that changes.
|
||||
*/
|
||||
private fun commit(needed: Set<UserFinderQueryState>) {
|
||||
synchronized(lock) {
|
||||
lock.withLock {
|
||||
if (destroyed) return
|
||||
|
||||
userFinder.subscribe((needed - activeSubscriptions).toList())
|
||||
@@ -103,7 +106,7 @@ class AddressableAuthorRelayLoaderSubAssembler(
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
synchronized(lock) {
|
||||
lock.withLock {
|
||||
destroyed = true
|
||||
bundler.cancel()
|
||||
userFinder.unsubscribe(activeSubscriptions.toList())
|
||||
+20
-14
@@ -18,33 +18,36 @@
|
||||
* 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.service.relayClient.reqCommand.event.loaders
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl> {
|
||||
fun potentialRelaysToFindAddress(
|
||||
cache: ICacheProvider,
|
||||
note: AddressableNote,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
val set = mutableSetOf<NormalizedRelayUrl>()
|
||||
|
||||
LocalCache.getOrCreateUser(note.address.pubKeyHex).outboxRelays()?.let {
|
||||
cache.getOrCreateUser(note.address.pubKeyHex)?.outboxRelays()?.let {
|
||||
set.addAll(it)
|
||||
}
|
||||
|
||||
set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex))
|
||||
set.addAll(cache.relayHints.hintsForAddress(note.idHex))
|
||||
|
||||
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
|
||||
cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
note.replyTo?.forEach { parentNote ->
|
||||
set.addAll(parentNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -52,7 +55,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
|
||||
note.replies.forEach { childNote ->
|
||||
set.addAll(childNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
|
||||
cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
childNote.author?.outboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -72,13 +75,16 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
|
||||
return set
|
||||
}
|
||||
|
||||
fun filterMissingAddressables(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
|
||||
fun filterMissingAddressables(
|
||||
cache: ICacheProvider,
|
||||
keys: List<EventFinderQueryState>,
|
||||
): List<RelayBasedFilter> {
|
||||
val addressesPerRelay =
|
||||
mapOfSet {
|
||||
keys.forEach { key ->
|
||||
val default = key.account.followPlusAllMineWithSearch.flow.value
|
||||
val default = key.account.followPlusAllMineWithSearchRelays()
|
||||
if (key.note is AddressableNote && key.note.event == null) {
|
||||
potentialRelaysToFindAddress(key.note).ifEmpty { default }.forEach { relayUrl ->
|
||||
potentialRelaysToFindAddress(cache, key.note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, key.note.address)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +92,7 @@ fun filterMissingAddressables(keys: List<EventFinderQueryState>): List<RelayBase
|
||||
// loads threading that is event-based
|
||||
key.note.replyTo?.forEach { note ->
|
||||
if (note is AddressableNote && note.event == null) {
|
||||
potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl ->
|
||||
potentialRelaysToFindAddress(cache, note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, note.address)
|
||||
}
|
||||
}
|
||||
+22
-16
@@ -18,33 +18,36 @@
|
||||
* 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.service.relayClient.reqCommand.event.loaders
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
fun potentialRelaysToFindEvent(
|
||||
cache: ICacheProvider,
|
||||
note: Note,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
val set = mutableSetOf<NormalizedRelayUrl>()
|
||||
|
||||
set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex))
|
||||
set.addAll(cache.relayHints.hintsForEvent(note.idHex))
|
||||
|
||||
note.author?.outboxRelays()?.let { set.addAll(it) }
|
||||
|
||||
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
|
||||
cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
note.replyTo?.forEach { parentNote ->
|
||||
set.addAll(parentNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -52,7 +55,7 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
note.replies.forEach { childNote ->
|
||||
set.addAll(childNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
|
||||
cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
childNote.author?.outboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -81,7 +84,7 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
val noteEvent = parent.event
|
||||
if (noteEvent is PubKeyHintProvider) {
|
||||
noteEvent.linkedPubKeys().forEach { potentialAuthor ->
|
||||
LocalCache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor ->
|
||||
cache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor ->
|
||||
potentialAuthor.outboxRelays()?.let { set.addAll(it) }
|
||||
potentialAuthor.inboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -98,18 +101,21 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
return set
|
||||
}
|
||||
|
||||
fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
|
||||
fun filterMissingEvents(
|
||||
cache: ICacheProvider,
|
||||
keys: List<EventFinderQueryState>,
|
||||
): List<RelayBasedFilter> {
|
||||
val eventsPerRelay =
|
||||
mapOfSet {
|
||||
keys.forEach { key ->
|
||||
val default = key.account.followPlusAllMineWithSearch.flow.value
|
||||
val default = key.account.followPlusAllMineWithSearchRelays()
|
||||
|
||||
if (key.note !is AddressableNote && key.note.event == null) {
|
||||
potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl ->
|
||||
potentialRelaysToFindEvent(cache, key.note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, key.note.idHex)
|
||||
}
|
||||
|
||||
key.account.searchRelayList.flow.value.forEach { relayUrl ->
|
||||
key.account.searchOnlyRelays().forEach { relayUrl ->
|
||||
add(relayUrl, key.note.idHex)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +123,7 @@ fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilte
|
||||
// loads threading that is event-based
|
||||
key.note.replyTo?.forEach { note ->
|
||||
if (note !is AddressableNote && note.event == null) {
|
||||
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
|
||||
potentialRelaysToFindEvent(cache, note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, note.idHex)
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -18,21 +18,28 @@
|
||||
* 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.service.relayClient.reqCommand.event.loaders
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
class NoteEventLoaderSubAssembler(
|
||||
client: INostrClient,
|
||||
val cache: ICacheProvider,
|
||||
allKeys: () -> Set<EventFinderQueryState>,
|
||||
) : SingleSubNoEoseCacheEoseManager<EventFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
override fun updateFilter(keys: List<EventFinderQueryState>) =
|
||||
listOfNotNull(
|
||||
filterMissingEvents(keys),
|
||||
filterMissingAddressables(keys),
|
||||
filterMissingEvents(cache, keys),
|
||||
filterMissingAddressables(cache, keys),
|
||||
).flatten()
|
||||
|
||||
override fun distinct(key: EventFinderQueryState) = key.note
|
||||
|
||||
// Attribute to the account that owns this subscription, when a single account is watching.
|
||||
// Deduped by pubkey hex, not by account identity, so two objects for the same logged-in user
|
||||
// don't look like two accounts and suppress attribution.
|
||||
override fun accountPubKeyOf(key: Any?): String? = (key as? EventFinderQueryState)?.account?.userFinderPubkeyHex
|
||||
}
|
||||
+8
-8
@@ -18,15 +18,15 @@
|
||||
* 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.service.relayClient.reqCommand.event.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.amethyst.service.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.amethyst.commons.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -67,7 +67,7 @@ class EventWatcherSubAssembler(
|
||||
// the same logged-in user would look like two accounts and suppress attribution entirely.
|
||||
val soleAccountPubKey =
|
||||
keys
|
||||
.mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
|
||||
.mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex }
|
||||
.singleOrNull()
|
||||
|
||||
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
|
||||
+3
-3
@@ -18,12 +18,12 @@
|
||||
* 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.service.relayClient.reqCommand.event.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
+3
-3
@@ -20,12 +20,12 @@
|
||||
*/
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.relayClient.user
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
fun pickRelaysToLoadUsers(
|
||||
users: Set<User>,
|
||||
relayHints: HintIndexer,
|
||||
indexRelays: Set<NormalizedRelayUrl>,
|
||||
homeRelays: Set<NormalizedRelayUrl>,
|
||||
searchRelays: Set<NormalizedRelayUrl>,
|
||||
connected: Set<NormalizedRelayUrl>,
|
||||
commonRelays: Set<NormalizedRelayUrl>,
|
||||
cannotConnectRelays: Set<NormalizedRelayUrl>,
|
||||
hasTried: EOSEAccountFast<User>,
|
||||
): Map<NormalizedRelayUrl, Set<HexKey>> =
|
||||
mapOfSet {
|
||||
users.forEachIndexed { _, key ->
|
||||
val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays
|
||||
|
||||
val outbox = key.authorRelayList()?.writeRelaysNorm()
|
||||
|
||||
if (!outbox.isNullOrEmpty()) {
|
||||
// If there is a home, get from it.
|
||||
|
||||
// if it tried all outbox relays, stop.
|
||||
// the UserWatch will take over from here.
|
||||
val leftToTry = (outbox - tried)
|
||||
leftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
// if not, tries hints first.
|
||||
val hints = key.allUsedRelays() + relayHints.hintsForKey(key.pubkeyHex)
|
||||
|
||||
val leftToTryOnHints = hints - tried
|
||||
|
||||
leftToTryOnHints.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
// if there are only a few hints, broadens the search
|
||||
if (leftToTryOnHints.size < 3) {
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val indexRelaysLeftToTry =
|
||||
(indexRelays - tried).sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val homeRelaysLeftToTry =
|
||||
(homeRelays - tried).sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}
|
||||
|
||||
// picks one at random to avoid overloading these relays
|
||||
if (users.size > 300) {
|
||||
if (indexRelaysLeftToTry.size >= 2) {
|
||||
add(indexRelaysLeftToTry[0], key.pubkeyHex)
|
||||
add(indexRelaysLeftToTry[1], key.pubkeyHex)
|
||||
} else if (indexRelaysLeftToTry.size == 1) {
|
||||
add(indexRelaysLeftToTry.first(), key.pubkeyHex)
|
||||
}
|
||||
|
||||
homeRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
indexRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
homeRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
if (indexRelaysLeftToTry.size < 2) {
|
||||
val searchRelaysLeftToTry = searchRelays - tried
|
||||
|
||||
searchRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
val connectedRelaysLeftToTry =
|
||||
(connected - tried)
|
||||
.sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}.take(100)
|
||||
|
||||
// picks one at random to avoid overloading these relays
|
||||
if (users.size > 300) {
|
||||
connectedRelaysLeftToTry.take(20).forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
connectedRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
if (searchRelaysLeftToTry.size < 2) {
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val allRelaysLeftToTry =
|
||||
(commonRelays - tried)
|
||||
.sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}.take(100)
|
||||
|
||||
allRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.amethyst.commons.relayClient.user
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
|
||||
/**
|
||||
* Narrow, read-only view of a logged-in account that the user-finder
|
||||
* subscription layer needs to route metadata / relay-list / report /
|
||||
* contact-card REQs for *other* users.
|
||||
*
|
||||
* This is deliberately NOT part of [IAccount][com.vitorpamplona.amethyst.commons.model.IAccount]:
|
||||
* `IAccount` is a behavioral capability interface for the *acting* user
|
||||
* (sending DMs, gift wraps, MLS groups). The relay hints needed to *discover
|
||||
* other users' metadata* are a separate concern, so they live on their own
|
||||
* narrow interface (ISP).
|
||||
*
|
||||
* All accessors are **snapshot getters** read fresh on every filter rebuild —
|
||||
* matching the prior direct `account.xxx.flow.value` reads. Relay-list changes
|
||||
* therefore take effect on the next subscription invalidation without any
|
||||
* captured-snapshot staleness.
|
||||
*
|
||||
* Platforms implement this on their concrete account (Android `Account`,
|
||||
* Desktop `DesktopIAccount`). Fields with no backing on a platform degrade
|
||||
* safely: Desktop has no NIP-85 trust-provider subsystem wired, so
|
||||
* [trustProvider] returns null and [declaredFollowsByOutboxRelay] returns an
|
||||
* empty map — contact-card and report discovery become best-effort there.
|
||||
*/
|
||||
interface UserFinderAccount {
|
||||
/** This account's own pubkey (hex). */
|
||||
val userFinderPubkeyHex: HexKey
|
||||
|
||||
/** Index/discovery relays, with the platform default fallback already applied. */
|
||||
fun indexRelays(): Set<NormalizedRelayUrl>
|
||||
|
||||
/** Home/write relays used for outbox discovery (nip65 + private storage + local). */
|
||||
fun outboxHomeRelays(): Set<NormalizedRelayUrl>
|
||||
|
||||
/** Search relays (trusted + own search list), for the user-finder's search-tier fallback. */
|
||||
fun searchRelays(): Set<NormalizedRelayUrl>
|
||||
|
||||
/**
|
||||
* Just this account's own NIP-51 search relay list — WITHOUT the trusted-relay
|
||||
* union that [searchRelays] adds. This is the narrow set the per-note event
|
||||
* finder fans "missing event" REQs to, matching the pre-extraction
|
||||
* `account.searchRelayList` read (reusing [searchRelays] there would have
|
||||
* unintentionally widened the fan-out to trusted relays).
|
||||
*/
|
||||
fun searchOnlyRelays(): Set<NormalizedRelayUrl>
|
||||
|
||||
/**
|
||||
* Follow + all-mine + search relays, used by the per-note event-finder to
|
||||
* place "missing event" / "missing addressable" REQs (reactions, zaps,
|
||||
* reposts, replies) when a note references content no relay has yet placed.
|
||||
* Snapshot getter, same contract as the others.
|
||||
*/
|
||||
fun followPlusAllMineWithSearchRelays(): Set<NormalizedRelayUrl>
|
||||
|
||||
/** Shared-outbox / proxy relays used as the broad common fallback. */
|
||||
fun commonRelays(): Set<NormalizedRelayUrl>
|
||||
|
||||
/** Home relays used specifically for NIP-51 contact-card (kind 30382) discovery. */
|
||||
fun cardHomeRelays(): Set<NormalizedRelayUrl>
|
||||
|
||||
/** NIP-85 trusted-assertions rank provider, or null when unsupported (e.g. Desktop). */
|
||||
fun trustProvider(): ServiceProviderTag?
|
||||
|
||||
/**
|
||||
* NIP-85 follower-count rank provider, or null when unsupported (e.g. Desktop).
|
||||
* Read by the contact-card sub-assembler alongside [trustProvider].
|
||||
*/
|
||||
fun followerCountProvider(): ServiceProviderTag?
|
||||
|
||||
/**
|
||||
* Declared follows keyed by the relay they were declared on, used to trust
|
||||
* report authors. Empty when the platform has no follow-graph-per-relay data.
|
||||
*/
|
||||
fun declaredFollowsByOutboxRelay(): Map<NormalizedRelayUrl, Set<HexKey>>
|
||||
}
|
||||
+10
-12
@@ -18,18 +18,16 @@
|
||||
* 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.service.relayClient.reqCommand.user
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.loaders.UserOutboxFinderSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserCardsSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserReportsSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserWatcherSubAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
|
||||
|
||||
@@ -37,13 +35,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
|
||||
@Stable
|
||||
class UserFinderQueryState(
|
||||
val user: User,
|
||||
override val account: Account,
|
||||
) : AccountScopedQuery
|
||||
val account: UserFinderAccount,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class UserFinderFilterAssembler(
|
||||
client: INostrClient,
|
||||
cache: LocalCache,
|
||||
cache: ICacheProvider,
|
||||
failureTracker: RelayOfflineTracker,
|
||||
) : ComposeSubscriptionManager<UserFinderQueryState>() {
|
||||
val group =
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
|
||||
/**
|
||||
* The shared per-user metadata data source for the current front end. A front
|
||||
* end provides this once near its composition root (Android via AppModules,
|
||||
* Desktop via its subscriptions coordinator). Reading it without a provider is
|
||||
* a programming error — the `observeUser*` composables must never be reachable
|
||||
* from a composition that has no relay client (e.g. the Android `:napplet`
|
||||
* sandbox process).
|
||||
*/
|
||||
val LocalUserFinder =
|
||||
staticCompositionLocalOf<UserFinderFilterAssembler> {
|
||||
error("LocalUserFinder not provided")
|
||||
}
|
||||
|
||||
/**
|
||||
* The current logged-in account, in the narrow [UserFinderAccount] view the
|
||||
* finder needs to route REQs. Provided alongside [LocalUserFinder].
|
||||
*/
|
||||
val LocalUserFinderAccount =
|
||||
staticCompositionLocalOf<UserFinderAccount> {
|
||||
error("LocalUserFinderAccount not provided")
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to relay updates for [user]'s metadata (and relay list / reports /
|
||||
* contact cards) for as long as this composable is in composition, coalesced
|
||||
* with every other on-screen user into batched REQs by [dataSource].
|
||||
*
|
||||
* Because a `LazyColumn` composes only the visible window (+ a small prefetch
|
||||
* buffer), this naturally means "load metadata only for users currently on
|
||||
* screen" — the [LifecycleAwareKeyDataSourceSubscription] unsubscribes ~30s
|
||||
* after the row leaves composition or the app is backgrounded.
|
||||
*/
|
||||
@Composable
|
||||
fun UserFinderFilterAssemblerSubscription(
|
||||
user: User,
|
||||
account: UserFinderAccount,
|
||||
dataSource: UserFinderFilterAssembler,
|
||||
) {
|
||||
// Different screens get their own query-state instance even when tracking
|
||||
// the same user; the assembler dedups to one REQ per pubkey.
|
||||
val state = remember(user, account) { UserFinderQueryState(user, account) }
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload that reads the front end's [LocalUserFinder] and
|
||||
* [LocalUserFinderAccount] from the composition.
|
||||
*/
|
||||
@Composable
|
||||
fun UserFinderFilterAssemblerSubscription(user: User) {
|
||||
UserFinderFilterAssemblerSubscription(
|
||||
user = user,
|
||||
account = LocalUserFinderAccount.current,
|
||||
dataSource = LocalUserFinder.current,
|
||||
)
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.relayClient.user
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Shared, platform-agnostic observers for a single user's metadata (kind 0).
|
||||
*
|
||||
* Each observer both (a) opens a composition-scoped relay subscription for the
|
||||
* user via [UserFinderFilterAssemblerSubscription] — so metadata is fetched only
|
||||
* while the user is on screen — and (b) collects the resulting cache flow so the
|
||||
* UI recomposes when the metadata arrives. The `(user)` overloads read the
|
||||
* front end's [LocalUserFinder] / [LocalUserFinderAccount]; the explicit-param
|
||||
* overloads are for callers that already hold both (and for tests).
|
||||
*
|
||||
* These are metadata-only. Richer per-user observers that depend on account
|
||||
* subsystems not yet in commons (contact-card petnames, follow counts,
|
||||
* bookmarks, statuses) remain in the Android layer for now and layer on top of
|
||||
* the same subscription.
|
||||
*/
|
||||
@Composable
|
||||
fun observeUserInfo(
|
||||
user: User,
|
||||
userFinder: UserFinderFilterAssembler,
|
||||
account: UserFinderAccount,
|
||||
): State<UserInfo?> {
|
||||
UserFinderFilterAssemblerSubscription(user, account, userFinder)
|
||||
return user.metadata().flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun observeUserInfo(user: User): State<UserInfo?> = observeUserInfo(user, LocalUserFinder.current, LocalUserFinderAccount.current)
|
||||
|
||||
@Composable
|
||||
fun observeUserPicture(
|
||||
user: User,
|
||||
userFinder: UserFinderFilterAssembler,
|
||||
account: UserFinderAccount,
|
||||
): State<String?> {
|
||||
UserFinderFilterAssemblerSubscription(user, account, userFinder)
|
||||
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
.metadata()
|
||||
.flow
|
||||
.map { it?.info?.picture }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(
|
||||
user
|
||||
.metadataOrNull()
|
||||
?.flow
|
||||
?.value
|
||||
?.info
|
||||
?.picture,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun observeUserPicture(user: User): State<String?> = observeUserPicture(user, LocalUserFinder.current, LocalUserFinderAccount.current)
|
||||
|
||||
@Composable
|
||||
fun observeUserBanner(
|
||||
user: User,
|
||||
userFinder: UserFinderFilterAssembler,
|
||||
account: UserFinderAccount,
|
||||
): State<String?> {
|
||||
UserFinderFilterAssemblerSubscription(user, account, userFinder)
|
||||
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
.metadata()
|
||||
.flow
|
||||
.map { it?.info?.banner }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(
|
||||
user
|
||||
.metadataOrNull()
|
||||
?.flow
|
||||
?.value
|
||||
?.info
|
||||
?.banner,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun observeUserBanner(user: User): State<String?> = observeUserBanner(user, LocalUserFinder.current, LocalUserFinderAccount.current)
|
||||
|
||||
@Composable
|
||||
fun observeUserAboutMe(
|
||||
user: User,
|
||||
userFinder: UserFinderFilterAssembler,
|
||||
account: UserFinderAccount,
|
||||
): State<String> {
|
||||
UserFinderFilterAssemblerSubscription(user, account, userFinder)
|
||||
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
.metadata()
|
||||
.flow
|
||||
.map { it?.info?.about ?: "" }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(
|
||||
user
|
||||
.metadataOrNull()
|
||||
?.flow
|
||||
?.value
|
||||
?.info
|
||||
?.about ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun observeUserAboutMe(user: User): State<String> = observeUserAboutMe(user, LocalUserFinder.current, LocalUserFinderAccount.current)
|
||||
|
||||
/**
|
||||
* The user's best available display name from their own metadata (kind 0),
|
||||
* falling back to a truncated pubkey. Metadata-only: it does NOT apply the
|
||||
* viewing account's private contact-card petname (that stays in the Android
|
||||
* layer, which wraps this).
|
||||
*/
|
||||
@Composable
|
||||
fun observeUserName(
|
||||
user: User,
|
||||
userFinder: UserFinderFilterAssembler,
|
||||
account: UserFinderAccount,
|
||||
): State<String> {
|
||||
UserFinderFilterAssemblerSubscription(user, account, userFinder)
|
||||
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
.metadata()
|
||||
.flow
|
||||
.map { it?.info?.bestName() ?: user.toBestDisplayName() }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(user.toBestDisplayName())
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun observeUserName(user: User): State<String> = observeUserName(user, LocalUserFinder.current, LocalUserFinderAccount.current)
|
||||
+30
-12
@@ -18,18 +18,18 @@
|
||||
* 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.service.relayClient.reqCommand.user.loaders
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers
|
||||
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class UserOutboxFinderSubAssembler(
|
||||
client: INostrClient,
|
||||
val cache: LocalCache,
|
||||
val cache: ICacheProvider,
|
||||
val failureTracker: RelayOfflineTracker,
|
||||
allKeys: () -> Set<UserFinderQueryState>,
|
||||
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
|
||||
@@ -108,19 +108,37 @@ class UserOutboxFinderSubAssembler(
|
||||
// and the users being resolved are whoever is on screen rather than anyone's follow list — so
|
||||
// with several accounts active there is no single honest owner for a given filter, and
|
||||
// splitting the sweep per account would re-issue the same lookups once per account.
|
||||
// Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for
|
||||
// the same logged-in user would look like two accounts and suppress attribution entirely.
|
||||
// Deduped by pubkey, not by account identity: two objects for the same logged-in user would
|
||||
// look like two accounts and suppress attribution entirely.
|
||||
val soleAccountPubKey =
|
||||
accounts
|
||||
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
|
||||
.mapTo(mutableSetOf()) { it.userFinderPubkeyHex }
|
||||
.singleOrNull()
|
||||
|
||||
// Union of every asking account's relay tiers. The UserFinderAccount getters already apply the
|
||||
// platform default fallbacks (index/search), matching the prior outer pickRelaysToLoadUsers.
|
||||
val cannotConnect = failureTracker.cannotConnectRelays
|
||||
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
val homeRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
val searchRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
val commonRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
accounts.forEach { account ->
|
||||
indexRelays.addAll(account.indexRelays())
|
||||
homeRelays.addAll(account.outboxHomeRelays())
|
||||
searchRelays.addAll(account.searchRelays())
|
||||
commonRelays.addAll(account.commonRelays())
|
||||
}
|
||||
|
||||
val perRelayKeysBoth =
|
||||
pickRelaysToLoadUsers(
|
||||
noOutboxList,
|
||||
accounts,
|
||||
cache.relayHints,
|
||||
indexRelays - cannotConnect,
|
||||
homeRelays - cannotConnect,
|
||||
searchRelays - cannotConnect,
|
||||
connectedRelays,
|
||||
failureTracker.cannotConnectRelays,
|
||||
commonRelays - cannotConnect,
|
||||
cannotConnect,
|
||||
hasTried,
|
||||
)
|
||||
|
||||
+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.amethyst.service.relayClient.reqCommand.user.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
+6
-5
@@ -18,16 +18,16 @@
|
||||
* 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.service.relayClient.reqCommand.user.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
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
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -60,6 +60,7 @@ val UserMetadataForKeyKinds =
|
||||
|
||||
fun filterUserMetadataForKey(
|
||||
authors: Set<User>,
|
||||
relayHints: HintIndexer,
|
||||
indexRelays: Set<NormalizedRelayUrl>,
|
||||
cannotConnectRelays: Set<NormalizedRelayUrl>,
|
||||
since: EOSEAccountFast<User>,
|
||||
@@ -72,7 +73,7 @@ fun filterUserMetadataForKey(
|
||||
val relays =
|
||||
when {
|
||||
outbox == null ->
|
||||
key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays
|
||||
key.allUsedRelays() + relayHints.hintsForKey(key.pubkeyHex) + indexRelays
|
||||
// Outbox is published but exhausted (every relay either EOSE'd
|
||||
// or is known-unreachable) and metadata is still missing —
|
||||
// widen to indexers so a misconfigured outbox doesn't strand
|
||||
+12
-12
@@ -18,16 +18,16 @@
|
||||
* 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.service.relayClient.reqCommand.user.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.toHexSet
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
class UserCardsSubAssembler(
|
||||
client: INostrClient,
|
||||
val cache: LocalCache,
|
||||
val cache: ICacheProvider,
|
||||
allKeys: () -> Set<UserFinderQueryState>,
|
||||
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
|
||||
override fun newEose(
|
||||
@@ -74,22 +74,22 @@ class UserCardsSubAssembler(
|
||||
// accounts, so with several active none of them owns a given filter.
|
||||
val soleAccountPubKey =
|
||||
accounts
|
||||
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
|
||||
.mapTo(mutableSetOf()) { it.userFinderPubkeyHex }
|
||||
.singleOrNull()
|
||||
|
||||
val trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>> =
|
||||
mapOfSet {
|
||||
accounts.forEach { account ->
|
||||
account.homeRelays.flow.value.forEach {
|
||||
add(it, account.userProfile().pubkeyHex)
|
||||
account.cardHomeRelays().forEach {
|
||||
add(it, account.userFinderPubkeyHex)
|
||||
}
|
||||
}
|
||||
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider ->
|
||||
accounts.map { it.trustProvider() }.forEach { provider ->
|
||||
if (provider != null) {
|
||||
add(provider.relayUrl, provider.pubkey)
|
||||
}
|
||||
}
|
||||
accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider ->
|
||||
accounts.map { it.followerCountProvider() }.forEach { provider ->
|
||||
if (provider != null) {
|
||||
add(provider.relayUrl, provider.pubkey)
|
||||
}
|
||||
+12
-11
@@ -18,16 +18,16 @@
|
||||
* 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.service.relayClient.reqCommand.user.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.toHexSet
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relays.MutableTime
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
class UserReportsSubAssembler(
|
||||
client: INostrClient,
|
||||
val cache: LocalCache,
|
||||
val cache: ICacheProvider,
|
||||
allKeys: () -> Set<UserFinderQueryState>,
|
||||
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
|
||||
override fun newEose(
|
||||
@@ -72,12 +72,13 @@ class UserReportsSubAssembler(
|
||||
}
|
||||
|
||||
private fun filtersFor(
|
||||
account: Account,
|
||||
account: UserFinderAccount,
|
||||
lastUsersOnFilter: Set<User>,
|
||||
): List<RelayBasedFilter> {
|
||||
val accountPubKey = account.userProfile().pubkeyHex
|
||||
val accountPubKey = account.userFinderPubkeyHex
|
||||
|
||||
return account.declaredFollowsPerOutboxRelay.value
|
||||
return account
|
||||
.declaredFollowsByOutboxRelay()
|
||||
.flatMap { (relay, trustedUsersInThisRelay) ->
|
||||
// this relay + accounts are where we could find reports.
|
||||
// we might have already loaded them, so let's separate new targets that were checked before from the others
|
||||
+9
-12
@@ -18,14 +18,13 @@
|
||||
* 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.service.relayClient.reqCommand.user.watchers
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
|
||||
@@ -37,7 +36,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class UserWatcherSubAssembler(
|
||||
client: INostrClient,
|
||||
val cache: LocalCache,
|
||||
val cache: ICacheProvider,
|
||||
val failureTracker: RelayOfflineTracker,
|
||||
allKeys: () -> Set<UserFinderQueryState>,
|
||||
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
|
||||
@@ -100,20 +99,18 @@ class UserWatcherSubAssembler(
|
||||
// account and the users are whoever is on screen, so with several askers none of them owns it.
|
||||
val soleAccountPubKey =
|
||||
keys
|
||||
.mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
|
||||
.mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex }
|
||||
.singleOrNull()
|
||||
|
||||
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
keys.mapTo(mutableSetOf()) { it.account }.forEach {
|
||||
indexRelays.addAll(
|
||||
it.indexerRelayList.flow.value
|
||||
.ifEmpty { DefaultIndexerRelayList },
|
||||
)
|
||||
indexRelays.addAll(it.indexRelays())
|
||||
}
|
||||
|
||||
val newFilters =
|
||||
filterUserMetadataForKey(
|
||||
users,
|
||||
cache.relayHints,
|
||||
indexRelays,
|
||||
failureTracker.cannotConnectRelays,
|
||||
latestEOSEs,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user