Merge remote-tracking branch 'origin/main' into claude/sharp-keller-7itny0

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt
This commit is contained in:
Claude
2026-09-23 18:24:14 +00:00
103 changed files with 8988 additions and 822 deletions
@@ -0,0 +1,154 @@
# Replaceable-event backup conflicts
## Problem
`AccountSettings` keeps an on-disk backup of ~26 of the account's replaceable events
(profile, follow list, mute list, relay lists, NIP-51 lists, wallet events, app settings).
Each `*State` collector copies whatever is newest in `LocalCache` into that backup. When
another client rewrites one of these events without reading the previous version (a fresh
follow list, a mute list without the private items, a profile missing fields), the newer
event wins on relays, lands in `LocalCache`, and the backup is overwritten with it: the
only surviving copy of the user's data is gone.
## Design
1. **Know what we signed.** `LocallySignedEvents` (commons, `model/backups/`) records the
ids of replaceable/addressable events passing through `justConsumeMyOwnEvent`, the
choke point for every local publish and for the backup restore at startup. It is a
bounded LRU (500 ids): only the latest versions of each list matter.
2. **Events diff themselves.** Every backed-up event implements
`DiffableEvent<D>` (quartz, `nip01Core/diff/`): `diffFrom(older)` returns its own diff
class built from its own parsed objects, e.g. `ContactListDiff(follows:
ListDiff<ContactTag>)`, `MuteListDiff(publicMutes: ListDiff<MuteTag>, privateItems)`,
`AdvertisedRelayListDiff(relays: ListDiff<AdvertisedRelayInfo>)`, `NutzapInfoDiff(mints:
ListDiff<NutzapMintTag>, relays, p2pkPubkey: ValueChange<HexKey>)`, `MetadataDiff` with
one `ValueChange` per `UserMetadata` field plus NIP-39 claims and unmodeled JSON fields.
The relay-tag lists (DM, key package, search, indexer, feeds, blocked, trusted, private
outbox) share `RelayListDiff`; encrypted-only events (Cashu wallet, Concord list, NIP-78
data) diff their content as a whole.
Tags an event's parser doesn't read never reach its diff, so bookkeeping other clients
rewrite on every save (`client`, NIP-13 `nonce`, NIP-40 `expiration`) is ignored by
construction; `EventDiffTest.clientPowAndExpirationTagsAreIgnored` pins it.
`nip01Core/diff/` only holds the containers: `EventDiff` (`removesData()`),
`ListDiff<T>` (items matched by an identity key; a matched pair with different details
is a change, not a removal plus an addition), `ValueChange<T>`, and `ContentChange` for
NIP-44 private items, which can't be compared item by item without decrypting.
3. **Only question lossy external rewrites.** `BackupConflictGuard` (commons,
`model/backups/`, tested in `BackupConflictGuardTest`) guards every `update*` backup
method of `AccountSettings`, which owns one as `backupGuard`. A newer version that was not signed here goes through
`ReplaceableBackupDiff.detectLoss` (commons), which asks the event for its diff and
keeps it only when `removesData()`.
If nothing was dropped, the other app evidently built on the previous version, and the
backup is updated silently as before.
Wiped versions (no tags, empty app data, a mints-less nutzap info) reach the guard too,
so wiping everything is questioned like any other loss. An empty version only becomes
the backup when this app signed it or the user kept it. The guard runs under a lock
shared with the resolutions, and a version older than an open conflict's incoming can
never replace or clear it (e.g. the retry of an accept that a newer change overtook).
4. **Freeze and ask.** On a loss, the backup keeps the saved version and a
`ReplaceableBackupConflict` is published on `BackupConflictGuard.conflicts`.
Home shows a card per open conflict as the first item of each tab's feed
(`BackupConflictCards`), so they scroll away with the posts instead of covering them;
an empty, loading or failed feed shows them above its placeholder: "You changed your mute list in another app" with a
"12 removed · 3 added" summary. Cards can't be dismissed; they stay until the user
decides. The most recent conflict gets a lead card with an event-specific headline
("You unblocked 3 relays", "You unfollowed 12 people" with its bar); the others collapse
into slim pills. List headlines lead with what was removed and add
what was added to the same list, worded per list ("You unfollowed 12 people and
followed 40", "You left 2 groups and joined 1", "You unblocked 3 relays and blocked 1").
All copy assumes the change was on purpose: the user did it in another client, and
Amethyst is only confirming before its backup moves on. So it says what "you" did, in
the past tense ("You left 1 public chat", not "You'd leave"), and never calls it a loss.
Tapping one opens `Route.BackupConflictReview(slot)`
(`BackupConflictReviewScreen`): a `LazyColumn` of every entry, so lists with hundreds of
items scroll lazily. Entries are typed (`ReviewItem`, built per diff class in
`BackupConflictPresentation.kt`) and rendered with the app's loaders, which subscribe to
relays and recompose when data arrives: people (`LoadUser` + `UsernameDisplay`, tap →
profile), muted threads (`LoadNote` + `NoteCompose`), communities and feeds
(`LoadAddressableNote` + `NoteCompose`), public chats (`observeChannel`, tap → chat),
ephemeral rooms (tap → room) and relays (tap → relay info). Leaving the screen without
deciding keeps the card and the frozen backup.
The list is laid out per event (`BackupConflictEventViews.kt`, `eventDiffItems`), each
leading with one picture of the change (`BackupConflictVisuals.kt`; red lost, green
gained, amber changed, all from the theme): the follow list shows saved → new counts, a
kept/dropped/new split bar, Dropped/New/Edited tabs and a searchable grid of faces; the
mute list a shield with the unmuted count, per-kind tiles, a face pile, struck-through
words and the private-items card; the profile both versions side by side as mini
cards, then inline field diffs with images as thumbnails; NIP-65 outbox and inbox
lanes; nutzap info a key swap
drawn as color fingerprints with a warning, then mints; groups a tile per group, faded
when you left it. The other lists (`BackupConflictListViews.kt`) get their own too:
each relay list as per-relay rows tagged by what the list is for (DM, key-package,
search, indexer, relay-feed, private-outbox and trusted relays; no "N of M" summary card,
since the change is the tagged rows, not a reach number); blocked relays
as an "N unblocked" warning with allowed / newly blocked / still blocked), public
chats, communities, favorite feeds and ephemeral rooms as tiles loaded from relays and
faded when you left them, hashtags and places as a pill cloud (places as "City · #geohash" through `LoadCityName`, each pill opening its feed), trust providers as a
per-service before/after table, payment targets and BOLT12 offers as cards, and the
encrypted-only Cashu wallet and Concord list as one explained panel. Only NIP-78 app
settings still uses the generic removed / added / changed sections.
Each card has an Accept button that keeps the new version right from Home (the change
was made on purpose; reverting needs the review screen, a tap on the card away). The
review screen is titled with the event and what happened to it ("Community list
updated", "Mute list cleared").
Buttons are worded per event ("Keep 120" / "Restore 523", "Re-mute 37", "Rejoin 3").
Keeping the new version is the default (the filled button, on the right); restoring
always reverts another app's change, so it is an outlined red button on every screen.
The screen offers:
- **Restore saved version** — `Account.restoreBackupOver` re-signs the saved kind, tags
and content (NIP-44 self-encrypted items stay valid) with
`created_at = max(now, incoming + 1)` (capped at 15 minutes ahead; a far-future
external version is answered at "now"), dropping the old `client` tag (so the signer
adds the current one), the `nonce` (its PoW was for the old id) and an already-past
`expiration` (`BackupRestore.tagsToResign`), and publishes it through
`EventBroadcaster.sendRestoredVersion`: where a normal save of that kind goes, and for
the user's own relay lists (NIP-65, DM, key package) and nutzap info also to every
relay the restored list names, since the lossy version may have dropped them from the
outbox set; profiles, like their normal saves, go everywhere.
- **Use new version** — `Account.acceptExternalVersion` whitelists that id and re-runs
the original update, so the backup moves forward.
Both resolutions first *claim* the conflict: they act only if it is still the open
conflict of its slot, so a stale screen, a double tap, or a conflict dropped because the
wallet/nutzap info was deleted does nothing. While a restore is signing, its incoming
version can't re-raise the conflict; a failed restore reopens it. The same incoming
version re-emitted by the cache is ignored without re-diffing.
Public items removed while the private section went from empty to filled are treated as
made private, not lost (`ContentChange.publicRemovalsAreLoss`).
While a conflict is open, edits made in Amethyst are compared against the frozen
saved version too (they are built on top of the external one), so they cannot
silently clear the conflict. They move the conflict's `incoming` forward but keep the
external version as its `cause` (the screen shows that date).
Open conflicts are persisted with the backups (`BackupConflictStorage`, as the saved,
incoming and cause events) and re-seeded on load through `BackupConflictGuard.restore`. The
diff is recomputed, so a version the other app has since fixed stops being a conflict. A
restored conflict has no retry closure until its version arrives again; keeping it rebuilds
the update from the event's type (`AccountSettings.reapplyBackup`), except NIP-78, which
lands on the next delivery.
## Known limits / follow-ups
- Private (encrypted) list items are only compared as "all gone"; a rewrite that drops
*some* private items isn't detected. Decrypting both sides would fix it but needs the
signer (an Amber round-trip), so it's left out of the passive path.
- The ids of locally signed events are in memory only. If the app dies inside the 1s
settings-save debounce right after a local removal, the next launch may ask once about
our own event. Harmless (either answer keeps the data the user chose).
- Edits from the same user on another Amethyst install count as "external" when they
remove items. That's intended: the device can't tell them apart from a careless app.
- Desktop marks its own events too but has no backup store or conflict UI yet.
- Moving public items into a private section that already existed is still questioned: the
private section re-encrypts on every save, so it only reads as "changed", and telling
"made private" from "deleted" would need decrypting it.
- A version dated more than 15 minutes in the future can't be outranked safely, so Restore
refuses it (the conflict reopens) and only keeping it works.
- Read-only (npub) accounts can't re-sign the saved version, so their review offers only
keeping the new one.
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
@@ -458,13 +459,14 @@ class AppModules(
scope = applicationIOScope,
dns = surgeDns,
// Transparently rewrites sha256-keyed HTTP requests to the local
// Blossom cache when the master toggle is on, the profile-pictures-only
// restriction is off, and the probe sees 127.0.0.1:24242 as available.
shouldBridgeBlossomCache = {
// Blossom cache when the master toggle is on, the probe sees
// 127.0.0.1:24242 as available, and either the profile-pictures-only
// restriction is off or the request is a profile picture.
shouldBridgeBlossomCache = { profilePicture ->
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly && localBlossomCacheProbe.available.value
master && (profilePicture || !profileOnly) && localBlossomCacheProbe.available.value
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
@@ -477,6 +479,7 @@ class AppModules(
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
onAuthRequired = blossomReadAuthTokens::warm,
),
onLocalBlossomCacheUnreachable = { localBlossomCacheProbe.markUnavailable() },
)
// Offers easy methods to know when connections are happening through Tor or not
@@ -1035,14 +1038,16 @@ class AppModules(
}
},
httpClientBuilder = roleBasedHttpClientBuilder,
// Same gate as the interceptor: the profile-pictures-only restriction keeps note
// media (including native `blossom:` URIs) off the local cache.
useLocalBlossomCache = {
sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value ?: false
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly
},
localCacheProbe = localBlossomCacheProbe,
scope = applicationIOScope,
)
}
@@ -1148,7 +1153,8 @@ class AppModules(
blossomServerResolver = { blossomResolver },
// Through the role builder (not raw getHttpClient) so Coil's image
// traffic carries the "image" ledger tag. Same Tor decision inside.
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
// Marked as media so the local Blossom cache may serve these downloads.
callFactory = { LocalBlossomMediaCallFactory(roleBasedHttpClientBuilder.okHttpClientForImage(it)) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
readAuth = blossomReadAuthTokens,
@@ -1341,8 +1347,7 @@ class AppModules(
state.account.settings.localBlossomCacheProfilePicturesOnly
.drop(1),
).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
blossomResolver.clearCaches()
localBlossomCacheProbe.invalidate()
// Re-probe immediately so enabling the feature activates it
// this session. Otherwise `available` only advances when a
@@ -1358,8 +1363,14 @@ class AppModules(
}
applicationIOScope.launch {
localBlossomCacheProbe.available.drop(1).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
blossomResolver.clearCaches()
}
}
// A resolution to the local cache made while a media type was not Tor-routed must not
// survive the user switching it to Tor: the cache would keep fetching it outside Tor.
applicationIOScope.launch {
torPrefs.value.propertyWatchFlow.drop(1).collect {
blossomResolver.clearCaches()
}
}
// Warm the local-cache probe so the very first image load doesn't pay
@@ -1367,6 +1378,22 @@ class AppModules(
applicationIOScope.launch {
localBlossomCacheProbe.isAvailable()
}
// Re-checks the local cache about once a minute while the feature is on, so the bridge
// comes back when the cache app is restarted and turns off when it is closed, even while
// no `blossom:` URI is being resolved. A loopback HEAD never wakes the radio.
applicationIOScope.launch {
while (true) {
delay(LOCAL_BLOSSOM_CACHE_RECHECK_MS)
if (sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value == true
) {
localBlossomCacheProbe.isAvailable()
}
}
}
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
@@ -1505,5 +1532,8 @@ class AppModules(
* check and burn CPU on a heap that has nothing left to give.
*/
private const val MIN_RECLAIM_INTERVAL_MS = 120_000L
/** How often the local Blossom cache is re-probed while the feature is enabled. */
private const val LOCAL_BLOSSOM_CACHE_RECHECK_MS = 60_000L
}
}
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
@@ -159,6 +160,7 @@ private object PrefKeys {
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
const val OPEN_BACKUP_CONFLICTS = "openBackupConflicts"
const val LATEST_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList"
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
@@ -582,6 +584,16 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
// The undecided conflicts themselves, not just the backups they hold back.
// Without these the card vanishes on the next launch and the user never
// answers the question the backup is still waiting on.
val openConflicts = settings.openBackupConflicts()
if (openConflicts.isEmpty()) {
remove(PrefKeys.OPEN_BACKUP_CONFLICTS)
} else {
putString(PrefKeys.OPEN_BACKUP_CONFLICTS, BackupConflictStorage.encode(openConflicts))
}
putOrRemove(PrefKeys.LATEST_USER_METADATA, settings.backupUserMetadata)
putOrRemove(PrefKeys.LATEST_DM_RELAY_LIST, settings.backupDMRelayList)
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
@@ -808,6 +820,7 @@ object LocalPreferences {
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null)
val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null)
val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null)
val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null)
@@ -1064,7 +1077,7 @@ object LocalPreferences {
backupCashuWallet = latestCashuWalletResolved,
backupNutzapInfo = latestNutzapInfoResolved,
callsEnabled = MutableStateFlow(callsEnabled),
)
).also { it.restoreBackupConflicts(BackupConflictStorage.decode(openBackupConflictsStr)) }
}
}
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
import com.vitorpamplona.amethyst.commons.model.backups.BackupRestore
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
@@ -1889,6 +1891,40 @@ class Account(
fun sendLiterallyEverywhere(event: Event) = broadcaster.sendLiterallyEverywhere(event)
/**
* Resolves a [ReplaceableBackupConflict] in favor of this device: re-signs the saved
* version (same kind, tags and content, so NIP-44 private items stay readable, minus the
* old client, proof-of-work and past expiration tags; see [BackupRestore.tagsToResign])
* with a timestamp newer than the current one, and publishes it so it replaces that version.
*
* Does nothing if the conflict is stale (already resolved, replaced by a newer one, or
* dropped because the event was deleted), which also makes a double tap harmless.
*/
suspend fun restoreBackupOver(conflict: ReplaceableBackupConflict) {
val token = settings.backupGuard.startRestoring(conflict) ?: return
var restored = false
try {
val saved = conflict.saved
val now = TimeUtils.now()
// Newer than the version it replaces, but never far in the future: relays reject
// those, and later edits signed at "now" would lose to it. A far-future external
// version can't be outranked safely, and a restore signed at "now" would lose to it
// everywhere, so it isn't sent: the conflict reopens and only keeping it works.
val createdAt = (conflict.incoming.createdAt + 1).coerceAtLeast(now)
if (createdAt > now + maxRestoreFutureSeconds) return
val resigned = signer.sign<Event>(createdAt, saved.kind, BackupRestore.tagsToResign(saved, now), saved.content)
broadcaster.sendRestoredVersion(resigned)
restored = true
} finally {
settings.backupGuard.finishRestoring(conflict, token, restored)
}
}
private val maxRestoreFutureSeconds = 15 * 60L
/** Resolves a [ReplaceableBackupConflict] in favor of the current version. No-op when stale. */
fun acceptExternalVersion(conflict: ReplaceableBackupConflict) = settings.backupGuard.keepIncoming(conflict)
suspend fun <T : Event> signAndSendPrivately(
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.cashu.UnavailableCashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.backups.BackupConflictGuard
import com.vitorpamplona.amethyst.commons.model.backups.backupSlotOf
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
@@ -51,6 +53,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEve
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -1041,6 +1044,69 @@ class AccountSettings(
// Backup Lists
// ----
/**
* Holds back external rewrites that dropped data from a backup, see [BackupConflictGuard].
* Home shows a card for each open conflict until the user keeps the new version or
* restores the saved one. Open conflicts are saved with the backups they hold back.
*/
val backupGuard =
BackupConflictGuard(
onChange = { saveAccountSettings() },
reapply = { reapplyBackup(it) },
)
val backupConflicts get() = backupGuard.conflicts
/** Re-seeds the open conflicts read back from storage, see [BackupConflictGuard.restore]. */
fun restoreBackupConflicts(saved: List<Triple<Event, Event, Event>>) = backupGuard.restore(saved)
/** The open conflicts, as the three events storage needs to rebuild each one. */
fun openBackupConflicts(): List<Triple<Event, Event, Event>> = backupGuard.open()
/**
* Re-runs the update that a restored conflict no longer carries.
*
* The guard's retries are closures, so they cannot be written to disk; a conflict read
* back after a restart has none until its version happens to arrive again. Keeping the new
* version must work regardless, and every update is the same shape — hand the event to the
* function that owns its slot — so the slot can be recovered from the event's own type.
*/
private fun reapplyBackup(incoming: Event) {
when (incoming) {
is MetadataEvent -> updateUserMetadata(incoming)
is ContactListEvent -> updateContactListTo(incoming)
is ChatMessageRelayListEvent -> updateDMRelayList(incoming)
is KeyPackageRelayListEvent -> updateKeyPackageRelayList(incoming)
is AdvertisedRelayListEvent -> updateNIP65RelayList(incoming)
is CashuWalletEvent -> updateCashuWallet(incoming)
is NutzapInfoEvent -> updateNutzapInfo(incoming)
is PaymentTargetsEvent -> updateNIPA3PaymentTargets(incoming)
is Bolt12OfferListEvent -> updateBolt12Offers(incoming)
is SearchRelayListEvent -> updateSearchRelayList(incoming)
is IndexerRelayListEvent -> updateIndexRelayList(incoming)
is RelayFeedsListEvent -> updateRelayFeedList(incoming)
is BlockedRelayListEvent -> updateBlockedRelayList(incoming)
is TrustedRelayListEvent -> updateTrustedRelayList(incoming)
is PrivateOutboxRelayListEvent -> updatePrivateHomeRelayList(incoming)
is ChannelListEvent -> updateChannelListTo(incoming)
is GeohashListEvent -> updateGeohashListTo(incoming)
is HashtagListEvent -> updateHashtagListTo(incoming)
is FavoriteAlgoFeedsListEvent -> updateFavoriteAlgoFeedsListTo(incoming)
is CommunityListEvent -> updateCommunityListTo(incoming)
is EphemeralChatListEvent -> updateEphemeralChatListTo(incoming)
is SimpleGroupListEvent -> updateRelayGroupListTo(incoming)
is ConcordCommunityListEvent -> updateConcordListTo(incoming)
is TrustProviderListEvent -> updateTrustProviderListTo(incoming)
is MuteListEvent -> updateMuteList(incoming)
// NIP-78 only raises a conflict when its blob was emptied, and an empty blob is
// stored without merging the settings handed with it, so defaults are safe here.
// A non-empty one needs the decrypted settings only the caller has; it lands when
// the event next arrives, already recorded as accepted.
is AppSpecificDataEvent -> if (incoming.content.isBlank()) updateAppSpecificData(incoming, AccountSyncedSettingsInternal())
else -> Unit
}
}
fun updateLocalRelayServers(servers: Set<String>) {
if (localRelayServers.value != servers) {
localRelayServers.update { servers }
@@ -1059,48 +1125,43 @@ class AccountSettings(
fun updateUserMetadata(newMetadata: MetadataEvent?) {
if (newMetadata == null) return
// Events might be different objects, we have to compare their ids.
if (backupUserMetadata?.id != newMetadata.id) {
if (backupGuard.accept(backupUserMetadata, newMetadata) { updateUserMetadata(newMetadata) }) {
backupUserMetadata = newMetadata
saveAccountSettings()
}
}
fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList == null || newContactList.tags.isEmpty()) return
if (newContactList == null) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList.id) {
if (backupGuard.accept(backupContactList, newContactList, isEmpty = newContactList.tags.isEmpty()) { updateContactListTo(newContactList) }) {
backupContactList = newContactList
saveAccountSettings()
}
}
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
if (newDMRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupDMRelayList?.id != newDMRelayList.id) {
if (backupGuard.accept(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) {
backupDMRelayList = newDMRelayList
saveAccountSettings()
}
}
fun updateKeyPackageRelayList(newKeyPackageRelayList: KeyPackageRelayListEvent?) {
if (newKeyPackageRelayList == null || newKeyPackageRelayList.tags.isEmpty()) return
if (newKeyPackageRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupKeyPackageRelayList?.id != newKeyPackageRelayList.id) {
if (backupGuard.accept(backupKeyPackageRelayList, newKeyPackageRelayList, isEmpty = newKeyPackageRelayList.tags.isEmpty()) { updateKeyPackageRelayList(newKeyPackageRelayList) }) {
backupKeyPackageRelayList = newKeyPackageRelayList
saveAccountSettings()
}
}
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
if (newNIP65RelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
if (backupGuard.accept(backupNIP65RelayList, newNIP65RelayList, isEmpty = newNIP65RelayList.tags.isEmpty()) { updateNIP65RelayList(newNIP65RelayList) }) {
backupNIP65RelayList = newNIP65RelayList
saveAccountSettings()
}
@@ -1109,14 +1170,17 @@ class AccountSettings(
fun updateCashuWallet(newWallet: CashuWalletEvent?) {
if (newWallet == null) return
// Replaceable: keep the latest by id (id changes on each re-sign).
if (backupCashuWallet?.id != newWallet.id) {
if (backupGuard.accept(backupCashuWallet, newWallet) { updateCashuWallet(newWallet) }) {
backupCashuWallet = newWallet
saveAccountSettings()
}
}
fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) {
if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return
if (newNutzapInfo == null) return
// The guard runs first, so another app wiping the nutzap info is questioned like any
// other lossy rewrite instead of silently clearing the backup.
if (!backupGuard.accept(backupNutzapInfo, newNutzapInfo) { updateNutzapInfo(newNutzapInfo) }) return
// A mints-less kind:10019 is the "stop receiving nutzaps" tombstone
// (an empty replacement carrying only an `alt` tag). Don't restore it
// on next launch — backing it up would undo clearNutzapInfo() once the
@@ -1125,10 +1189,8 @@ class AccountSettings(
clearNutzapInfo()
return
}
if (backupNutzapInfo?.id != newNutzapInfo.id) {
backupNutzapInfo = newNutzapInfo
saveAccountSettings()
}
backupNutzapInfo = newNutzapInfo
saveAccountSettings()
}
/**
@@ -1138,6 +1200,8 @@ class AccountSettings(
* LocalCache on next launch and resurrect the deleted wallet.
*/
fun clearCashuWallet() {
// A deleted wallet must not be restorable from an open conflict.
backupGuard.drop(backupSlotOf(CashuWalletEvent.KIND))
if (backupCashuWallet != null) {
backupCashuWallet = null
saveAccountSettings()
@@ -1146,6 +1210,7 @@ class AccountSettings(
/** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */
fun clearNutzapInfo() {
backupGuard.drop(backupSlotOf(NutzapInfoEvent.KIND))
if (backupNutzapInfo != null) {
backupNutzapInfo = null
saveAccountSettings()
@@ -1184,80 +1249,72 @@ class AccountSettings(
}
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
if (newNIPA3PaymentTargets == null) return
// Events might be different objects, we have to compare their ids.
if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) {
if (backupGuard.accept(backupNipA3PaymentTargets, newNIPA3PaymentTargets, isEmpty = newNIPA3PaymentTargets.tags.isEmpty()) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) {
backupNipA3PaymentTargets = newNIPA3PaymentTargets
saveAccountSettings()
}
}
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
if (newBolt12Offers == null) return
// Events might be different objects, we have to compare their ids.
if (backupBolt12Offers?.id != newBolt12Offers.id) {
if (backupGuard.accept(backupBolt12Offers, newBolt12Offers, isEmpty = newBolt12Offers.tags.isEmpty()) { updateBolt12Offers(newBolt12Offers) }) {
backupBolt12Offers = newBolt12Offers
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
if (newSearchRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupSearchRelayList?.id != newSearchRelayList.id) {
if (backupGuard.accept(backupSearchRelayList, newSearchRelayList, isEmpty = newSearchRelayList.tags.isEmpty()) { updateSearchRelayList(newSearchRelayList) }) {
backupSearchRelayList = newSearchRelayList
saveAccountSettings()
}
}
fun updateIndexRelayList(newIndexRelayList: IndexerRelayListEvent?) {
if (newIndexRelayList == null || newIndexRelayList.tags.isEmpty()) return
if (newIndexRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupIndexRelayList?.id != newIndexRelayList.id) {
if (backupGuard.accept(backupIndexRelayList, newIndexRelayList, isEmpty = newIndexRelayList.tags.isEmpty()) { updateIndexRelayList(newIndexRelayList) }) {
backupIndexRelayList = newIndexRelayList
saveAccountSettings()
}
}
fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) {
if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return
if (newRelayFeedList == null) return
// Events might be different objects, we have to compare their ids.
if (backupRelayFeedsList?.id != newRelayFeedList.id) {
if (backupGuard.accept(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) {
backupRelayFeedsList = newRelayFeedList
saveAccountSettings()
}
}
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
if (newBlockedRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupBlockedRelayList?.id != newBlockedRelayList.id) {
if (backupGuard.accept(backupBlockedRelayList, newBlockedRelayList, isEmpty = newBlockedRelayList.tags.isEmpty()) { updateBlockedRelayList(newBlockedRelayList) }) {
backupBlockedRelayList = newBlockedRelayList
saveAccountSettings()
}
}
fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) {
if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return
if (newTrustedRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupTrustedRelayList?.id != newTrustedRelayList.id) {
if (backupGuard.accept(backupTrustedRelayList, newTrustedRelayList, isEmpty = newTrustedRelayList.tags.isEmpty()) { updateTrustedRelayList(newTrustedRelayList) }) {
backupTrustedRelayList = newTrustedRelayList
saveAccountSettings()
}
}
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
if (newPrivateHomeRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
if (backupGuard.accept(backupPrivateHomeRelayList, newPrivateHomeRelayList, isEmpty = newPrivateHomeRelayList.tags.isEmpty()) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) {
backupPrivateHomeRelayList = newPrivateHomeRelayList
saveAccountSettings()
}
@@ -1266,50 +1323,45 @@ class AccountSettings(
override fun channelList() = backupChannelList
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
if (newChannelList == null || newChannelList.tags.isEmpty()) return
if (newChannelList == null) return
// Events might be different objects, we have to compare their ids.
if (backupChannelList?.id != newChannelList.id) {
if (backupGuard.accept(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) {
backupChannelList = newChannelList
saveAccountSettings()
}
}
fun updateGeohashListTo(newGeohashList: GeohashListEvent?) {
if (newGeohashList == null || newGeohashList.tags.isEmpty()) return
if (newGeohashList == null) return
// Events might be different objects, we have to compare their ids.
if (backupGeohashList?.id != newGeohashList.id) {
if (backupGuard.accept(backupGeohashList, newGeohashList, isEmpty = newGeohashList.tags.isEmpty()) { updateGeohashListTo(newGeohashList) }) {
backupGeohashList = newGeohashList
saveAccountSettings()
}
}
fun updateHashtagListTo(newHashtagList: HashtagListEvent?) {
if (newHashtagList == null || newHashtagList.tags.isEmpty()) return
if (newHashtagList == null) return
// Events might be different objects, we have to compare their ids.
if (backupHashtagList?.id != newHashtagList.id) {
if (backupGuard.accept(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) {
backupHashtagList = newHashtagList
saveAccountSettings()
}
}
fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) {
if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return
if (newFavoriteDvmList == null) return
// Events might be different objects, we have to compare their ids.
if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) {
if (backupGuard.accept(backupFavoriteAlgoFeedsList, newFavoriteDvmList, isEmpty = newFavoriteDvmList.tags.isEmpty()) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) {
backupFavoriteAlgoFeedsList = newFavoriteDvmList
saveAccountSettings()
}
}
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
if (newCommunityList == null) return
// Events might be different objects, we have to compare their ids.
if (backupCommunityList?.id != newCommunityList.id) {
if (backupGuard.accept(backupCommunityList, newCommunityList, isEmpty = newCommunityList.tags.isEmpty()) { updateCommunityListTo(newCommunityList) }) {
backupCommunityList = newCommunityList
saveAccountSettings()
}
@@ -1318,10 +1370,9 @@ class AccountSettings(
override fun ephemeralChatList() = backupEphemeralChatList
override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
if (newEphemeralChatList == null) return
// Events might be different objects, we have to compare their ids.
if (backupEphemeralChatList?.id != newEphemeralChatList.id) {
if (backupGuard.accept(backupEphemeralChatList, newEphemeralChatList, isEmpty = newEphemeralChatList.tags.isEmpty()) { updateEphemeralChatListTo(newEphemeralChatList) }) {
backupEphemeralChatList = newEphemeralChatList
saveAccountSettings()
}
@@ -1334,8 +1385,7 @@ class AccountSettings(
// so an empty `tags` is NOT an empty list — guard only on null.
if (newRelayGroupList == null) return
// Events might be different objects, we have to compare their ids.
if (backupRelayGroupList?.id != newRelayGroupList.id) {
if (backupGuard.accept(backupRelayGroupList, newRelayGroupList) { updateRelayGroupListTo(newRelayGroupList) }) {
backupRelayGroupList = newRelayGroupList
saveAccountSettings()
}
@@ -1348,27 +1398,25 @@ class AccountSettings(
// so an empty `tags` is NOT an empty list — guard only on null.
if (newConcordList == null) return
if (backupConcordList?.id != newConcordList.id) {
if (backupGuard.accept(backupConcordList, newConcordList) { updateConcordListTo(newConcordList) }) {
backupConcordList = newConcordList
saveAccountSettings()
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
if (trustProviderList == null) return
// Events might be different objects, we have to compare their ids.
if (backupTrustProviderList?.id != trustProviderList.id) {
if (backupGuard.accept(backupTrustProviderList, trustProviderList, isEmpty = trustProviderList.tags.isEmpty()) { updateTrustProviderListTo(trustProviderList) }) {
backupTrustProviderList = trustProviderList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
if (newMuteList == null) return
// Events might be different objects, we have to compare their ids.
if (backupMuteList?.id != newMuteList.id) {
if (backupGuard.accept(backupMuteList, newMuteList, isEmpty = newMuteList.tags.isEmpty()) { updateMuteList(newMuteList) }) {
backupMuteList = newMuteList
saveAccountSettings()
}
@@ -1378,11 +1426,15 @@ class AccountSettings(
appSettings: AppSpecificDataEvent?,
newSyncedSettings: AccountSyncedSettingsInternal,
) {
if (appSettings == null || appSettings.content.isEmpty()) return
if (appSettings == null) return
// Events might be different objects, we have to compare their ids.
if (backupAppSpecificData?.id != appSettings.id) {
if (backupGuard.accept(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isBlank()) { updateAppSpecificData(appSettings, newSyncedSettings) }) {
backupAppSpecificData = appSettings
// An emptied blob (signed here or accepted by the user) has no settings to merge.
if (appSettings.content.isBlank()) {
saveAccountSettings()
return
}
syncedSettings.updateFrom(newSyncedSettings)
// Null means an older client rewrote the blob without this key — leave the
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -49,6 +51,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
@@ -369,6 +373,35 @@ class EventBroadcaster(
}
}
/**
* Publishes a re-signed backup that replaces a lossy version from another app. It goes
* where a normal save of that event goes; lists of the user's own relays (NIP-65, DM,
* key package) and the wallet go everywhere, and also to every relay the restored list
* names, since the lossy version may have removed them from the outbox set. Profiles and
* nutzap info go everywhere too, like their normal saves.
*/
fun sendRestoredVersion(event: Event) {
when (event) {
is AdvertisedRelayListEvent -> sendEverywhereAnd(event, event.relays().mapTo(mutableSetOf()) { it.relayUrl })
is ChatMessageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
is KeyPackageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
is CashuWalletEvent -> sendLiterallyEverywhere(event)
// Profiles and nutzap info are normally saved everywhere so others can find them;
// the restore must reach the same relays or they keep serving the lossy version.
is MetadataEvent -> sendLiterallyEverywhere(event)
is NutzapInfoEvent -> sendEverywhereAnd(event, event.relays().toSet())
else -> sendMyPublicAndPrivateOutbox(event)
}
}
private fun sendEverywhereAnd(
event: Event,
extraRelays: Set<NormalizedRelayUrl>,
) {
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + extraRelays)
account.cache.justConsumeMyOwnEvent(event)
}
fun sendLiterallyEverywhere(event: Event) {
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
account.cache.justConsumeMyOwnEvent(event)
@@ -0,0 +1,92 @@
/*
* 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.backups
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.utils.Log
/**
* How an undecided backup conflict is written to storage.
*
* A conflict is a question put to the user, and the backup it holds back stays held back until
* they answer. That outlives the process it was raised in, so the three events it is made of —
* the saved version, the incoming one, and the change that caused it — are stored and read back.
*
* The diff is **not** stored. It is recomputed from the two versions on load, so a conflict whose
* incoming version no longer removes anything (the other app put it back) simply stops being one
* instead of being resurrected from a stale description of itself.
*
* Kept out of `LocalPreferences` so it is reachable from a plain JVM test.
*/
object BackupConflictStorage {
// Every settings save writes the open conflicts, and each can hold a follow list with
// thousands of entries: reuse the last encoding while the conflicts themselves are the same.
@Volatile
private var lastEncoded: Pair<List<String>, String>? = null
fun encode(conflicts: List<Triple<Event, Event, Event>>): String {
val ids = conflicts.flatMap { (saved, incoming, cause) -> listOf(saved.id, incoming.id, cause.id) }
lastEncoded?.let { (cachedIds, encoded) -> if (cachedIds == ids) return encoded }
return encodeUncached(conflicts).also { lastEncoded = ids to it }
}
private fun encodeUncached(conflicts: List<Triple<Event, Event, Event>>): String =
JsonMapper.toJson(
conflicts.map { (saved, incoming, cause) ->
listOf(
OptimizedJsonMapper.toJson(saved),
OptimizedJsonMapper.toJson(incoming),
OptimizedJsonMapper.toJson(cause),
)
},
)
/**
* Unreadable entries are dropped one at a time: a conflict that cannot be parsed is not one
* the user can be asked about, and it must not take the readable ones down with it.
*/
fun decode(stored: String?): List<Triple<Event, Event, Event>> {
if (stored.isNullOrBlank()) return emptyList()
val rows =
try {
JsonMapper.fromJson<List<List<String>>>(stored)
} catch (e: Exception) {
Log.w("BackupConflictStorage", "Could not read the stored backup conflicts", e)
return emptyList()
}
return rows.mapNotNull { row ->
if (row.size != 3) return@mapNotNull null
try {
Triple(
OptimizedJsonMapper.fromJson(row[0]),
OptimizedJsonMapper.fromJson(row[1]),
OptimizedJsonMapper.fromJson(row[2]),
)
} catch (e: Exception) {
Log.w("BackupConflictStorage", "Could not read a stored backup conflict", e)
null
}
}
}
}
@@ -111,11 +111,26 @@ class AppSpecificState(
getAppSpecificDataFlow().collect {
try {
Log.d("AccountRegisterObservers") { "Updating AppSpecificData for ${signer.pubKey}" }
(it.note.event as? AppSpecificDataEvent)?.let {
val decrypted = signer.decrypt(it.content, it.pubKey)
(it.note.event as? AppSpecificDataEvent)?.let { event ->
// A blob another app emptied has nothing to decrypt, so it used to die
// in the catch below — yet an empty version is exactly the wipe the
// backup guard exists to question, and [AccountSettings] already
// accepts one (`isEmpty`). Without this it never got there: the guard
// was never called, no conflict was raised, and the NIP-78 card could
// not appear for the case it was written for.
//
// The settings handed over are never merged — updateAppSpecificData
// returns before that whenever the content is empty — so defaults
// here cannot overwrite the real ones.
if (event.content.isBlank()) {
settings.updateAppSpecificData(event, AccountSyncedSettingsInternal())
return@let
}
val decrypted = signer.decrypt(event.content, event.pubKey)
try {
val syncedSettings = JsonMapper.fromJson<AccountSyncedSettingsInternal>(decrypted)
settings.updateAppSpecificData(it, syncedSettings)
settings.updateAppSpecificData(event, syncedSettings)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences", e)
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
import okhttp3.Call
import kotlin.coroutines.cancellation.CancellationException
@@ -44,18 +45,49 @@ import kotlin.coroutines.cancellation.CancellationException
class BlossomFetcher(
private val options: Options,
private val data: Uri,
private val imageLoader: ImageLoader,
private val blossomServerResolver: () -> BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
private val networkFetcher: (url: String, diskCacheKey: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? =
try {
val urlResult = blossomServerResolver().findServers(data.toString())
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
val uri = data.toString()
val key = options.diskCacheKey ?: diskCacheKey(uri)
// A blob already on disk is served without resolving: resolution may HEAD
// servers and wait on relays, and fails outright offline, where the bytes are
// still sitting in the cache. NetworkFetcher reads the snapshot under [key]
// before it ever looks at the url.
val onDisk = options.diskCachePolicy.readEnabled && imageLoader.diskCache?.openSnapshot(key)?.use { true } ?: false
val fromDisk =
if (onDisk) {
// Evicted between the check and the read: the fetcher would try the
// network with the unresolvable `blossom:` uri, so resolve after all.
try {
networkFetcher(uri, key).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
} else {
null
}
fromDisk ?: networkFetcher(blossomServerResolver().findServers(uri)?.serverUrl ?: uri, key).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
/**
* Blossom blobs are content-addressed, so the sha256 names the bytes wherever they
* were served from. Keying on it (instead of the resolved server URL) finds the
* blob again after a restart, a probe flip or a different server answering.
*/
fun diskCacheKey(uri: String): String = BlossomUri.parse(uri)?.let { "blossom:${it.sha256}" } ?: uri
}
@OptIn(ExperimentalCoilApi::class)
class Factory(
val blossomServerResolver: () -> BlossomServerResolver,
@@ -78,20 +110,19 @@ class BlossomFetcher(
if (!isApplicable(data)) return null
// Wrapped per resolved url (not per Factory) because the server the
// blob actually lives on is only known once the resolver has run.
return BlossomFetcher(options, data, blossomServerResolver) { url ->
return BlossomFetcher(options, data, imageLoader, blossomServerResolver) { url, diskCacheKey ->
val keyedOptions = options.copy(diskCacheKey = diskCacheKey)
readAuthAware(url, readAuth) { authHeader ->
NetworkFetcher(
url = url,
options = options.withAuthHeader(authHeader),
options = keyedOptions.withAuthHeader(authHeader),
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = concurrentRequestStrategyLazy,
)
// Keyed on the resolved server url, which is what the NetworkFetcher above
// caches under -- not on the `blossom:` uri the request came in as.
}.onSystemFileSystem(options.diskCacheKey ?: url)
}.onSystemFileSystem(diskCacheKey)
}
}
@@ -87,9 +87,10 @@ class ImageLoaderSetup {
// coordinates through a map of in-flight fetches that it owns, so it only
// works when every fetcher shares the same instance -- a fresh one per
// request can never see anybody else's fetch and the de-dupe silently
// no-ops. Shared across all three network-backed factories so a feed
// image and the same blob reached through `blossom:` (or a profile
// picture) still collapse onto one download.
// no-ops. Shared across all three network-backed factories so the same
// key requested through any of them (e.g. a feed image and a profile
// picture with the same URL) collapses onto one download. `blossom:`
// blobs are keyed by their sha256 instead (see BlossomFetcher.diskCacheKey).
val concurrentRequests = DeDupeConcurrentRequestStrategy()
SingletonImageLoader.setUnsafe(
@@ -36,6 +36,8 @@ import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor.Companion.PROFILE_PICTURE
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
@@ -121,7 +123,8 @@ class ProfilePictureFetcher(
NetworkFetcher(
url = data.url,
options = options.withAuthHeader(authHeader),
networkClient = lazy { networkClient(data.url).asNetworkClient() },
// Marked so the local Blossom cache bridge can honour "profile pictures only".
networkClient = lazy { LocalBlossomMediaCallFactory(networkClient(data.url), PROFILE_PICTURE).asNetworkClient() },
diskCache = diskCacheLazy,
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
@@ -39,6 +39,7 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.http.DynamicCallFactory
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
@@ -59,7 +60,11 @@ class PlaybackService : MediaSessionService() {
okHttpClient: DynamicCallFactory,
blossomServerResolver: BlossomServerResolver,
): MediaSessionPool {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
// Marked as media so the local Blossom cache may serve these downloads.
val dataSourceFactory =
OkHttpDataSource
.Factory(okHttpClient)
.setDefaultRequestProperties(mapOf(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER to LocalBlossomCacheRedirectInterceptor.MEDIA))
val resolvingDataSourceFactory: DataSource.Factory =
ResolvingDataSource.Factory(
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayClient.account.metadata.filterBasicAccountInfoFromKeys
import com.vitorpamplona.amethyst.commons.relayClient.account.metadata.filterBookmarksAndReportsFromKey
@@ -38,6 +39,24 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Which relays to ask for an account's own events, in order of what we actually know.
*
* Split out from the manager so the precedence can be tested without standing up an account:
* the rule is the whole fix, and the case that broke — every set but [defaults] empty — is the
* one no live account reproduces on demand.
*/
fun pickAccountDataRelays(
home: Set<NormalizedRelayUrl>,
inbox: Set<NormalizedRelayUrl>,
defaults: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> =
when {
home.isNotEmpty() -> home
inbox.isNotEmpty() -> inbox
else -> defaults
}
/**
* Each account's own profile, lists and recent posts — for **every** logged-in account, in one
* subscription.
@@ -59,13 +78,40 @@ class AccountMetadataEoseManager(
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
/**
* Where to look for an account's **own** profile, follow list and lists.
*
* Normally its home relays: where it publishes is where its own events are. But home relays
* are NIP-65 write relays (plus private storage and local ones), and a kind:10002 that lists
* none — read-only, or simply wrong — is a statement about publishing, not about where the
* account's existing events can be found. Keyed on that set alone, such an account joined no
* relay bucket at all and this subscription asked **nothing** on its behalf: its profile,
* follows and every backed-up list stayed frozen at whatever was last stored, forever, with
* nothing on screen to say why. A follow list replaced from another client never arrived, so
* the backup guard could not even raise the conflict it exists to raise.
*
* The fallbacks only widen where we *read*; they never redirect a publish. Its inbox relays
* come first — a client that rewrote these lists most likely also put them where the account
* says to reach it — and the app's event finders last, which is what "we don't know" means
* everywhere else.
*
* Deliberately not fixed in [relayListOrDefaultsWhenUnknown]: an empty list there means "they
* told us: nothing", and defaulting it would override an explicit choice about publishing.
*/
fun relaysToQuery(query: AccountQueryState): Set<NormalizedRelayUrl> =
pickAccountDataRelays(
home = relayFlow(query).value,
inbox = query.account.nip65RelayList.inboxFlow.value,
defaults = Constants.eventFinderRelays,
)
override fun updateFilter(
keys: List<AccountQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val accountsPerRelay = mutableMapOf<NormalizedRelayUrl, MutableList<AccountQueryState>>()
keys.forEach { key ->
relayFlow(key).value.forEach { relay ->
relaysToQuery(key).forEach { relay ->
accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key)
}
}
@@ -117,6 +163,13 @@ class AccountMetadataEoseManager(
key.account.scope.launch(Dispatchers.IO) {
relayFlow(key).collectLatest { invalidateFilters() }
},
// The inbox list is watched too because [relaysToQuery] falls back to it:
// an account with no home relays would otherwise keep querying whatever
// the fallback resolved to at startup, deaf to its own NIP-65 changing.
key.account.scope.launch(Dispatchers.IO) {
key.account.nip65RelayList.inboxFlow
.collectLatest { invalidateFilters() }
},
)
}
}
@@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okio.Buffer
import okio.ForwardingSource
import okio.Source
import okio.buffer
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
@@ -97,24 +99,29 @@ class UsageCountingInterceptor(
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache
// hits to 127.0.0.1) never touches the radio: counting it would inflate
// the network numbers — and could trip the background-data alert — for
// exactly the users who cache aggressively to SAVE data.
// Loopback traffic never touches the radio: counting it would inflate the
// network numbers — and could trip the background-data alert — for exactly
// the users who cache aggressively to SAVE data. This interceptor is the
// outermost one, so it sees URLs BEFORE LocalBlossomCacheRedirectInterceptor
// rewrites them to 127.0.0.1; the host that was actually contacted is only
// known from the response's request.
if (isLoopback(request.url.host)) return chain.proceed(request)
val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
bursts?.onHttpActivity()
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
if (requestBytes > 0) {
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
}
val startedAtMs = nowMs()
val response = chain.proceed(request)
val response =
try {
chain.proceed(request)
} catch (e: IOException) {
countRequest(request, role)
throw e
}
if (isLoopback(response.request.url.host)) return response
countRequest(request, role)
return response
.newBuilder()
.body(
@@ -131,6 +138,19 @@ class UsageCountingInterceptor(
).build()
}
private fun countRequest(
request: Request,
role: String,
) {
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
bursts?.onHttpActivity()
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
if (requestBytes > 0) {
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
}
}
companion object {
/** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */
fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]"
@@ -28,17 +28,23 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
@@ -46,10 +52,20 @@ class BlossomServerResolver(
val httpClientBuilder: IRoleBasedHttpClientBuilder,
val useLocalBlossomCache: () -> Boolean = { false },
val localCacheProbe: LocalBlossomCacheProbe? = null,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
// Unresolvable URIs, with when they failed. Without this every retry — each ExoPlayer
// load attempt blocks a loader thread on it — paid the full resolution timeout again.
private val missCache = LruCache<String, Long>(200)
// One resolution per URI at a time: the feed preview, Coil and the player asking for the
// same `blossom:` URI together share it instead of each firing its own HEADs and relay
// subscriptions. Runs in [scope] so a caller that goes away doesn't cancel it for the rest.
private val inFlight = ConcurrentHashMap<String, Deferred<BlossomUriServer?>>()
class BlossomUriServer(
val uri: BlossomUri,
val serverUrl: String,
@@ -57,41 +73,71 @@ class BlossomServerResolver(
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
// Bumped by [clearCaches]. A resolution that started before a clear (e.g. it picked the
// local cache just before the cache went down) must not write its stale answer back.
private val generation = AtomicInteger(0)
/** Forgets every resolution, e.g. when the local cache comes up or goes down. */
fun clearCaches() {
generation.incrementAndGet()
uriToUrlCache.evictAll()
missCache.evictAll()
blossomHitCache.cache.evictAll()
}
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
missCache[uriStr]?.let { failedAt ->
if (TimeUtils.nowMillis() - failedAt < MISS_TTL_MS) return null
}
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
val result =
withContext(Dispatchers.IO) {
withTimeoutOrNull(10000) {
findServersInner(uriStr)
val resolution =
inFlight.computeIfAbsent(uriStr) {
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
scope.async(Dispatchers.IO) {
val startedAt = generation.get()
try {
val result = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { findServersInner(uriStr) }
// Cleared while resolving: answer the waiting callers, remember nothing.
if (startedAt == generation.get()) {
if (result != null) {
uriToUrlCache.put(uriStr, result)
} else {
missCache.put(uriStr, TimeUtils.nowMillis())
}
}
result
} finally {
inFlight.remove(uriStr)
}
}
}
if (result != null) {
uriToUrlCache.put(uriStr, result)
}
return result
return resolution.await()
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
val expectedMimeType = mimeTypeMap[uri.extension]
// Same rule as the HTTP-level bridge, which Tor-routed clients don't carry: media the
// user sends through Tor must not be handed to the local cache, which would fetch it
// from the origin outside Tor.
if (useLocalBlossomCache() && !isTorRouted(uri, expectedMimeType) && localCacheProbe?.isAvailable() == true) {
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
}
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
if (uri.servers.isNotEmpty()) {
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
// Bounded well under RESOLVE_TIMEOUT_MS so a hint server that drops packets
// leaves time for the author's own server list below.
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size, XS_TIMEOUT_MS)
if (workingUrl != null) {
return BlossomUriServer(uri, workingUrl)
}
@@ -135,13 +181,20 @@ class BlossomServerResolver(
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
timeoutMs: Long = RESOLVE_TIMEOUT_MS,
): String? =
firstNotNullOrNullAsync(servers, 10000) {
firstNotNullOrNullAsync(servers, timeoutMs) {
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
client(url, expectedMimeType)
}
}
/** Whether this blob's media type would be fetched through Tor from a regular (clearnet) server. */
private fun isTorRouted(
uri: BlossomUri,
mimeType: String?,
): Boolean = client(uri.toServerUrl() ?: "https://blossom.invalid/${uri.filename()}", mimeType).proxy != null
fun client(
url: String,
mimeType: String?,
@@ -153,9 +206,13 @@ class BlossomServerResolver(
else -> httpClientBuilder.okHttpClientForPreview(url)
}
fun canResolve(scheme: String) = scheme == SCHEME
fun canResolve(scheme: String) = scheme.equals(SCHEME, ignoreCase = true)
companion object {
const val SCHEME = "blossom"
private const val RESOLVE_TIMEOUT_MS = 10_000L
private const val XS_TIMEOUT_MS = 4_000L
private const val MISS_TTL_MS = 30_000L
}
}
@@ -32,6 +32,7 @@ import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* Discovers a local Blossom cache running on `http://127.0.0.1:24242` per
@@ -48,6 +49,9 @@ class LocalBlossomCacheProbe(
@Volatile
private var cachedAtMs: Long = 0L
// Bumped by [markUnavailable]; lets a probe that raced it discard its stale positive result.
private val unavailableMarks = AtomicInteger(0)
private val _available = MutableStateFlow(false)
val available: StateFlow<Boolean> = _available
@@ -66,7 +70,11 @@ class LocalBlossomCacheProbe(
return@withLock _available.value
}
val startedAt = unavailableMarks.get()
val newResult = probe()
// A refused connection reported while this probe was in flight is newer evidence
// than a HEAD that may have succeeded just before the cache went away.
if (newResult && startedAt != unavailableMarks.get()) return@withLock false
_available.value = newResult
cachedAtMs = currentTimeMs()
newResult
@@ -80,6 +88,17 @@ class LocalBlossomCacheProbe(
cachedAtMs = 0L
}
/**
* Records that the cache just refused a connection. Flipping [available] right away turns
* the bridge off everywhere it is read, instead of letting every sha256 URL keep failing
* against the dead loopback port until the positive TTL runs out and someone re-probes.
*/
fun markUnavailable() {
unavailableMarks.incrementAndGet()
cachedAtMs = currentTimeMs()
_available.value = false
}
// Confined to Dispatchers.IO because callers reach this through suspend
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
// on the main dispatcher: building the OkHttp client and issuing the HEAD
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import androidx.collection.LruCache
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
@@ -56,13 +56,18 @@ class ServerHeadCache {
client(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
cache.put(url, HasFile.NoFile)
// Only a definitive answer is remembered. A 5xx, 429 or auth challenge is
// transient: caching it would hide this server for the app's lifetime.
if (response.code == 404 || response.code == 410) {
cache.put(url, HasFile.NoFile)
}
return HasFile.NoFile
}
// Retrieve the "Content-Length" header
val contentLength = response.header("Content-Length")?.toLongOrNull()
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
// type/subtype only: a `; charset=binary` parameter must not fail the match below.
val mimeType = response.header("Content-Type")?.toMediaTypeOrNull()?.let { "${it.type}/${it.subtype}" }
return if (contentLength != null && mimeType != null) {
val result = HasFile.TypeAndSize(mimeType, contentLength)
@@ -75,7 +80,8 @@ class ServerHeadCache {
}
} catch (e: Exception) {
if (e is CancellationException) throw e
cache.put(url, HasFile.NoFile)
// Offline, DNS failure, timeout: says nothing about the server having the file,
// so it is not cached — a brief outage must not hide the blob until eviction.
return HasFile.NoFile
}
}
@@ -104,7 +110,7 @@ class ServerHeadCache {
if (result.size == expectedSize) {
return url
}
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
if (expectedSize == null && result.size > 0 && result.mimeType.equals(expectedMimeType, ignoreCase = true)) {
return url
}
}
@@ -212,7 +212,7 @@ fun MediaCacheSection(accountViewModel: AccountViewModel) {
*/
@Composable
private fun CacheDetectionChip(accountViewModel: AccountViewModel) {
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
val probeAvailable by accountViewModel.localBlossomCacheDetected
.collectAsStateWithLifecycle()
val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
@@ -39,48 +39,21 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.asDrawable
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.Amethyst
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
import com.vitorpamplona.amethyst.commons.ui.components.forwardingPainter
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.onBackgroundColorFilter
import com.vitorpamplona.amethyst.ui.screen.AccountState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun rememberLocalBlossomBridgeForProfilePics(): Boolean {
val sessionManager =
try {
Amethyst.instance.sessionManager
} catch (e: UninitializedPropertyAccessException) {
return false
}
val probe = Amethyst.instance.localBlossomCacheProbe
val flow =
remember {
combine(
sessionManager.accountContent.flatMapLatest { state ->
if (state is AccountState.LoggedIn) state.account.settings.useLocalBlossomCache else flowOf(false)
},
probe.available,
) { toggle, probeUp -> toggle && probeUp }
}
val state by flow.collectAsStateWithLifecycle(initialValue = false)
return state
}
@Composable
fun RobohashAsyncImage(
@@ -120,21 +93,16 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean,
autoPlayGif: Boolean = true,
) {
val useBridge = rememberLocalBlossomBridgeForProfilePics()
val bridgedModel =
remember(model, robot, useBridge) {
bridgeProfilePictureUrl(model, useBridge, robot)
}
if (bridgedModel != null && loadProfilePicture && isAnimatedMediaUrl(bridgedModel)) {
if (model != null && loadProfilePicture && isAnimatedMediaUrl(model)) {
GifProfilePicture(
userHex = robot,
userPicture = bridgedModel,
userPicture = model,
contentDescription = contentDescription,
modifier = modifier,
loadRobohash = loadRobohash,
autoPlay = autoPlayGif,
)
} else if (bridgedModel != null && loadProfilePicture) {
} else if (model != null && loadProfilePicture) {
val fallbackPainter =
if (loadRobohash) {
rememberVectorPainter(
@@ -154,10 +122,10 @@ fun RobohashFallbackAsyncImage(
// file://) would fail there. Route only remote http(s) pictures through the thumbnail
// cache; hand local/content URIs to Coil's native fetchers, which load them directly.
model =
if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) {
ProfilePictureUrl(bridgedModel)
if (model.startsWith("http://", ignoreCase = true) || model.startsWith("https://", ignoreCase = true)) {
ProfilePictureUrl(model)
} else {
bridgedModel
model
},
contentDescription = contentDescription,
modifier = modifier,
@@ -237,9 +205,25 @@ fun GifProfilePicture(
)
}
val context = LocalContext.current
// Animated avatars skip ProfilePictureFetcher (its thumbnail cache would flatten them), so
// they carry the profile-picture marker themselves for the local Blossom cache bridge.
val model =
remember(userPicture) {
ImageRequest
.Builder(context)
.data(userPicture)
.httpHeaders(
NetworkHeaders
.Builder()
.set(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE)
.build(),
).build()
}
Box(modifier = modifier) {
SubcomposeAsyncImage(
model = userPicture,
model = model,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -27,6 +27,7 @@ import androidx.core.content.FileProvider
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType
import com.vitorpamplona.amethyst.service.images.BlossomFetcher
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -93,7 +94,7 @@ object ShareHelper {
): Pair<Uri, String> =
withContext(Dispatchers.IO) {
// Safely get snapshot and file
Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot ->
Amethyst.instance.diskCache.openSnapshot(BlossomFetcher.diskCacheKey(imageUrl))?.use { snapshot ->
val file = snapshot.data.toFile()
// Determine file extension and prepare sharable file
@@ -64,7 +64,6 @@ import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_save_the_image
@@ -81,7 +80,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
@@ -505,12 +503,6 @@ private fun RenderImageOrVideo(
}
val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url)
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val modifier =
if (ratio != null) {
Modifier.aspectRatio(ratio)
@@ -520,7 +512,7 @@ private fun RenderImageOrVideo(
Box(modifier, contentAlignment = Alignment.Center) {
VideoViewInner(
videoUri = bridgedUrl,
videoUri = content.url,
mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description,
@@ -66,7 +66,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
@@ -105,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.commons.service.image.placeholderModel
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
@@ -123,6 +121,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size75dp
import com.vitorpamplona.amethyst.commons.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.images.BlossomFetcher
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -200,26 +199,20 @@ fun ZoomableContentView(
sourceBounds = coordinates.boundsInWindow()
}
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
when (content) {
is MediaUrlImage -> {
val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
preloadUrls = listOf(bridgedUrl),
preloadUrls = listOf(content.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) {
if (content.isAnimatedMedia()) {
GifVideoView(
videoUri = bridgedUrl,
videoUri = content.url,
contentDescription = content.description,
dimensions = content.dim,
blurhash = content.blurhash,
@@ -254,10 +247,6 @@ fun ZoomableContentView(
content.dim?.aspectRatioOrNull()
?: MediaAspectRatioCache.get(content.url)
?: fallbackRatio
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
@@ -277,7 +266,7 @@ fun ZoomableContentView(
contentAlignment = Alignment.Center,
) {
VideoView(
videoUri = bridgedUrl,
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
@@ -544,22 +533,17 @@ fun UrlImageView(
}
val context = LocalContext.current
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModel =
if (fullResolution) {
remember(bridgedUrl, context) {
remember(content.url, context) {
ImageRequest
.Builder(context)
.data(bridgedUrl)
.data(content.url)
.size(Size.ORIGINAL)
.build()
}
} else {
bridgedUrl
content.url
}
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
@@ -1281,15 +1265,9 @@ private suspend fun shareLocalVideoFile(
private fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null
val keys = mutableListOf(content.url)
val bridged = content.toCoilModel(true)
if (bridged != content.url) keys.add(bridged)
for (key in keys) {
Amethyst.instance.diskCache.openSnapshot(key)?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
}
Amethyst.instance.diskCache.openSnapshot(BlossomFetcher.diskCacheKey(content.url))?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
}
return null
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.ProfileAppRecommendationsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictReviewScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen
@@ -680,6 +681,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.BackupConflictReview> { BackupConflictReviewScreen(it.slot, accountViewModel, nav) }
composableFromEndArgs<Route.RelayManagement> { RelayManagementScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.RelayMembers> { RelayMembersScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.model.Dao
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
@@ -275,32 +276,10 @@ class AccountViewModel(
val feedStates = AccountFeedContentStates(account, viewModelScope)
/**
* `true` when feed/note media (images and videos in `MediaUrlContent`)
* should be routed through the local Blossom cache. Requires the master
* toggle on, the probe up, AND the profile-pictures-only restriction
* to be off.
* `true` when the local Blossom cache is enabled and the probe sees it up. Only drives the
* "detected" chip in settings: the routing itself happens in LocalBlossomCacheRedirectInterceptor.
*/
val useLocalBlossomBridge: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
account.settings.localBlossomCacheProfilePicturesOnly,
Amethyst.instance.localBlossomCacheProbe.available,
) { toggle, profileOnly, probeUp -> toggle && probeUp && !profileOnly }.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
} catch (e: UninitializedPropertyAccessException) {
MutableStateFlow(false)
}
/**
* `true` when profile pictures should be routed through the local
* Blossom cache. Requires only the master toggle and the probe to be
* up; the profile-pictures-only restriction does not gate this flow.
*/
val useLocalBlossomBridgeForProfilePics: StateFlow<Boolean> =
val localBlossomCacheDetected: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
@@ -1663,6 +1642,12 @@ class AccountViewModel(
}
}
/**
* Keeps another app's version of a backed-up list. Off the main thread: it waits for the
* backup guard's lock, which the backup collectors hold while diffing large lists.
*/
fun acceptExternalBackupVersion(conflict: ReplaceableBackupConflict) = viewModelScope.launch(Dispatchers.IO) { account.acceptExternalVersion(conflict) }
inline fun launchSigner(crossinline action: suspend () -> Unit) =
viewModelScope.launch(Dispatchers.IO) {
reportSignerErrors { action() }
@@ -0,0 +1,347 @@
/*
* 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.backups
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
/**
* Cards at the top of Home's feed, one per open [ReplaceableBackupConflict]: the user
* changed one of the account's backed-up lists (or the profile) in another app, and
* Amethyst asks to confirm before its backup moves on. They can't be dismissed. They stay
* until the user decides on the review screen they open ([BackupConflictReviewScreen]).
*
* The most recent conflict gets a lead card with a headline and a picture of the change
* (the follow list's bar, or removed/added counts); the rest are slim pills. They are the
* first item of the feed, so they scroll away with it instead of covering posts.
*/
@Composable
fun BackupConflictCards(
conflictMap: Map<String, ReplaceableBackupConflict>,
nav: INav,
onAccept: (ReplaceableBackupConflict) -> Unit,
modifier: Modifier = Modifier,
) {
if (conflictMap.isEmpty()) return
val conflicts = remember(conflictMap) { conflictMap.values.sortedByDescending { it.cause.createdAt } }
val open = { conflict: ReplaceableBackupConflict -> nav.nav(Route.BackupConflictReview(conflict.slot)) }
Column(
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val lead = conflicts.first()
LeadConflictCard(lead, onAccept = { onAccept(lead) }) { open(lead) }
conflicts.drop(1).forEach { conflict -> ConflictPill(conflict, onAccept = { onAccept(conflict) }) { open(conflict) } }
}
}
/** A headline that says what happened, in the event's own terms. */
@Composable
private fun headlineOf(
conflict: ReplaceableBackupConflict,
counts: ConflictCounts,
): String {
val diff = conflict.diff
val specific: String? =
when (diff) {
// Counted, not "shrank": a version that drops 3 follows and adds 100 still raises
// a conflict, and calling that a shrink contradicts the card's own bar.
is ContactListDiff -> plural(R.plurals.backup_card_follows_dropped, diff.follows.removed.size)
is MuteListDiff -> plural(R.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size)
is MetadataDiff -> plural(R.plurals.backup_card_profile_lost_fields, counts.removed)
is AdvertisedRelayListDiff -> plural(R.plurals.backup_card_nip65_lost, diff.relays.removed.size)
is RelayListDiff -> relayListHeadline(conflict.eventType, diff.relays.removed.size)
is ChannelListDiff -> plural(R.plurals.backup_card_leave_chats, diff.channels.removed.size)
is CommunityListDiff -> plural(R.plurals.backup_card_leave_communities, diff.communities.removed.size)
is EphemeralChatListDiff -> plural(R.plurals.backup_card_leave_rooms, diff.rooms.removed.size)
is SimpleGroupListDiff -> plural(R.plurals.backup_card_leave_groups, diff.groups.removed.size)
is FavoriteAlgoFeedsListDiff -> plural(R.plurals.backup_card_feeds_removed, diff.feeds.removed.size)
is HashtagListDiff -> plural(R.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size)
is GeohashListDiff -> plural(R.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size)
is TrustProviderListDiff -> plural(R.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size)
is NutzapInfoDiff ->
when {
diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_removed)
diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_replaced)
else -> plural(R.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size)
}
is PaymentTargetsDiff -> plural(R.plurals.backup_card_payment_targets_removed, diff.targets.removed.size)
is Bolt12OfferListDiff -> plural(R.plurals.backup_card_offers_removed, diff.offers.removed.size)
is CashuWalletDiff -> encryptedHeadline(R.string.backup_card_wallet_emptied, R.string.backup_card_wallet_rewritten, diff.wallet)
is ConcordCommunityListDiff -> encryptedHeadline(R.string.backup_card_concord_emptied, R.string.backup_card_concord_rewritten, diff.communities)
is AppSpecificDataDiff -> stringRes(R.string.backup_card_app_settings)
else -> null
}
// A headline counting removed items reads wrong when only encrypted items were lost.
if (specific.isNullOrEmpty()) return stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType)))
// Headlines lead with the loss; what the other app added to the same list is said too,
// so a list that dropped 3 and gained 40 doesn't read as if it only shrank. The wording
// follows the headline's: following people, muting, joining chats, blocking relays.
val added = addedToHeadlinedList(diff, conflict.eventType)
if (added <= 0) return specific
val tail =
when {
diff is ContactListDiff -> R.plurals.backup_card_and_followed
diff is MuteListDiff -> R.plurals.backup_card_and_muted
diff is ChannelListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> R.plurals.backup_card_and_joined
conflict.eventType == BackupEventType.BLOCKED_RELAYS -> R.plurals.backup_card_and_blocked
else -> R.plurals.backup_card_and_added
}
return pluralStringResource(tail, added, specific, added)
}
/**
* How many items were added to the list the headline counts removals from, or 0 when the
* headline isn't a count of list items (a key swap, an encrypted rewrite, a profile).
*/
private fun addedToHeadlinedList(
diff: EventDiff,
type: BackupEventType,
): Int =
when (diff) {
is ContactListDiff -> diff.follows.added.size
is MuteListDiff -> diff.publicMutes.added.size
is AdvertisedRelayListDiff -> diff.relays.added.size
is RelayListDiff -> if (relayListHasHeadline(type)) diff.relays.added.size else 0
is ChannelListDiff -> diff.channels.added.size
is CommunityListDiff -> diff.communities.added.size
is EphemeralChatListDiff -> diff.rooms.added.size
is SimpleGroupListDiff -> diff.groups.added.size
is FavoriteAlgoFeedsListDiff -> diff.feeds.added.size
is HashtagListDiff -> diff.hashtags.added.size
is GeohashListDiff -> diff.geohashes.added.size
is NutzapInfoDiff -> if (diff.p2pkPubkey == null) diff.mints.added.size else 0
is PaymentTargetsDiff -> diff.targets.added.size
is Bolt12OfferListDiff -> diff.offers.added.size
else -> 0
}
/** The relay lists [relayListHeadline] has a counted headline for. */
private fun relayListHasHeadline(type: BackupEventType): Boolean =
when (type) {
BackupEventType.DM_RELAYS, BackupEventType.KEY_PACKAGE_RELAYS, BackupEventType.SEARCH_RELAYS,
BackupEventType.INDEXER_RELAYS, BackupEventType.RELAY_FEEDS, BackupEventType.PRIVATE_OUTBOX_RELAYS,
BackupEventType.TRUSTED_RELAYS, BackupEventType.BLOCKED_RELAYS,
-> true
else -> false
}
/** The count's plural, or "" when nothing of that kind was removed. */
@Composable
private fun plural(
id: Int,
count: Int,
): String = if (count > 0) pluralStringResource(id, count, count) else ""
@Composable
private fun relayListHeadline(
type: BackupEventType,
removed: Int,
): String =
when (type) {
BackupEventType.DM_RELAYS -> plural(R.plurals.backup_card_dm_relays_lost, removed)
BackupEventType.KEY_PACKAGE_RELAYS -> plural(R.plurals.backup_card_key_package_relays_lost, removed)
BackupEventType.SEARCH_RELAYS -> plural(R.plurals.backup_card_search_relays_lost, removed)
BackupEventType.INDEXER_RELAYS -> plural(R.plurals.backup_card_indexer_relays_lost, removed)
BackupEventType.RELAY_FEEDS -> plural(R.plurals.backup_card_relay_feeds_lost, removed)
BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(R.plurals.backup_card_private_outbox_lost, removed)
BackupEventType.TRUSTED_RELAYS -> plural(R.plurals.backup_card_trusted_lost, removed)
BackupEventType.BLOCKED_RELAYS -> plural(R.plurals.backup_card_unblocked, removed)
else -> ""
}
@Composable
private fun encryptedHeadline(
emptied: Int,
rewritten: Int,
change: ContentChange,
): String = if (change == ContentChange.CLEARED) stringRes(emptied) else stringRes(rewritten)
@Composable
private fun LeadConflictCard(
conflict: ReplaceableBackupConflict,
onAccept: () -> Unit,
onClick: () -> Unit,
) {
val tones = conflictTones()
val counts = remember(conflict) { countsOf(conflict.diff) }
Surface(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).clickable(onClick = onClick),
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 4.dp,
shadowElevation = 4.dp,
) {
Column(
Modifier.border(1.dp, tones.removed.copy(alpha = 0.35f), RoundedCornerShape(22.dp)).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.size(40.dp).clip(CircleShape).background(tones.removedContainer()), contentAlignment = Alignment.Center) {
Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.removed)
}
Column(Modifier.weight(1f)) {
Text(headlineOf(conflict, counts), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis)
Text(
stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
}
AcceptButton(onAccept)
}
val diff = conflict.diff
if (diff is ContactListDiff) {
val (saved, new) = conflict.followCounts
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(saved.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold)
SplitBar(
listOf(
BarSegment(saved - diff.follows.removed.size, tones.kept),
BarSegment(diff.follows.removed.size, tones.removed.copy(alpha = 0.6f)),
BarSegment(diff.follows.added.size, tones.added),
),
Modifier.weight(1f),
height = 8.dp,
)
Text(new.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = if (new < saved) tones.removed else tones.added)
}
} else {
CountChips(counts)
}
}
}
}
@Composable
private fun CountChips(counts: ConflictCounts) {
val tones = conflictTones()
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (counts.removed > 0) StatusTag(stringRes(R.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed)
if (counts.added > 0) StatusTag(stringRes(R.string.backup_conflict_count_added, counts.added.toString()), tones.added)
if (counts.changed > 0) StatusTag(stringRes(R.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed)
}
}
@Composable
private fun ConflictPill(
conflict: ReplaceableBackupConflict,
onAccept: () -> Unit,
onClick: () -> Unit,
) {
val tones = conflictTones()
val counts = remember(conflict) { countsOf(conflict.diff) }
Surface(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 4.dp,
shadowElevation = 2.dp,
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(32.dp).clip(CircleShape).background(tones.changedContainer()), contentAlignment = Alignment.Center) {
Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
}
Text(headlineOf(conflict, counts), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
AcceptButton(onAccept)
}
}
}
/**
* Confirms the change right from Home: the change was made on purpose in another app, so
* keeping it is the quick path. Reverting needs the review screen, one tap on the card away.
*/
@Composable
private fun AcceptButton(onAccept: () -> Unit) {
FilledTonalButton(
onClick = onAccept,
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Text(stringRes(R.string.backup_card_accept), fontWeight = FontWeight.SemiBold)
}
}
@@ -0,0 +1,937 @@
/*
* 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.backups
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.channels
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
/**
* Per-event layouts for the account's other lists: each relay list framed by what it is
* for (who can reach you, what gets unblocked…), joined spaces as tiles, followed topics
* as a pill cloud, trust providers as a per-service table, payment targets and offers as
* cards, and the encrypted-only events as a single explained panel. Returns false when
* [conflict] has no layout here, so the caller falls back to the generic one.
*/
internal fun LazyListScope.listDiffItems(
conflict: ReplaceableBackupConflict,
accountViewModel: AccountViewModel,
nav: INav,
): Boolean {
when (val diff = conflict.diff) {
is RelayListDiff -> relayListItems(diff, conflict, nav)
is ChannelListDiff -> publicChatItems(diff, conflict, accountViewModel, nav)
is CommunityListDiff -> communityItems(diff, conflict, accountViewModel, nav)
is EphemeralChatListDiff -> ephemeralRoomItems(diff, conflict, nav)
is FavoriteAlgoFeedsListDiff -> favoriteFeedItems(diff, conflict, accountViewModel, nav)
is HashtagListDiff -> topicItems(diff.hashtags, (conflict.saved as? HashtagListEvent)?.publicHashtags().orEmpty(), diff.privateItems, isPlace = false, nav)
is GeohashListDiff -> topicItems(diff.geohashes, (conflict.saved as? GeohashListEvent)?.publicGeohashes().orEmpty(), diff.privateItems, isPlace = true, nav)
is TrustProviderListDiff -> trustProviderItems(diff, conflict, accountViewModel, nav)
is PaymentTargetsDiff -> paymentTargetItems(diff, conflict)
is Bolt12OfferListDiff -> offerItems(diff, conflict)
is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, R.string.backup_review_wallet_body)
is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, R.string.backup_review_concord_body)
else -> return false
}
return true
}
private val Pad = Modifier.padding(horizontal = 16.dp)
/** Fate of an item between the saved version and the new one. */
internal enum class ItemFate { KEPT, DROPPED, ADDED, CHANGED }
/**
* The saved items in their order, each marked kept or dropped (or changed), then the new
* ones: the full picture of a list, not just its differences.
*/
private fun <T, K> fates(
saved: List<T>,
diff: ListDiff<T>,
key: (T) -> K,
): List<Pair<T, ItemFate>> {
val removed = diff.removed.mapTo(HashSet(), key)
val changed = diff.changed.associateBy { key(it.after) }
val seen = HashSet<K>()
val result = mutableListOf<Pair<T, ItemFate>>()
saved.forEach { item ->
val k = key(item)
if (!seen.add(k)) return@forEach
when (k) {
in removed -> result.add(item to ItemFate.DROPPED)
in changed -> result.add(changed.getValue(k).after to ItemFate.CHANGED)
else -> result.add(item to ItemFate.KEPT)
}
}
diff.added.forEach { result.add(it to ItemFate.ADDED) }
return result
}
@Composable
private fun fateColor(fate: ItemFate): Color {
val tones = conflictTones()
return when (fate) {
ItemFate.KEPT -> MaterialTheme.colorScheme.onSurface
ItemFate.DROPPED -> tones.removed
ItemFate.ADDED -> tones.added
ItemFate.CHANGED -> tones.changed
}
}
private fun <T> LazyListScope.countTiles(
items: List<Pair<T, ItemFate>>,
removedCaption: Int,
addedCaption: Int,
) {
item(key = "count-tiles", contentType = "tiles") {
val tones = conflictTones()
Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatTile(items.count { it.second == ItemFate.DROPPED }.toString(), stringRes(removedCaption), tones.removed, Modifier.weight(1f))
StatTile(items.count { it.second == ItemFate.ADDED }.toString(), stringRes(addedCaption), tones.added, Modifier.weight(1f))
StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(R.string.backup_review_kept), tones.kept, Modifier.weight(1f))
}
}
}
private fun LazyListScope.privateItemsCard(change: ContentChange) {
if (change == ContentChange.NONE) return
item(key = "private-items", contentType = "private") { PrivateItemsCard(change, Pad.padding(top = 18.dp)) }
}
// ---------------------------------------------------------------------------------------
// Relay lists, framed by what each one is for.
// ---------------------------------------------------------------------------------------
/** How a relay list's rows are presented: its icon and what a kept or dropped relay is called. */
private class RelayFraming(
val icon: MaterialSymbol,
val keptTag: Int,
val droppedTag: Int = R.string.backup_review_dropped_tag,
)
private fun framingOf(type: BackupEventType): RelayFraming =
when (type) {
BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_receiving_tag)
BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_listed_tag)
BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_searching_tag)
BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag)
BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_listed_tag)
BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_storing_tag)
BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag)
else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag)
}
private fun savedRelaysOf(event: Event): List<NormalizedRelayUrl> =
when (event) {
is ChatMessageRelayListEvent -> event.relays()
is KeyPackageRelayListEvent -> event.relays()
is SearchRelayListEvent -> event.publicRelays()
is IndexerRelayListEvent -> event.publicRelays()
is RelayFeedsListEvent -> event.publicRelays()
is BlockedRelayListEvent -> event.publicRelays()
is TrustedRelayListEvent -> event.publicRelays()
is PrivateOutboxRelayListEvent -> event.publicRelays()
else -> emptyList()
}
private fun LazyListScope.relayListItems(
diff: RelayListDiff,
conflict: ReplaceableBackupConflict,
nav: INav,
) {
val rows = fates(savedRelaysOf(conflict.saved), diff.relays) { it }
if (conflict.eventType == BackupEventType.BLOCKED_RELAYS) {
blockedRelayItems(rows, nav)
} else {
// No summary card: the rows themselves, tagged kept / dropped / new, are the change.
val framing = framingOf(conflict.eventType)
items(rows, key = { "relay-" + it.first.url }, contentType = { "relay-row" }) { (url, fate) ->
RelayFateRow(url, fate, framing, nav)
}
}
privateItemsCard(diff.privateRelays)
}
@Composable
private fun RelayFateRow(
url: NormalizedRelayUrl,
fate: ItemFate,
framing: RelayFraming,
nav: INav,
) {
val color = fateColor(fate)
val tag =
when (fate) {
ItemFate.KEPT -> framing.keptTag
ItemFate.DROPPED -> framing.droppedTag
ItemFate.ADDED -> R.string.backup_review_new_tag
ItemFate.CHANGED -> R.string.backup_review_demoted_tag
}
Row(
Pad
.padding(top = 6.dp)
.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable { nav.nav(Route.RelayInfo(url.url)) }
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(symbol = framing.icon, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp))
Text(
url.url.removePrefix("wss://").removeSuffix("/"),
modifier = Modifier.weight(1f),
color = color,
textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
StatusTag(stringRes(tag), if (fate == ItemFate.KEPT) MaterialTheme.colorScheme.placeholderText else color)
}
}
@OptIn(ExperimentalLayoutApi::class)
private fun LazyListScope.blockedRelayItems(
rows: List<Pair<NormalizedRelayUrl, ItemFate>>,
nav: INav,
) {
val unblocked = rows.filter { it.second == ItemFate.DROPPED }.map { it.first }
val stillBlocked = rows.filter { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.map { it.first }
val newlyBlocked = rows.filter { it.second == ItemFate.ADDED }.map { it.first }
item(key = "blocked-hero", contentType = "hero") {
val tones = conflictTones()
TintedPanel(tones.changedContainer(), Pad.padding(top = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(44.dp))
Column {
Text(stringRes(R.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed)
Text(stringRes(R.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
}
if (unblocked.isNotEmpty()) {
item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) }
items(unblocked, key = { "unblocked-" + it.url }, contentType = { "relay-row" }) { url ->
val tones = conflictTones()
Row(
Pad
.padding(top = 6.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.border(1.dp, tones.changed.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable { nav.nav(Route.RelayInfo(url.url)) }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(36.dp).clip(CircleShape).border(2.dp, tones.changed, CircleShape), contentAlignment = Alignment.Center) {
Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
}
Text(url.url.removePrefix("wss://").removeSuffix("/"), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
StatusTag(stringRes(R.string.backup_review_allowed_tag), tones.changed)
}
}
}
if (newlyBlocked.isNotEmpty()) {
item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) }
items(newlyBlocked, key = { "newly-blocked-" + it.url }, contentType = { "relay-row" }) { url ->
RelayFateRow(url, ItemFate.ADDED, framingOf(BackupEventType.BLOCKED_RELAYS), nav)
}
}
if (stillBlocked.isNotEmpty()) {
item(key = "still-blocked", contentType = "chips") {
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionCaption(stringRes(R.string.backup_review_still_blocked))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
stillBlocked.forEach { url ->
Row(
Modifier
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable { nav.nav(Route.RelayInfo(url.url)) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(symbol = MaterialSymbols.Lock, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(16.dp))
Text(url.url.removePrefix("wss://").removeSuffix("/"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
}
}
}
}
// ---------------------------------------------------------------------------------------
// Joined spaces: public chats, communities, ephemeral rooms and favorite feeds as tiles.
// ---------------------------------------------------------------------------------------
/** One space tile: its picture, name and subtitle are loaded by [content]. */
private fun <T> LazyListScope.spaceGrid(
keyPrefix: String,
items: List<Pair<T, ItemFate>>,
key: (T) -> String,
content: @Composable (T, ItemFate, Modifier) -> Unit,
) {
countTiles(items, R.string.backup_review_left, R.string.backup_review_joined)
items(items.chunked(2), key = { keyPrefix + key(it.first().first) }, contentType = { "space-row" }) { row ->
Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEach { (item, fate) -> content(item, fate, Modifier.weight(1f)) }
if (row.size == 1) Spacer(Modifier.weight(1f))
}
}
}
@Composable
private fun SpaceTile(
name: String,
subtitle: String,
robot: String,
picture: String?,
fate: ItemFate,
accountViewModel: AccountViewModel,
modifier: Modifier,
onClick: (() -> Unit)?,
) {
val tones = conflictTones()
Box(
modifier
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(14.dp),
) {
Column(Modifier.alpha(if (fate == ItemFate.DROPPED) 0.45f else 1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
RobohashFallbackAsyncImage(
robot = robot,
model = picture,
contentDescription = null,
modifier = Modifier.size(48.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
Column {
Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
val tag =
when (fate) {
ItemFate.DROPPED -> R.string.backup_review_left_tag to tones.removed
ItemFate.ADDED -> R.string.backup_review_new_tag to tones.added
ItemFate.CHANGED -> R.string.backup_review_moved_tag to tones.changed
ItemFate.KEPT -> null
}
tag?.let { (res, color) -> StatusTag(stringRes(res), color, Modifier.align(Alignment.TopEnd)) }
}
}
private fun LazyListScope.publicChatItems(
diff: ChannelListDiff,
conflict: ReplaceableBackupConflict,
accountViewModel: AccountViewModel,
nav: INav,
) {
val saved = (conflict.saved as? ChannelListEvent)?.tags?.channels().orEmpty()
spaceGrid("chat-", fates(saved, diff.channels) { it.eventId }, { it.eventId }) { tag, fate, modifier ->
LoadPublicChatChannel(tag.eventId, accountViewModel) { channel ->
// Subscribes to the channel's metadata on relays and recomposes when it arrives.
val state by observeChannel(channel, accountViewModel)
val current = state?.channel ?: channel
SpaceTile(
name = current.toBestDisplayName(),
subtitle = stringRes(R.string.backup_type_public_chats),
robot = tag.eventId,
picture = channel.profilePicture(),
fate = fate,
accountViewModel = accountViewModel,
modifier = modifier,
onClick = { nav.nav(routeFor(channel)) },
)
}
}
privateItemsCard(diff.privateItems)
}
private fun LazyListScope.communityItems(
diff: CommunityListDiff,
conflict: ReplaceableBackupConflict,
accountViewModel: AccountViewModel,
nav: INav,
) {
val saved = (conflict.saved as? CommunityListEvent)?.publicCommunities().orEmpty()
spaceGrid("community-", fates(saved, diff.communities) { it.address }, { it.address.toValue() }) { tag, fate, modifier ->
AddressableSpaceTile(tag.address, fate, accountViewModel, modifier) { nav.nav(Route.Community(tag.address)) }
}
privateItemsCard(diff.privateItems)
}
private fun LazyListScope.favoriteFeedItems(
diff: FavoriteAlgoFeedsListDiff,
conflict: ReplaceableBackupConflict,
accountViewModel: AccountViewModel,
nav: INav,
) {
val saved = (conflict.saved as? FavoriteAlgoFeedsListEvent)?.publicFavoriteAlgoFeeds().orEmpty()
spaceGrid("feed-", fates(saved, diff.feeds) { it.address }, { it.address.toValue() }) { bookmark, fate, modifier ->
AddressableSpaceTile(bookmark.address, fate, accountViewModel, modifier) {
nav.nav { accountViewModel.getAddressableNoteIfExists(bookmark.address)?.let { routeFor(it, accountViewModel.account) } }
}
}
privateItemsCard(diff.privateItems)
}
/** A community or feed tile: loads the definition event from relays for its name and picture. */
@Composable
private fun AddressableSpaceTile(
address: Address,
fate: ItemFate,
accountViewModel: AccountViewModel,
modifier: Modifier,
onClick: () -> Unit,
) {
LoadAddressableNote(address, accountViewModel) { note ->
val event =
if (note != null) {
val state by observeNote(note, accountViewModel)
state.note.event
} else {
null
}
val (name, picture) =
when (event) {
is CommunityDefinitionEvent -> event.name() to event.image()?.imageUrl
is AppDefinitionEvent -> event.appMetaData().let { (it?.name ?: it?.displayName) to (it?.picture ?: it?.image) }
else -> null to null
}
SpaceTile(
name = name ?: address.dTag.ifBlank { address.toValue().toShortDisplay() },
subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) R.string.backup_review_feed else R.string.backup_type_communities),
robot = address.toValue(),
picture = picture,
fate = fate,
accountViewModel = accountViewModel,
modifier = modifier,
onClick = onClick,
)
}
}
private fun LazyListScope.ephemeralRoomItems(
diff: EphemeralChatListDiff,
conflict: ReplaceableBackupConflict,
nav: INav,
) {
val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty()
val rooms = fates(saved, diff.rooms) { it }
countTiles(rooms, R.string.backup_review_left, R.string.backup_review_joined)
items(rooms, key = { "room-" + it.first.toKey() }, contentType = { "room-row" }) { (room, fate) -> RoomRow(room, fate, nav) }
privateItemsCard(diff.privateItems)
}
@Composable
private fun RoomRow(
room: RoomId,
fate: ItemFate,
nav: INav,
) {
val color = fateColor(fate)
Row(
Pad
.padding(top = 8.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable { nav.nav(routeFor(room)) }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(color.copy(alpha = 0.14f)), contentAlignment = Alignment.Center) {
Text("#", fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, color = color)
}
Column(Modifier.weight(1f).alpha(if (fate == ItemFate.DROPPED) 0.55f else 1f)) {
Text(room.id, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
room.relayUrl.url
.removePrefix("wss://")
.removeSuffix("/"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
when (fate) {
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), color)
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
else -> {}
}
}
}
// ---------------------------------------------------------------------------------------
// Followed hashtags and places: a pill cloud of what stays, goes and arrives.
// ---------------------------------------------------------------------------------------
@OptIn(ExperimentalLayoutApi::class)
private fun LazyListScope.topicItems(
diff: ListDiff<String>,
saved: List<String>,
privateItems: ContentChange,
isPlace: Boolean,
nav: INav,
) {
val topics = fates(saved, diff) { it.lowercase() }
countTiles(topics, if (isPlace) R.string.backup_review_places_gone else R.string.backup_review_topics_gone, R.string.backup_review_new_count_caption)
item(key = "topic-cloud", contentType = "cloud") {
val tones = conflictTones()
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionCaption(stringRes(if (isPlace) R.string.backup_review_places_you_follow else R.string.backup_review_hashtags_you_follow))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) {
topics.forEach { (topic, fate) ->
val color = fateColor(fate)
val shape = RoundedCornerShape(16.dp)
Row(
Modifier
.clip(shape)
.background(
when (fate) {
ItemFate.KEPT -> MaterialTheme.colorScheme.surfaceContainer
else -> color.copy(alpha = 0.12f)
},
).then(if (fate == ItemFate.DROPPED) Modifier.border(1.dp, tones.removed.copy(alpha = 0.5f), shape) else Modifier)
.clickable { nav.nav(if (isPlace) Route.Geohash(topic) else Route.Hashtag(topic)) }
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (isPlace) Icon(symbol = MaterialSymbols.LocationOn, contentDescription = null, tint = color, modifier = Modifier.size(16.dp))
val decoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null
if (isPlace) {
// Like the rest of the app: the city the geohash resolves to next to
// its code, and just the code until (or unless) the name resolves.
LoadCityName(topic, onLoading = { TopicText("#$topic", color, decoration) }) { city ->
TopicText(if (city == topic) "#$topic" else "$city · #$topic", color, decoration)
}
} else {
TopicText("#$topic", color, decoration)
}
}
}
}
}
}
privateItemsCard(privateItems)
}
@Composable
private fun TopicText(
text: String,
color: Color,
decoration: TextDecoration?,
) {
Text(
text,
color = color,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = decoration,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// ---------------------------------------------------------------------------------------
// Trust providers: one row per service, who provided it before and who does now.
// ---------------------------------------------------------------------------------------
private class ServiceRow(
val service: String,
val before: HexKey?,
val after: HexKey?,
)
private fun LazyListScope.trustProviderItems(
diff: TrustProviderListDiff,
conflict: ReplaceableBackupConflict,
accountViewModel: AccountViewModel,
nav: INav,
) {
// A service can have several providers, so each side is a set per service: providers on
// both sides are kept rows, and the ones only before or only after are paired up as swaps.
val before = (conflict.saved as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey })
val after = (conflict.incoming as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey })
val services = (before.keys + after.keys).distinct()
val rows =
services.flatMap { service ->
val had = before[service].orEmpty().distinct()
val has = after[service].orEmpty().distinct()
val gone = had - has.toSet()
val new = has - had.toSet()
had.filter { it in has }.map { ServiceRow(service, it, it) } +
List(maxOf(gone.size, new.size)) { ServiceRow(service, gone.getOrNull(it), new.getOrNull(it)) }
}
val affected =
rows
.filter { it.before != it.after }
.map { it.service }
.distinct()
.size
item(key = "trust-hero", contentType = "hero") {
val tones = conflictTones()
TintedPanel(MaterialTheme.colorScheme.surfaceContainer, Pad.padding(top = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(tones.changedContainer()), contentAlignment = Alignment.Center) {
Icon(symbol = MaterialSymbols.Group, contentDescription = null, tint = tones.changed)
}
Column {
Text(stringRes(R.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
Text(stringRes(R.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
}
item(key = "trust-header", contentType = "table-header") {
Row(Pad.padding(top = 16.dp, bottom = 4.dp).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(R.string.backup_review_service, R.string.backup_review_saved, R.string.backup_review_new).forEachIndexed { index, res ->
Text(stringRes(res).uppercase(), Modifier.weight(if (index == 0) 1.1f else 1f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
items(rows, key = { "service-" + it.service + "-" + it.before + "-" + it.after }, contentType = { "service-row" }) { row -> ServiceTableRow(row, accountViewModel, nav) }
item(key = "trust-note", contentType = "note") {
val tones = conflictTones()
Row(
Pad
.padding(top = 14.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(tones.changedContainer())
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
Text(stringRes(R.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed)
}
}
privateItemsCard(diff.privateItems)
}
@Composable
private fun ServiceTableRow(
row: ServiceRow,
accountViewModel: AccountViewModel,
nav: INav,
) {
val tones = conflictTones()
val tint =
when {
row.before == row.after -> MaterialTheme.colorScheme.surfaceContainer
row.after == null -> tones.removedContainer()
row.before == null -> tones.addedContainer()
else -> tones.changedContainer()
}
Row(
Pad
.padding(top = 6.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(tint)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(serviceLabel(row.service), Modifier.weight(1.1f), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, maxLines = 2)
ProviderCell(row.before, struck = row.before != row.after, color = tones.removed, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f))
ProviderCell(row.after, struck = false, color = if (row.before == row.after) MaterialTheme.colorScheme.onSurface else tones.added, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f))
}
}
@Composable
private fun serviceLabel(type: String): String =
when (type) {
"rank" -> stringRes(R.string.backup_review_service_rank)
"followers" -> stringRes(R.string.backup_review_service_followers)
"t" -> stringRes(R.string.backup_review_service_topics)
else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
@Composable
private fun ProviderCell(
pubKey: HexKey?,
struck: Boolean,
color: Color,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier,
) {
if (pubKey == null) {
Text("—", modifier, color = MaterialTheme.colorScheme.placeholderText)
return
}
LoadUser(pubKey, accountViewModel) { user ->
Row(
modifier.clip(RoundedCornerShape(8.dp)).clickable { nav.nav(Route.Profile(pubKey)) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RobohashFallbackAsyncImage(
robot = pubKey,
// Observed, so the picture appears when the provider's metadata arrives.
model = observedPicture(pubKey, accountViewModel),
contentDescription = null,
modifier = Modifier.size(22.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(color = if (struck) color else MaterialTheme.colorScheme.onSurface, textDecoration = if (struck) TextDecoration.LineThrough else null)) {
if (user != null) {
UsernameDisplay(user, fontWeight = FontWeight.Medium, textColor = if (struck) color else Color.Unspecified, accountViewModel = accountViewModel)
} else {
Text(pubKey.toShortDisplay(), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
// ---------------------------------------------------------------------------------------
// Payment targets and BOLT12 offers: the ways people pay you, as cards.
// ---------------------------------------------------------------------------------------
private fun LazyListScope.paymentTargetItems(
diff: PaymentTargetsDiff,
conflict: ReplaceableBackupConflict,
) {
val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty()
val targets = fates(saved, diff.targets) { it }
moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_payment_targets_gone)
items(targets, key = { "target-" + it.first.type + ":" + it.first.authority }, contentType = { "money-row" }) { (target, fate) -> PaymentTargetRow(target, fate) }
}
private fun LazyListScope.offerItems(
diff: Bolt12OfferListDiff,
conflict: ReplaceableBackupConflict,
) {
val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty()
val offers = fates(saved, diff.offers) { it }
moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_offers_gone)
items(offers, key = { "offer-" + it.first }, contentType = { "money-row" }) { (offer, fate) ->
MoneyRow(stringRes(R.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate)
}
}
private fun LazyListScope.moneyHero(
dropped: Int,
headline: Int,
) {
item(key = "money-hero", contentType = "hero") {
val tones = conflictTones()
val color = if (dropped > 0) tones.removed else tones.added
TintedPanel(color.copy(alpha = 0.12f), Pad.padding(top = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(color.copy(alpha = 0.18f)), contentAlignment = Alignment.Center) {
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = color)
}
Column {
Text(pluralStringResource(headline, dropped, dropped), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color)
Text(stringRes(R.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
}
}
@Composable
private fun PaymentTargetRow(
target: PaymentTarget,
fate: ItemFate,
) = MoneyRow(target.type.replaceFirstChar { it.uppercase() }, target.authority, fate)
@Composable
private fun MoneyRow(
kind: String,
value: String,
fate: ItemFate,
) {
val color = fateColor(fate)
Row(
Pad
.padding(top = 8.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) {
Text(kind.take(1), fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.placeholderText)
}
Column(Modifier.weight(1f)) {
Text(kind, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = color,
textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
when (fate) {
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_dropped_tag), color)
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
else -> {}
}
}
}
// ---------------------------------------------------------------------------------------
// Encrypted-only events (Cashu wallet, Concord list): one explained panel.
// ---------------------------------------------------------------------------------------
private fun LazyListScope.encryptedOnlyItems(
icon: MaterialSymbol,
change: ContentChange,
body: Int,
) {
item(key = "encrypted-hero", contentType = "hero") {
val tones = conflictTones()
val (color, headline) =
when (change) {
ContentChange.CLEARED -> tones.removed to R.string.backup_review_encrypted_cleared
ContentChange.ADDED -> tones.added to R.string.backup_review_encrypted_added
else -> tones.changed to R.string.backup_review_encrypted_changed
}
Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
TintedPanel(color.copy(alpha = 0.12f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(symbol = icon, contentDescription = null, tint = color, modifier = Modifier.size(44.dp))
Text(stringRes(headline), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color)
}
}
PrivateItemsCard(change)
Text(stringRes(body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
@@ -0,0 +1,485 @@
/*
* 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.backups
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ItemChange
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
import com.vitorpamplona.quartz.nip01Core.metadata.Birthday
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
private const val MAX_VALUE_LENGTH = 80
/** Items lost, gained and changed in a conflict, counting encrypted content as one item. */
@Immutable
class ConflictCounts(
val removed: Int,
val added: Int,
val changed: Int,
)
private fun ContentChange.asCounts() =
ConflictCounts(
removed = if (this == ContentChange.CLEARED) 1 else 0,
added = if (this == ContentChange.ADDED) 1 else 0,
changed = if (this == ContentChange.CHANGED) 1 else 0,
)
private operator fun ConflictCounts.plus(other: ConflictCounts) = ConflictCounts(removed + other.removed, added + other.added, changed + other.changed)
private fun ListDiff<*>.asCounts() = ConflictCounts(removed.size, added.size, changed.size)
private fun ValueChange<*>?.asCounts() =
when {
this == null -> ConflictCounts(0, 0, 0)
after == null -> ConflictCounts(1, 0, 0)
before == null -> ConflictCounts(0, 1, 0)
else -> ConflictCounts(0, 0, 1)
}
/**
* The counts straight from a diff's own lists, without building display items: what the
* Home cards need, however large the conflict.
*/
fun countsOf(diff: EventDiff): ConflictCounts =
when (diff) {
is MetadataDiff ->
listOf(diff.name, diff.displayName, diff.picture, diff.banner, diff.website, diff.about, diff.pronouns, diff.nip05, diff.lud06, diff.lud16, diff.clinkOffer, diff.bot, diff.birthday)
.fold(diff.otherFields.asCounts() + diff.identityClaims.asCounts()) { acc, change -> acc + change.asCounts() }
is ContactListDiff -> diff.follows.asCounts()
is MuteListDiff -> diff.publicMutes.asCounts() + diff.privateItems.asCounts()
is AdvertisedRelayListDiff -> diff.relays.asCounts()
is RelayListDiff -> diff.relays.asCounts() + diff.privateRelays.asCounts()
is ChannelListDiff -> diff.channels.asCounts() + diff.privateItems.asCounts()
is CommunityListDiff -> diff.communities.asCounts() + diff.privateItems.asCounts()
is HashtagListDiff -> diff.hashtags.asCounts() + diff.privateItems.asCounts()
is GeohashListDiff -> diff.geohashes.asCounts() + diff.privateItems.asCounts()
is FavoriteAlgoFeedsListDiff -> diff.feeds.asCounts() + diff.privateItems.asCounts()
is EphemeralChatListDiff -> diff.rooms.asCounts() + diff.privateItems.asCounts()
is SimpleGroupListDiff -> diff.groups.asCounts() + diff.privateItems.asCounts()
is TrustProviderListDiff -> diff.providers.asCounts() + diff.privateItems.asCounts()
is NutzapInfoDiff -> diff.mints.asCounts() + diff.relays.asCounts() + diff.p2pkPubkey.asCounts()
is PaymentTargetsDiff -> diff.targets.asCounts()
is Bolt12OfferListDiff -> diff.offers.asCounts()
is CashuWalletDiff -> diff.wallet.asCounts()
is ConcordCommunityListDiff -> diff.communities.asCounts()
is AppSpecificDataDiff -> diff.data.asCounts()
else -> ConflictCounts(0, 0, 0)
}
/**
* One entry of a conflict, typed by what it points to so the review screen can load it from
* relays and link to it. [detail] is extra text: a petname, a relay marker, before → after.
*/
@Immutable
sealed interface ReviewItem {
val detail: String?
class Person(
val pubKey: HexKey,
override val detail: String? = null,
) : ReviewItem
class Thread(
val eventId: HexKey,
override val detail: String? = null,
) : ReviewItem
class PublicChat(
val channelId: HexKey,
override val detail: String? = null,
) : ReviewItem
class Addressable(
val address: Address,
override val detail: String? = null,
) : ReviewItem
class ChatRoom(
val roomId: RoomId,
override val detail: String? = null,
) : ReviewItem
class Relay(
val url: NormalizedRelayUrl,
override val detail: String? = null,
) : ReviewItem
class Text(
val text: String,
override val detail: String? = null,
) : ReviewItem
}
/** One labelled group of differences (people, relays, profile fields…) of a conflict. */
@Immutable
class DiffGroup(
val label: Int,
val removed: List<ReviewItem>,
val added: List<ReviewItem>,
val changed: List<ReviewItem>,
) {
fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty()
}
/** Everything a conflict shows: its non-empty groups and the change to its encrypted content. */
@Immutable
class DiffPresentation(
val groups: List<DiffGroup>,
val content: ContentChange,
) {
val removedCount = groups.sumOf { it.removed.size }
val addedCount = groups.sumOf { it.added.size }
val changedCount = groups.sumOf { it.changed.size }
}
/** A group from a [ListDiff] of an event's parsed items. */
private fun <T> listGroup(
label: Int,
diff: ListDiff<T>,
item: (T) -> ReviewItem,
change: (ItemChange<T>) -> ReviewItem,
) = DiffGroup(label, diff.removed.map(item), diff.added.map(item), diff.changed.map(change))
/** A group whose items can't change, only be added or removed (hashtags, plain relays…). */
private fun <T> listGroup(
label: Int,
diff: ListDiff<T>,
item: (T) -> ReviewItem,
) = listGroup(label, diff, item) { item(it.after) }
/** Keeps only the items of [T] from a list diff of a sealed family (e.g. one kind of [MuteTag]). */
private inline fun <reified T> ListDiff<*>.only() =
ListDiff(
removed.filterIsInstance<T>(),
added.filterIsInstance<T>(),
changed.filter { it.before is T }.map { ItemChange(it.before as T, it.after as T) },
)
/** A group from single-valued fields: a field set only after is added, only before is removed. */
private fun fieldGroup(
label: Int,
fields: List<Pair<String, ValueChange<String>?>>,
): DiffGroup {
val removed = mutableListOf<ReviewItem>()
val added = mutableListOf<ReviewItem>()
val changed = mutableListOf<ReviewItem>()
fields.forEach { (name, change) ->
if (change == null) return@forEach
val before = change.before
val after = change.after
when {
before != null && after == null -> removed.add(ReviewItem.Text(name, clip(before)))
before == null && after != null -> added.add(ReviewItem.Text(name, clip(after)))
else -> changed.add(ReviewItem.Text(name, clip(before) + " → " + clip(after)))
}
}
return DiffGroup(label, removed, added, changed)
}
private fun arrow(
before: String?,
after: String?,
) = (before ?: "∅") + " → " + (after ?: "∅")
/**
* Each event's own diff as typed, labelled groups, built once per diff: it maps every item,
* which for a large list is too much work to redo on each recomposition.
*/
@Composable
fun rememberPresentation(diff: EventDiff): DiffPresentation {
val context = LocalContext.current
return remember(diff, context) { presentationOf(diff) { context.getString(it) } }
}
/** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */
fun presentationOf(
diff: EventDiff,
str: (Int) -> String,
): DiffPresentation {
val (groups, content) =
when (diff) {
is MetadataDiff -> {
val birthday = diff.birthday?.let { ValueChange(it.before?.let(::birthdayText), it.after?.let(::birthdayText)) }
val bot = diff.bot?.let { ValueChange(it.before?.toString(), it.after?.toString()) }
listOf(
fieldGroup(
R.string.backup_entry_profile_field,
listOf(
str(R.string.backup_profile_field_name) to diff.name,
str(R.string.backup_profile_field_display_name) to diff.displayName,
str(R.string.backup_profile_field_about) to diff.about,
str(R.string.backup_profile_field_picture) to diff.picture,
str(R.string.backup_profile_field_banner) to diff.banner,
str(R.string.backup_profile_field_website) to diff.website,
str(R.string.backup_profile_field_nip05) to diff.nip05,
str(R.string.backup_profile_field_lud16) to diff.lud16,
str(R.string.backup_profile_field_lud06) to diff.lud06,
str(R.string.backup_profile_field_clink_offer) to diff.clinkOffer,
str(R.string.backup_profile_field_pronouns) to diff.pronouns,
str(R.string.backup_profile_field_birthday) to birthday,
str(R.string.backup_profile_field_bot) to bot,
),
),
listGroup(
R.string.backup_entry_identity_claim,
diff.identityClaims,
{ ReviewItem.Text(it.platformIdentity()) },
{ ReviewItem.Text(it.after.platformIdentity(), arrow(it.before.proof, it.after.proof)) },
),
listGroup(
R.string.backup_entry_other,
diff.otherFields,
{ ReviewItem.Text(it.first, clip(it.second)) },
{ ReviewItem.Text(it.after.first, clip(it.before.second) + " → " + clip(it.after.second)) },
),
) to ContentChange.NONE
}
is ContactListDiff ->
listOf(
listGroup(
R.string.backup_entry_person,
diff.follows,
{ ReviewItem.Person(it.pubKey, it.petname) },
{
ReviewItem.Person(
it.after.pubKey,
arrow(
listOfNotNull(it.before.petname, it.before.relayUri?.url).joinToString().ifEmpty { null },
listOfNotNull(it.after.petname, it.after.relayUri?.url).joinToString().ifEmpty { null },
),
)
},
),
) to ContentChange.NONE
is MuteListDiff ->
listOf(
listGroup(R.string.backup_entry_person, diff.publicMutes.only<UserTag>(), { ReviewItem.Person(it.pubKey) }),
listGroup(R.string.backup_entry_word, diff.publicMutes.only<WordTag>(), { ReviewItem.Text("\"" + it.word + "\"") }),
listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only<HashtagTag>(), { ReviewItem.Text("#" + it.hashtag) }),
listGroup(R.string.backup_entry_thread, diff.publicMutes.only<EventTag>(), { ReviewItem.Thread(it.eventId) }),
) to diff.privateItems
is AdvertisedRelayListDiff -> {
val types =
mapOf(
AdvertisedRelayType.BOTH to str(R.string.backup_conflict_relay_read_write),
AdvertisedRelayType.READ to str(R.string.backup_conflict_relay_read_only),
AdvertisedRelayType.WRITE to str(R.string.backup_conflict_relay_write_only),
)
listOf(
listGroup(
R.string.backup_entry_relay,
diff.relays,
{ ReviewItem.Relay(it.relayUrl, types[it.type]) },
{ ReviewItem.Relay(it.after.relayUrl, arrow(types[it.before.type], types[it.after.type])) },
),
) to ContentChange.NONE
}
is RelayListDiff -> listOf(listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays
is ChannelListDiff ->
listOf(
listGroup(
R.string.backup_entry_public_chat,
diff.channels,
{ ReviewItem.PublicChat(it.eventId) },
{ ReviewItem.PublicChat(it.after.eventId, arrow(it.before.relay?.url, it.after.relay?.url)) },
),
) to diff.privateItems
is CommunityListDiff ->
listOf(
listGroup(
R.string.backup_entry_community,
diff.communities,
{ ReviewItem.Addressable(it.address) },
{ ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) },
),
) to diff.privateItems
is HashtagListDiff -> listOf(listGroup(R.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems
is GeohashListDiff -> listOf(listGroup(R.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems
is FavoriteAlgoFeedsListDiff ->
listOf(
listGroup(
R.string.backup_entry_algo_feed,
diff.feeds,
{ ReviewItem.Addressable(it.address) },
{ ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) },
),
) to diff.privateItems
is EphemeralChatListDiff -> listOf(listGroup(R.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems
is SimpleGroupListDiff ->
listOf(
listGroup(
R.string.backup_entry_relay_group,
diff.groups,
{ ReviewItem.Text(it.name ?: it.groupId, it.relayUrl) },
{ ReviewItem.Text(it.after.name ?: it.after.groupId, arrow(it.before.name, it.after.name)) },
),
) to diff.privateItems
is TrustProviderListDiff ->
listOf(
listGroup(
R.string.backup_entry_trust_provider,
diff.providers,
{ ReviewItem.Person(it.pubkey, it.service.type) },
{ ReviewItem.Person(it.after.pubkey, it.after.service.type + ": " + arrow(it.before.relayUrl.url, it.after.relayUrl.url)) },
),
) to diff.privateItems
is NutzapInfoDiff ->
listOf(
listGroup(
R.string.backup_entry_mint,
diff.mints,
{ ReviewItem.Text(it.mintUrl, it.units.joinToString().ifEmpty { null }) },
{ ReviewItem.Text(it.after.mintUrl, arrow(it.before.units.joinToString(), it.after.units.joinToString())) },
),
listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) }),
nutzapKeyGroup(diff.p2pkPubkey),
) to ContentChange.NONE
is PaymentTargetsDiff ->
listOf(
listGroup(R.string.backup_entry_payment_target, diff.targets, { ReviewItem.Text(it.type, clip(it.authority)) }),
) to ContentChange.NONE
is Bolt12OfferListDiff -> listOf(listGroup(R.string.backup_entry_offer, diff.offers, { ReviewItem.Text(clip(it)) })) to ContentChange.NONE
is CashuWalletDiff -> emptyList<DiffGroup>() to diff.wallet
is ConcordCommunityListDiff -> emptyList<DiffGroup>() to diff.communities
is AppSpecificDataDiff -> emptyList<DiffGroup>() to diff.data
else -> emptyList<DiffGroup>() to ContentChange.NONE
}
return DiffPresentation(groups.filterNot { it.isEmpty() }, content)
}
private fun nutzapKeyGroup(change: ValueChange<HexKey>?): DiffGroup {
val none = emptyList<ReviewItem>()
if (change == null) return DiffGroup(R.string.backup_entry_nutzap_key, none, none, none)
val before = change.before
val after = change.after
return when {
before != null && after == null -> DiffGroup(R.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none)
before == null && after != null -> DiffGroup(R.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none)
else -> DiffGroup(R.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after))))
}
}
private fun birthdayText(birthday: Birthday): String = listOfNotNull(birthday.year, birthday.month, birthday.day).joinToString("-")
fun clip(text: String?): String {
if (text == null) return ""
val oneLine = text.replace('\n', ' ')
return if (oneLine.length > MAX_VALUE_LENGTH) oneLine.take(MAX_VALUE_LENGTH) + "…" else oneLine
}
fun eventTypeName(type: BackupEventType): Int =
when (type) {
BackupEventType.PROFILE -> R.string.backup_type_profile
BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list
BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list
BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays
BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays
BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays
BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays
BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays
BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds
BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays
BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays
BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays
BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings
BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats
BackupEventType.COMMUNITIES -> R.string.backup_type_communities
BackupEventType.HASHTAGS -> R.string.backup_type_hashtags
BackupEventType.GEOHASHES -> R.string.backup_type_geohashes
BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds
BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats
BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups
BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities
BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers
BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet
BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info
BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets
BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers
BackupEventType.OTHER -> R.string.backup_type_other
}
fun eventTypeExplainer(type: BackupEventType): Int =
when (type) {
BackupEventType.PROFILE -> R.string.backup_type_profile_explainer
BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list_explainer
BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list_explainer
BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays_explainer
BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays_explainer
BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays_explainer
BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays_explainer
BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays_explainer
BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds_explainer
BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays_explainer
BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays_explainer
BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays_explainer
BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings_explainer
BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats_explainer
BackupEventType.COMMUNITIES -> R.string.backup_type_communities_explainer
BackupEventType.HASHTAGS -> R.string.backup_type_hashtags_explainer
BackupEventType.GEOHASHES -> R.string.backup_type_geohashes_explainer
BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds_explainer
BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats_explainer
BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups_explainer
BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities_explainer
BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers_explainer
BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet_explainer
BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info_explainer
BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets_explainer
BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers_explainer
BackupEventType.OTHER -> R.string.backup_type_other_explainer
}
@@ -0,0 +1,523 @@
/*
* 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.backups
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
/** A row of the review list, flattened so hundreds of entries scroll lazily. */
@Immutable
internal sealed interface ReviewRow {
class Section(
val titleRes: Int,
val count: Int,
val tone: Tone,
) : ReviewRow
class Note(
val textRes: Int,
) : ReviewRow
class Group(
val labelRes: Int,
) : ReviewRow
class Entry(
val item: ReviewItem,
) : ReviewRow
}
internal enum class Tone { REMOVED, ADDED, CHANGED }
internal fun buildRows(
presentation: DiffPresentation,
skipGroups: Set<Int> = emptySet(),
): List<ReviewRow> {
val rows = mutableListOf<ReviewRow>()
fun section(
titleRes: Int,
count: Int,
tone: Tone,
contentNote: Int?,
items: (DiffGroup) -> List<ReviewItem>,
) {
if (count == 0 && contentNote == null) return
rows.add(ReviewRow.Section(titleRes, count, tone))
contentNote?.let { rows.add(ReviewRow.Note(it)) }
presentation.groups.forEach { group ->
if (group.label in skipGroups) return@forEach
val entries = items(group)
if (entries.isNotEmpty()) {
rows.add(ReviewRow.Group(group.label))
entries.forEach { rows.add(ReviewRow.Entry(it)) }
}
}
}
val content = presentation.content
section(R.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, R.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed }
section(R.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, R.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added }
section(R.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, R.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed }
return rows
}
/**
* Every entry of a backup conflict, removed / added / changed, in a lazy list: people,
* notes, communities, feeds, chats and relays are loaded from relays and open their own
* screens when tapped, so the user can investigate the change before deciding. Opened from
* the Home cards ([BackupConflictCards]); deciding resolves the conflict and removes its card.
* Leaving without deciding keeps the card and the frozen backup.
*/
@Composable
fun BackupConflictReviewScreen(
slot: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val settings = accountViewModel.account.settings
val conflicts by settings.backupConflicts.collectAsStateWithLifecycle()
val conflict = conflicts[slot]
// The only way out after a decision: resolving (from here or elsewhere) removes the
// conflict, and this pops the screen exactly once. The buttons don't pop themselves,
// which would pop a second screen, and a stale conflict (replaced by a newer one) is
// simply shown in its place.
val popped = remember { mutableStateOf(false) }
if (conflict == null) {
LaunchedEffect(Unit) {
if (!popped.value) {
popped.value = true
nav.popBack()
}
}
return
}
BackupConflictReview(
conflict = conflict,
accountViewModel = accountViewModel,
nav = nav,
// A read-only account can't sign the saved version again, so it can only keep the new one.
onRestore = if (accountViewModel.isWriteable()) ({ accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }) else null,
onKeepNew = { accountViewModel.acceptExternalBackupVersion(conflict) },
)
}
@Composable
private fun BackupConflictReview(
conflict: ReplaceableBackupConflict,
accountViewModel: AccountViewModel,
nav: INav,
onRestore: (() -> Unit)?,
onKeepNew: () -> Unit,
) {
val presentation = rememberPresentation(conflict.diff)
val ui = remember(conflict.slot) { ReviewUiState() }
val (keepLabel, restoreLabel) = actionLabels(conflict)
Scaffold(
topBar = {
TopBarWithBackButton(eventTitle(conflict), nav)
},
bottomBar = { ReviewActions(keepLabel, restoreLabel, onRestore, onKeepNew) },
) { padding ->
val layoutDirection = LocalLayoutDirection.current
LazyColumn(
// Scaffold hands the content its insets (status bar, the bottom action bar and,
// in landscape, a side 3-button navigation bar); consume them so the IME padding
// below only adds what the keyboard covers beyond them.
modifier =
Modifier
.fillMaxSize()
.consumeWindowInsets(padding)
.imePaddingSafe(),
contentPadding =
PaddingValues(
start = padding.calculateStartPadding(layoutDirection),
top = padding.calculateTopPadding(),
end = padding.calculateEndPadding(layoutDirection),
bottom = padding.calculateBottomPadding() + 16.dp,
),
) {
item(key = "intro", contentType = "intro") {
BackupConflictIntro(conflict, Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
}
eventDiffItems(conflict, presentation, ui, accountViewModel, nav)
}
}
}
/** The default layout: removed / added / changed sections of labelled, typed entries. */
internal fun LazyListScope.genericDiffItems(
rows: List<ReviewRow>,
accountViewModel: AccountViewModel,
nav: INav,
) {
itemsIndexed(
rows,
contentType = { _, row ->
when (row) {
is ReviewRow.Section -> "section"
is ReviewRow.Note -> "note"
is ReviewRow.Group -> "group"
is ReviewRow.Entry -> row.item::class.simpleName
}
},
) { _, row ->
when (row) {
is ReviewRow.Section -> SectionRow(row)
is ReviewRow.Note -> DetailText("• " + stringRes(row.textRes), Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
is ReviewRow.Group -> GroupLabel(row.labelRes)
is ReviewRow.Entry -> ReviewEntry(row.item, accountViewModel, nav)
}
}
}
@Composable
internal fun GroupLabel(labelRes: Int) {
Text(
text = stringRes(labelRes),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 4.dp),
)
}
@Composable
internal fun SectionRow(row: ReviewRow.Section) {
val color =
when (row.tone) {
Tone.REMOVED -> MaterialTheme.colorScheme.error
Tone.ADDED -> MaterialTheme.colorScheme.primary
Tone.CHANGED -> MaterialTheme.colorScheme.tertiary
}
HorizontalDivider()
Text(
text = stringRes(row.titleRes, row.count.toString()),
style = MaterialTheme.typography.titleMedium,
color = color,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
)
}
@Composable
private fun ReviewActions(
keepLabel: String,
restoreLabel: String,
onRestore: (() -> Unit)?,
onKeepNew: () -> Unit,
) {
Surface(tonalElevation = 3.dp) {
Row(
// Scaffold only insets its content slot; a bottomBar handles its own. The padding
// goes on the content, not the Surface, so the bar's background still reaches the
// screen edge while the buttons sit above the 3-button navigation (and above the
// keyboard, if one is up) instead of underneath it.
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePaddingSafe()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
// Keeping the new version is the default: it's what is on relays already. Restoring
// always reverts another app's change, so it is the red, secondary choice.
if (onRestore != null) {
OutlinedButton(
onClick = onRestore,
modifier = Modifier.weight(1f).height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) {
Text(restoreLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold)
}
}
Button(onClick = onKeepNew, modifier = Modifier.weight(1f).height(52.dp)) {
Text(keepLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold)
}
}
}
}
/** "Follow list updated", "Mute list cleared"…: the event and what happened to it, as a title. */
@Composable
internal fun eventTitle(conflict: ReplaceableBackupConflict): String {
val name = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() }
// Says what happened to it, not just what it is: "Community list updated".
val emptied = conflict.incoming.tags.isEmpty() && conflict.incoming.content.isEmpty()
return stringRes(if (emptied) R.string.backup_review_title_cleared else R.string.backup_review_title_updated, name)
}
/** The two choices, worded for what they do to this event: "Keep 120" / "Restore 523". */
@Composable
private fun actionLabels(conflict: ReplaceableBackupConflict): Pair<String, String> =
when (val diff = conflict.diff) {
is ContactListDiff -> {
val (saved, new) = conflict.followCounts
stringRes(R.string.backup_action_keep_count, new.toString()) to stringRes(R.string.backup_action_restore_count, saved.toString())
}
is MuteListDiff -> {
val removed = diff.publicMutes.removed.size
stringRes(R.string.backup_conflict_keep_new) to
if (removed > 0) stringRes(R.string.backup_action_remute, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
}
is SimpleGroupListDiff -> {
val removed = diff.groups.removed.size
stringRes(R.string.backup_conflict_keep_new) to
if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
}
is MetadataDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_profile)
is RelayListDiff ->
if (conflict.eventType == BackupEventType.BLOCKED_RELAYS && diff.relays.removed.isNotEmpty()) {
stringRes(R.string.backup_conflict_keep_new) to
stringRes(
R.string.backup_action_block_again,
diff.relays.removed.size
.toString(),
)
} else {
stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays)
}
is AdvertisedRelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays)
is ChannelListDiff -> rejoinLabels(diff.channels.removed.size)
is CommunityListDiff -> rejoinLabels(diff.communities.removed.size)
is EphemeralChatListDiff -> rejoinLabels(diff.rooms.removed.size)
is FavoriteAlgoFeedsListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_feeds)
is HashtagListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics)
// Geohashes are places, not topics: the screen counts "places gone" under "Followed
// locations", so the button has to agree with the page it sits on.
is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_places)
is TrustProviderListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_providers)
is CashuWalletDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_wallet)
else -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_conflict_restore_mine)
}
@Composable
private fun rejoinLabels(removed: Int): Pair<String, String> =
stringRes(R.string.backup_conflict_keep_new) to
if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
/** When the other app changed it, and what the event is for. */
@Composable
private fun BackupConflictIntro(
conflict: ReplaceableBackupConflict,
modifier: Modifier = Modifier,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
style = MaterialTheme.typography.labelLarge,
color = conflictTones().removed,
)
Text(
text = stringRes(eventTypeExplainer(conflict.eventType)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
internal val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
@Composable
internal fun ReviewEntry(
item: ReviewItem,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (item) {
is ReviewItem.Person -> PersonEntry(item, accountViewModel, nav)
is ReviewItem.Thread ->
LoadNote(item.eventId, accountViewModel) { note ->
if (note != null) {
Column(EntryPadding) {
NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav)
item.detail?.let { DetailText(it) }
}
} else {
TextEntry(item.eventId.toShortDisplay(), item.detail)
}
}
is ReviewItem.Addressable ->
LoadAddressableNote(item.address, accountViewModel) { note ->
if (note != null) {
Column(EntryPadding) {
NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav)
item.detail?.let { DetailText(it) }
}
} else {
TextEntry(item.address.dTag.ifBlank { item.address.toValue() }, item.detail)
}
}
is ReviewItem.PublicChat -> PublicChatEntry(item, accountViewModel, nav)
is ReviewItem.ChatRoom ->
TextEntry(item.roomId.id + " @ " + item.roomId.relayUrl.url, item.detail) { nav.nav(routeFor(item.roomId)) }
is ReviewItem.Relay -> TextEntry(item.url.url, item.detail) { nav.nav(Route.RelayInfo(item.url.url)) }
is ReviewItem.Text -> TextEntry(item.text, item.detail)
}
}
@Composable
private fun PersonEntry(
item: ReviewItem.Person,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadUser(item.pubKey, accountViewModel) { user ->
if (user == null) {
TextEntry(item.pubKey.toShortDisplay(), item.detail)
} else {
Row(
modifier = Modifier.fillMaxWidth().clickable { nav.nav(routeFor(user)) }.then(EntryPadding),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(user, Size35dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.padding(start = 10.dp).weight(1f)) {
// Subscribes to the user's metadata on relays, so unknown people resolve.
UsernameDisplay(user, accountViewModel = accountViewModel)
item.detail?.let { DetailText(it) }
}
}
}
}
}
@Composable
private fun PublicChatEntry(
item: ReviewItem.PublicChat,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadPublicChatChannel(item.channelId, accountViewModel) { channel ->
// Subscribes to the channel's metadata on relays and recomposes when it arrives.
val state by observeChannel(channel, accountViewModel)
val name = state?.channel?.toBestDisplayName() ?: channel.toBestDisplayName()
TextEntry(name, item.detail) { nav.nav(routeFor(channel)) }
}
}
@Composable
internal fun TextEntry(
text: String,
detail: String?,
onClick: (() -> Unit)? = null,
) {
val modifier = if (onClick != null) Modifier.fillMaxWidth().clickable(onClick = onClick) else Modifier.fillMaxWidth()
Column(modifier.then(EntryPadding)) {
Text(
text = text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = if (onClick != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
)
detail?.let { DetailText(it) }
}
}
@Composable
internal fun DetailText(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
@@ -0,0 +1,253 @@
/*
* 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.backups
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.quartz.utils.Hex
/** The three meanings every conflict visual uses: lost, gained, edited. Theme-aware. */
@Immutable
class ConflictTones(
val removed: Color,
val added: Color,
val changed: Color,
val kept: Color,
) {
fun removedContainer() = removed.copy(alpha = 0.14f)
fun addedContainer() = added.copy(alpha = 0.14f)
fun changedContainer() = changed.copy(alpha = 0.14f)
}
@Composable
fun conflictTones(): ConflictTones {
val scheme = MaterialTheme.colorScheme
return ConflictTones(
removed = scheme.error,
added = scheme.allGoodColor,
changed = scheme.warningColor,
kept = scheme.primary,
)
}
/** "523 → 120": the saved size against the new one, the headline of list conflicts. */
@Composable
fun HeroCounts(
savedLabel: String,
saved: Int,
newLabel: String,
new: Int,
modifier: Modifier = Modifier,
) {
val tones = conflictTones()
Row(modifier, verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column {
Text(savedLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
Text(saved.toString(), fontSize = 44.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold)
}
Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp))
Column {
Text(newLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
Text(
new.toString(),
fontSize = 44.sp,
lineHeight = 44.sp,
fontWeight = FontWeight.Bold,
color = if (new < saved) tones.removed else tones.added,
)
}
}
}
/** One segment of a [SplitBar]. */
@Immutable
class BarSegment(
val weight: Int,
val color: Color,
)
/** A single bar split into kept / dropped / new segments, proportional to their counts. */
@Composable
fun SplitBar(
segments: List<BarSegment>,
modifier: Modifier = Modifier,
height: Dp = 14.dp,
) {
val visible = segments.filter { it.weight > 0 }
Row(
modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(height / 2))
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
visible.forEach {
Box(Modifier.weight(it.weight.toFloat()).fillMaxHeight().background(it.color))
}
}
}
/** A colored dot and a label: the legend under a [SplitBar]. */
@Composable
fun LegendItem(
color: Color,
text: String,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Text(text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
}
}
/** A big signed number over a caption, on a tinted tile. */
@Composable
fun StatTile(
value: String,
caption: String,
color: Color,
modifier: Modifier = Modifier,
) {
Column(
modifier
.clip(RoundedCornerShape(16.dp))
.background(color.copy(alpha = 0.12f))
.padding(vertical = 12.dp, horizontal = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(value, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = color)
Text(caption, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, textAlign = TextAlign.Center)
}
}
/** A small uppercase tag: DROPPED, NEW, RENAMED… */
@Composable
fun StatusTag(
text: String,
color: Color,
modifier: Modifier = Modifier,
) {
Text(
text = text.uppercase(),
modifier = modifier.clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.14f)).padding(horizontal = 8.dp, vertical = 3.dp),
color = color,
fontSize = 10.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 0.5.sp,
)
}
/** An uppercase section caption. */
@Composable
fun SectionCaption(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text.uppercase(),
modifier = modifier,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.placeholderText,
)
}
/**
* A 3×3 color fingerprint of a key, so two keys can be told apart at a glance without
* reading hex. Deterministic: the same key always draws the same grid.
*/
@Composable
fun KeyFingerprint(
hex: String,
borderColor: Color,
modifier: Modifier = Modifier,
) {
val bytes = runCatching { Hex.decode(hex) }.getOrNull() ?: ByteArray(0)
val colors =
List(9) { i ->
val b = if (bytes.isEmpty()) 0 else bytes[i % bytes.size].toInt() and 0xFF
val b2 = if (bytes.isEmpty()) 0 else bytes[(i + 9) % bytes.size].toInt() and 0xFF
Color.hsl(hue = b / 255f * 360f, saturation = 0.55f + (b2 % 30) / 100f, lightness = 0.62f)
}
Column(
modifier
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(2.dp)
.clip(RoundedCornerShape(16.dp))
.background(borderColor)
.padding(3.dp)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(6.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
colors.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
row.forEach { Box(Modifier.size(24.dp).clip(RoundedCornerShape(6.dp)).background(it)) }
}
}
}
}
/** A tinted rounded panel, the base of every hero block. */
@Composable
fun TintedPanel(
tint: Color,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(tint)
.padding(18.dp),
) { content() }
}
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -36,6 +38,8 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -45,6 +49,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -52,6 +57,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
@@ -67,6 +73,8 @@ import com.vitorpamplona.amethyst.commons.resources.feed_is_empty
import com.vitorpamplona.amethyst.commons.resources.home_tab_everything
import com.vitorpamplona.amethyst.commons.resources.new_threads
import com.vitorpamplona.amethyst.commons.resources.refresh
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.commons.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
@@ -96,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictCards
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.NewHashtagPostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
@@ -395,7 +404,9 @@ fun HomeFeeds(
nav = nav,
routeForLastRead = routeForLastRead,
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
onEmpty = { HomeFeedEmpty(onRefresh) },
onEmpty = { WithBackupConflicts(accountViewModel, nav) { HomeFeedEmpty(onRefresh) } },
onError = { WithBackupConflicts(accountViewModel, nav) { FeedError(it, onRefresh) } },
onLoading = { WithBackupConflicts(accountViewModel, nav) { LoadingFeed() } },
)
}
}
@@ -428,11 +439,30 @@ fun FeedLoaded(
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val backupConflicts by accountViewModel.account.settings.backupConflicts
.collectAsStateWithLifecycle()
// The list keeps its position anchored to the first visible post, so cards that appear
// while the feed sits at the top would be inserted just above the screen. Only then (not
// when the user has scrolled down) bring them into view.
val hasConflicts = backupConflicts.isNotEmpty()
val atTop by remember(listState) { derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } }
val cardsAppearedAtTop = remember(hasConflicts) { hasConflicts && atTop }
LaunchedEffect(cardsAppearedAtTop) {
if (cardsAppearedAtTop) listState.scrollToItem(0)
}
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
// Only while a conflict is open, so the list's first item (and with it how the
// scroll position anchors when new posts arrive) is unchanged the rest of the time.
if (hasConflicts) {
item(key = "backupConflicts", contentType = "backupConflicts") {
BackupConflictCards(backupConflicts, nav, onAccept = accountViewModel::acceptExternalBackupVersion)
}
}
if (liveSection != null) {
item {
DisplayLiveBubbles(liveSection, accountViewModel, nav)
@@ -513,6 +543,43 @@ fun HomeFeedEmptyPreview() {
)
}
/**
* The backup-conflict cards above a feed that has no posts to show yet, so an empty or
* still-loading Home doesn't hide the question. They get at most half the screen and scroll
* there if there are many.
*/
@Composable
private fun WithBackupConflicts(
accountViewModel: AccountViewModel,
nav: INav,
content: @Composable () -> Unit,
) {
val backupConflicts by accountViewModel.account.settings.backupConflicts
.collectAsStateWithLifecycle()
if (backupConflicts.isEmpty()) {
content()
return
}
val padding = rememberFeedContentPadding(FeedPadding)
val layoutDirection = LocalLayoutDirection.current
Column(Modifier.fillMaxSize()) {
BackupConflictCards(
backupConflicts,
nav,
onAccept = accountViewModel::acceptExternalBackupVersion,
Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
.padding(
start = padding.calculateStartPadding(layoutDirection),
top = padding.calculateTopPadding(),
end = padding.calculateEndPadding(layoutDirection),
),
)
Box(Modifier.weight(1f)) { content() }
}
}
@Composable
fun HomeFeedEmpty(onRefresh: () -> Unit) {
Column(
@@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
@@ -50,7 +49,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isHlsMimeType
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -256,16 +254,11 @@ fun UrlImageView(
val isVideo = content is MediaUrlVideo
val artworkUri = (content as? MediaUrlVideo)?.artworkUri
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
// Coil's VideoFrameDecoder can extract a frame from .mp4/.webm but not from an HLS .m3u8
// playlist (it's a text manifest). For an HLS video without a separate artwork URL, sending
// the playlist to SubcomposeAsyncImage just produces an Error state and a stand-in icon.
// Skip the fetch in that case and render blurhash + play overlay directly.
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModelUrl = artworkUri ?: bridgedUrl
val imageModelUrl = artworkUri ?: content.url
val canLoadAsImage = !isVideo || artworkUri != null || !isHlsMedia(content.url, content.mimeType)
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
+318
View File
@@ -677,4 +677,322 @@
<string name="mute_button">Sound on. Click to mute</string>
<string name="play">Play</string>
<string name="pause">Pause</string>
<string name="backup_conflict_title">You changed your %1$s in another app</string>
<string name="backup_conflict_removed">Removed (%1$s)</string>
<string name="backup_conflict_added">Added (%1$s)</string>
<string name="backup_conflict_changed">Changed (%1$s)</string>
<string name="backup_conflict_private_cleared">You removed all private (encrypted) items.</string>
<string name="backup_conflict_private_changed">You also changed private (encrypted) items. They can\'t be compared here.</string>
<string name="backup_conflict_private_added">You added private (encrypted) items.</string>
<string name="backup_conflict_restore_mine">Restore saved version</string>
<string name="backup_review_saved">On this device</string>
<string name="backup_review_new">New version</string>
<string name="backup_review_not_set">Not set</string>
<string name="backup_conflict_count_removed">%1$s removed</string>
<string name="backup_conflict_count_added">%1$s added</string>
<string name="backup_conflict_count_changed">%1$s changed</string>
<plurals name="backup_card_follows_dropped">
<item quantity="one">You unfollowed %1$d person</item>
<item quantity="other">You unfollowed %1$d people</item>
</plurals>
<string name="backup_card_accept">Accept</string>
<string name="backup_review_title_updated">%1$s updated</string>
<string name="backup_review_title_cleared">%1$s cleared</string>
<string name="backup_review_changed_by_other_app">Changed in another app · %1$s</string>
<string name="backup_action_keep_count">Keep %1$s</string>
<string name="backup_action_restore_count">Restore %1$s</string>
<string name="backup_action_remute">Re-mute %1$s</string>
<string name="backup_action_rejoin">Rejoin %1$s</string>
<string name="backup_action_restore_profile">Restore profile</string>
<string name="backup_action_restore_relays">Restore relays</string>
<string name="backup_review_kept_count">%1$s kept</string>
<string name="backup_review_dropped_count">%1$s dropped</string>
<string name="backup_review_new_count">%1$s new</string>
<string name="backup_review_tab_dropped">Dropped · %1$s</string>
<string name="backup_review_tab_new">New · %1$s</string>
<string name="backup_review_tab_edited">Edited · %1$s</string>
<string name="backup_review_search_people">Search %1$s people</string>
<string name="backup_review_nothing_here">Nothing here</string>
<string name="backup_review_unmuted">%1$s unmuted</string>
<string name="backup_review_unmuted_explainer">These can reach your feeds again</string>
<string name="backup_review_newly_muted">%1$s newly muted</string>
<string name="backup_review_words_and_hashtags">Words &amp; hashtags</string>
<string name="backup_review_names_and_more">%1$s and %2$s more</string>
<string name="backup_review_private_wiped_title">You removed all private items</string>
<string name="backup_review_private_wiped_body">They\'re encrypted, so only you can see what they were. Restoring brings them all back.</string>
<string name="backup_review_private_added_title">You made items private</string>
<string name="backup_review_private_added_body">They\'re encrypted now, which is likely where the public items above went.</string>
<string name="backup_review_private_changed_title">You changed private items</string>
<string name="backup_review_private_changed_body">They\'re encrypted, so they can\'t be compared here.</string>
<string name="backup_review_gone">Gone</string>
<string name="backup_review_new_tag">New</string>
<string name="backup_review_dropped_tag">Dropped</string>
<string name="backup_review_demoted_tag">Moved</string>
<string name="backup_review_units_tag">Units</string>
<string name="backup_review_left_tag">Left</string>
<string name="backup_review_renamed_tag">Renamed</string>
<string name="backup_review_outbox">Outbox</string>
<string name="backup_review_outbox_explainer">where you publish</string>
<string name="backup_review_inbox">Inbox</string>
<string name="backup_review_inbox_explainer">where others reach you</string>
<string name="backup_review_key_replaced">Receiving key replaced</string>
<string name="backup_review_key_removed">Receiving key removed</string>
<string name="backup_review_key_warning">Nutzaps will be locked to this new key. If this device doesn\'t have it, you may not be able to redeem them here.</string>
<string name="backup_review_left">left</string>
<string name="backup_review_joined">joined</string>
<string name="backup_review_renamed">renamed</string>
<string name="backup_review_was_named">was “%1$s”</string>
<string name="backup_action_block_again">Block %1$s again</string>
<string name="backup_action_restore_feeds">Restore feeds</string>
<string name="backup_action_restore_topics">Restore topics</string>
<string name="backup_action_restore_places">Restore places</string>
<string name="backup_action_restore_providers">Restore providers</string>
<string name="backup_action_restore_wallet">Restore wallet</string>
<string name="backup_review_kept">kept</string>
<string name="backup_review_receiving_tag">Receiving</string>
<string name="backup_review_listed_tag">Listed</string>
<string name="backup_review_searching_tag">Searching</string>
<string name="backup_review_storing_tag">Storing</string>
<string name="backup_review_trusted_tag">Trusted</string>
<string name="backup_review_untrusted_tag">Untrusted</string>
<string name="backup_review_unblocked">%1$s unblocked</string>
<string name="backup_review_unblocked_explainer">Amethyst will connect to them again</string>
<string name="backup_review_no_longer_blocked">No longer blocked</string>
<string name="backup_review_newly_blocked">Newly blocked</string>
<string name="backup_review_still_blocked">Still blocked</string>
<string name="backup_review_allowed_tag">Allowed</string>
<string name="backup_review_moved_tag">Moved</string>
<string name="backup_review_feed">Feed</string>
<string name="backup_review_topics_gone">topics gone</string>
<string name="backup_review_places_gone">places gone</string>
<string name="backup_review_new_count_caption">new</string>
<string name="backup_review_hashtags_you_follow">Hashtags you follow</string>
<string name="backup_review_places_you_follow">Places you follow</string>
<string name="backup_review_services_changed">%1$s of %2$s services changed</string>
<string name="backup_review_services_changed_explainer">They now come from someone else, or no one</string>
<string name="backup_review_service">Service</string>
<string name="backup_review_service_rank">Rank</string>
<string name="backup_review_service_followers">Followers</string>
<string name="backup_review_service_topics">Topics</string>
<string name="backup_review_trust_warning">Ranks, follower counts and spam filtering you see come from these providers.</string>
<plurals name="backup_review_payment_targets_gone">
<item quantity="one">%1$d way to pay you removed</item>
<item quantity="other">%1$d ways to pay you removed</item>
</plurals>
<plurals name="backup_review_offers_gone">
<item quantity="one">%1$d offer removed</item>
<item quantity="other">%1$d offers removed</item>
</plurals>
<string name="backup_review_payments_explainer">People paying you only see what\'s in this list.</string>
<string name="backup_review_offer">BOLT12 offer</string>
<string name="backup_review_encrypted_cleared">You emptied it</string>
<string name="backup_review_encrypted_added">You created it</string>
<string name="backup_review_encrypted_changed">You changed it</string>
<string name="backup_review_wallet_body">Your wallet\'s mints and keys are encrypted to you, so they can\'t be compared item by item here. Restoring puts back exactly what this device saved.</string>
<string name="backup_review_concord_body">Your Concord communities are encrypted to you, so they can\'t be compared here. Restoring puts back exactly what this device saved.</string>
<plurals name="backup_card_mutes_visible">
<item quantity="one">You unmuted %1$d thing</item>
<item quantity="other">You unmuted %1$d things</item>
</plurals>
<plurals name="backup_card_profile_lost_fields">
<item quantity="one">You removed %1$d profile field</item>
<item quantity="other">You removed %1$d profile fields</item>
</plurals>
<plurals name="backup_card_nip65_lost">
<item quantity="one">You removed %1$d public relay</item>
<item quantity="other">You removed %1$d public relays</item>
</plurals>
<plurals name="backup_card_dm_relays_lost">
<item quantity="one">You removed %1$d DM relay</item>
<item quantity="other">You removed %1$d DM relays</item>
</plurals>
<plurals name="backup_card_key_package_relays_lost">
<item quantity="one">You removed %1$d group-chat key relay</item>
<item quantity="other">You removed %1$d group-chat key relays</item>
</plurals>
<plurals name="backup_card_search_relays_lost">
<item quantity="one">You removed %1$d search relay</item>
<item quantity="other">You removed %1$d search relays</item>
</plurals>
<plurals name="backup_card_indexer_relays_lost">
<item quantity="one">You removed %1$d indexer relay</item>
<item quantity="other">You removed %1$d indexer relays</item>
</plurals>
<plurals name="backup_card_relay_feeds_lost">
<item quantity="one">You removed %1$d relay feed</item>
<item quantity="other">You removed %1$d relay feeds</item>
</plurals>
<plurals name="backup_card_private_outbox_lost">
<item quantity="one">You removed %1$d private relay</item>
<item quantity="other">You removed %1$d private relays</item>
</plurals>
<plurals name="backup_card_trusted_lost">
<item quantity="one">You stopped trusting %1$d relay</item>
<item quantity="other">You stopped trusting %1$d relays</item>
</plurals>
<plurals name="backup_card_unblocked">
<item quantity="one">You unblocked %1$d relay</item>
<item quantity="other">You unblocked %1$d relays</item>
</plurals>
<plurals name="backup_card_leave_chats">
<item quantity="one">You left %1$d public chat</item>
<item quantity="other">You left %1$d public chats</item>
</plurals>
<plurals name="backup_card_leave_communities">
<item quantity="one">You left %1$d community</item>
<item quantity="other">You left %1$d communities</item>
</plurals>
<plurals name="backup_card_leave_rooms">
<item quantity="one">You left %1$d chat room</item>
<item quantity="other">You left %1$d chat rooms</item>
</plurals>
<plurals name="backup_card_leave_groups">
<item quantity="one">You left %1$d group</item>
<item quantity="other">You left %1$d groups</item>
</plurals>
<plurals name="backup_card_feeds_removed">
<item quantity="one">You removed %1$d favorite feed</item>
<item quantity="other">You removed %1$d favorite feeds</item>
</plurals>
<plurals name="backup_card_hashtags_unfollowed">
<item quantity="one">You unfollowed %1$d hashtag</item>
<item quantity="other">You unfollowed %1$d hashtags</item>
</plurals>
<plurals name="backup_card_places_unfollowed">
<item quantity="one">You unfollowed %1$d place</item>
<item quantity="other">You unfollowed %1$d places</item>
</plurals>
<plurals name="backup_card_trust_services_changed">
<item quantity="one">You changed the provider of %1$d trust service</item>
<item quantity="other">You changed the provider of %1$d trust services</item>
</plurals>
<plurals name="backup_card_nutzap_mints_removed">
<item quantity="one">You removed %1$d nutzap mint</item>
<item quantity="other">You removed %1$d nutzap mints</item>
</plurals>
<plurals name="backup_card_payment_targets_removed">
<item quantity="one">You removed %1$d way to get paid</item>
<item quantity="other">You removed %1$d ways to get paid</item>
</plurals>
<plurals name="backup_card_offers_removed">
<item quantity="one">You removed %1$d BOLT12 offer</item>
<item quantity="other">You removed %1$d BOLT12 offers</item>
</plurals>
<plurals name="backup_card_and_added">
<item quantity="one">%1$s and added %2$d</item>
<item quantity="other">%1$s and added %2$d</item>
</plurals>
<plurals name="backup_card_and_followed">
<item quantity="one">%1$s and followed %2$d</item>
<item quantity="other">%1$s and followed %2$d</item>
</plurals>
<plurals name="backup_card_and_muted">
<item quantity="one">%1$s and muted %2$d</item>
<item quantity="other">%1$s and muted %2$d</item>
</plurals>
<plurals name="backup_card_and_joined">
<item quantity="one">%1$s and joined %2$d</item>
<item quantity="other">%1$s and joined %2$d</item>
</plurals>
<plurals name="backup_card_and_blocked">
<item quantity="one">%1$s and blocked %2$d</item>
<item quantity="other">%1$s and blocked %2$d</item>
</plurals>
<string name="backup_card_app_settings">Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems.</string>
<string name="backup_card_nutzap_key_replaced">You replaced your nutzap key</string>
<string name="backup_card_nutzap_key_removed">You removed your nutzap key</string>
<string name="backup_card_wallet_emptied">You emptied your Cashu wallet</string>
<string name="backup_card_wallet_rewritten">You changed your Cashu wallet</string>
<string name="backup_card_concord_emptied">You emptied your Concord list</string>
<string name="backup_card_concord_rewritten">You changed your Concord list</string>
<string name="backup_conflict_keep_new">Use new version</string>
<string name="backup_conflict_relay_read_only">read only</string>
<string name="backup_conflict_relay_write_only">write only</string>
<string name="backup_conflict_relay_read_write">read and write</string>
<string name="backup_type_profile">profile</string>
<string name="backup_type_profile_explainer">Your name, picture, bio and payment addresses, as everyone sees them.</string>
<string name="backup_type_follow_list">follow list</string>
<string name="backup_type_follow_list_explainer">Decides whose posts appear in your home feed.</string>
<string name="backup_type_mute_list">mute list</string>
<string name="backup_type_mute_list_explainer">Hides people, words, hashtags and threads from everything you see.</string>
<string name="backup_type_outbox_inbox_relays">public relay list</string>
<string name="backup_type_outbox_inbox_relays_explainer">Tells everyone where to find your posts and where to send you replies and mentions.</string>
<string name="backup_type_dm_relays">DM relay list</string>
<string name="backup_type_dm_relays_explainer">Where others deliver your private messages.</string>
<string name="backup_type_key_package_relays">group-chat key relays</string>
<string name="backup_type_key_package_relays_explainer">Where others find the keys to add you to encrypted group chats.</string>
<string name="backup_type_search_relays">search relay list</string>
<string name="backup_type_search_relays_explainer">The relays used when you search.</string>
<string name="backup_type_indexer_relays">indexer relay list</string>
<string name="backup_type_indexer_relays_explainer">The relays used to find other people\'s profiles and relay lists.</string>
<string name="backup_type_relay_feeds">favorite relay feeds</string>
<string name="backup_type_relay_feeds_explainer">The relays you browse as feeds.</string>
<string name="backup_type_blocked_relays">blocked relay list</string>
<string name="backup_type_blocked_relays_explainer">Relays this app never connects to.</string>
<string name="backup_type_trusted_relays">trusted relay list</string>
<string name="backup_type_trusted_relays_explainer">Relays you trust to connect to without asking.</string>
<string name="backup_type_private_outbox_relays">private relay list</string>
<string name="backup_type_private_outbox_relays_explainer">Where your drafts and private data are stored.</string>
<string name="backup_type_app_settings">synced app settings</string>
<string name="backup_type_app_settings_explainer">Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared.</string>
<string name="backup_type_public_chats">public chat list</string>
<string name="backup_type_public_chats_explainer">The public chats you joined.</string>
<string name="backup_type_communities">community list</string>
<string name="backup_type_communities_explainer">The communities you joined.</string>
<string name="backup_type_hashtags">followed hashtags</string>
<string name="backup_type_hashtags_explainer">Hashtags whose posts appear in your feeds.</string>
<string name="backup_type_geohashes">followed locations</string>
<string name="backup_type_geohashes_explainer">Locations whose posts appear in your feeds.</string>
<string name="backup_type_favorite_algo_feeds">favorite feeds list</string>
<string name="backup_type_favorite_algo_feeds_explainer">The algorithmic feeds you saved.</string>
<string name="backup_type_ephemeral_chats">ephemeral chat list</string>
<string name="backup_type_ephemeral_chats_explainer">The ephemeral chat rooms you joined.</string>
<string name="backup_type_relay_groups">group list</string>
<string name="backup_type_relay_groups_explainer">The relay-based groups you joined.</string>
<string name="backup_type_concord_communities">Concord community list</string>
<string name="backup_type_concord_communities_explainer">The Concord communities you joined. They are encrypted, so only their presence can be compared.</string>
<string name="backup_type_trust_providers">trust provider list</string>
<string name="backup_type_trust_providers_explainer">The services you trust to rank and verify people for you.</string>
<string name="backup_type_cashu_wallet">Cashu wallet</string>
<string name="backup_type_cashu_wallet_explainer">Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared.</string>
<string name="backup_type_nutzap_info">nutzap receiving settings</string>
<string name="backup_type_nutzap_info_explainer">The mints and key others use to send you nutzaps.</string>
<string name="backup_type_payment_targets">payment addresses</string>
<string name="backup_type_payment_targets_explainer">The ways other people can pay you.</string>
<string name="backup_type_bolt12_offers">BOLT12 offers</string>
<string name="backup_type_bolt12_offers_explainer">The lightning offers others use to pay you.</string>
<string name="backup_type_other">list</string>
<string name="backup_type_other_explainer">A list saved on this device.</string>
<string name="backup_entry_profile_field">Profile fields</string>
<string name="backup_entry_person">People</string>
<string name="backup_entry_hashtag">Hashtags</string>
<string name="backup_entry_word">Words</string>
<string name="backup_entry_thread">Threads</string>
<string name="backup_entry_public_chat">Public chats</string>
<string name="backup_entry_community">Communities</string>
<string name="backup_entry_algo_feed">Feeds</string>
<string name="backup_entry_location">Locations</string>
<string name="backup_entry_relay">Relays</string>
<string name="backup_entry_relay_group">Groups</string>
<string name="backup_entry_chat_room">Chat rooms</string>
<string name="backup_entry_trust_provider">Trust providers</string>
<string name="backup_entry_mint">Mints</string>
<string name="backup_entry_nutzap_key">Nutzap keys</string>
<string name="backup_entry_payment_target">Payment addresses</string>
<string name="backup_entry_offer">Offers</string>
<string name="backup_entry_identity_claim">Linked identities</string>
<string name="backup_entry_other">Other entries</string>
<string name="backup_profile_field_name">Username</string>
<string name="backup_profile_field_display_name">Display name</string>
<string name="backup_profile_field_about">About</string>
<string name="backup_profile_field_picture">Picture</string>
<string name="backup_profile_field_banner">Banner</string>
<string name="backup_profile_field_website">Website</string>
<string name="backup_profile_field_nip05">Nostr address</string>
<string name="backup_profile_field_lud16">Lightning address</string>
<string name="backup_profile_field_lud06">LNURL</string>
<string name="backup_profile_field_pronouns">Pronouns</string>
<string name="backup_profile_field_birthday">Birthday</string>
<string name="backup_profile_field_clink_offer">CLINK offer</string>
<string name="backup_profile_field_bot">Bot flag</string>
</resources>
@@ -0,0 +1,144 @@
/*
* 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.backups
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupDiff
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* An undecided conflict has to survive the process that raised it. Before this, it lived only in
* a `MutableStateFlow`, so a restart answered the user's question for them by dropping it.
*/
class BackupConflictStorageTest {
private val signer = NostrSignerSync(KeyPair())
private fun sign(
kind: Int,
createdAt: Long,
tags: Array<Array<String>>,
content: String = "",
): Event = signer.sign<Event>(createdAt, kind, tags, content)
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val carol = "c".repeat(64)
private fun savedAndIncoming(): Pair<Event, Event> =
sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) to
sign(3, 200, arrayOf(arrayOf("p", carol)))
@Test
fun survivesARoundTrip() {
val (saved, incoming) = savedAndIncoming()
val restored = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, incoming, incoming))))
assertEquals(1, restored.size)
val (restoredSaved, restoredIncoming, restoredCause) = restored.first()
assertEquals(saved.id, restoredSaved.id)
assertEquals(incoming.id, restoredIncoming.id)
assertEquals(incoming.id, restoredCause.id)
// The events must come back whole, not as ids: the review screen diffs their contents.
assertEquals(2, restoredSaved.tags.count { it.firstOrNull() == "p" })
}
@Test
fun theRestoredPairStillDescribesTheSameLoss() {
val (saved, incoming) = savedAndIncoming()
val (restoredSaved, restoredIncoming, _) = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, incoming, incoming)))).first()
// The diff is recomputed rather than stored, so it has to still be derivable.
val diff = ReplaceableBackupDiff.detectLoss(restoredSaved, restoredIncoming)
assertNotNull(diff)
assertTrue(diff!!.removesData())
}
@Test
fun aVersionThatNoLongerRemovesAnythingStopsBeingAConflict() {
val saved = sign(3, 100, arrayOf(arrayOf("p", alice)))
val putBack = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob)))
val (restoredSaved, restoredIncoming, _) = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, putBack, putBack)))).first()
assertNull(ReplaceableBackupDiff.detectLoss(restoredSaved, restoredIncoming))
}
@Test
fun severalSlotsAreKeptApart() {
val (saved, incoming) = savedAndIncoming()
val mutesSaved = sign(10000, 100, arrayOf(arrayOf("p", alice)))
val mutesIncoming = sign(10000, 200, arrayOf())
val restored =
BackupConflictStorage.decode(
BackupConflictStorage.encode(
listOf(Triple(saved, incoming, incoming), Triple(mutesSaved, mutesIncoming, mutesIncoming)),
),
)
assertEquals(2, restored.size)
assertEquals(listOf(3, 10000), restored.map { it.second.kind })
}
@Test
fun nothingStoredMeansNoConflicts() {
assertTrue(BackupConflictStorage.decode(null).isEmpty())
assertTrue(BackupConflictStorage.decode("").isEmpty())
assertTrue(BackupConflictStorage.encode(emptyList()).let { BackupConflictStorage.decode(it) }.isEmpty())
}
@Test
fun oneUnreadableEntryDoesNotTakeTheOthersWithIt() {
val (saved, incoming) = savedAndIncoming()
// A row whose events are not parseable, next to a good one.
val mixed =
JsonMapper.toJson(
listOf(
listOf("not an event", "not an event", "not an event"),
listOf(
OptimizedJsonMapper.toJson(saved),
OptimizedJsonMapper.toJson(incoming),
OptimizedJsonMapper.toJson(incoming),
),
),
)
val restored = BackupConflictStorage.decode(mixed)
assertEquals(1, restored.size)
assertEquals(incoming.id, restored.first().second.id)
}
@Test
fun garbageIsNotAConflict() {
assertTrue(BackupConflictStorage.decode("not json at all").isEmpty())
}
}
@@ -206,7 +206,13 @@ class LocalBlossomCacheRedirectInterceptorTest {
url: String,
captured: MutableList<String>,
): Interceptor.Chain {
val request = Request.Builder().url(url.toHttpUrl()).build()
// Marked the way the media loaders mark their downloads; only those are bridged.
val request =
Request
.Builder()
.url(url.toHttpUrl())
.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.MEDIA)
.build()
return Proxy.newProxyInstance(
Interceptor.Chain::class.java.classLoader,
arrayOf(Interceptor.Chain::class.java),
@@ -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.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* An account whose kind:10002 lists no write relays has no home relays, and this subscription is
* keyed on them. Keyed on them *alone*, such an account was asked about on no relay at all, so its
* own profile, follow list and every backed-up list silently stopped updating.
*/
class PickAccountDataRelaysTest {
private fun relays(vararg urls: String) = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
private val home = relays("wss://home.example.com")
private val inbox = relays("wss://inbox.example.com")
private val defaults = relays("wss://default1.example.com", "wss://default2.example.com")
@Test
fun homeRelaysWinWhenThereAreAny() {
assertEquals(home, pickAccountDataRelays(home, inbox, defaults))
}
@Test
fun theInboxIsUsedWhenNothingIsPublishedAnywhere() {
assertEquals(inbox, pickAccountDataRelays(emptySet(), inbox, defaults))
}
/** The case that broke: everything the account declares is empty, and the answer was nothing. */
@Test
fun defaultsRatherThanNothing() {
assertEquals(defaults, pickAccountDataRelays(emptySet(), emptySet(), defaults))
}
@Test
fun theAnswerIsNeverEmptyWhileThereAreDefaults() {
val cases =
listOf(
Triple(home, inbox, defaults),
Triple(emptySet(), inbox, defaults),
Triple(emptySet(), emptySet(), defaults),
)
cases.forEach { (h, i, d) ->
assert(pickAccountDataRelays(h, i, d).isNotEmpty()) { "empty for home=$h inbox=$i" }
}
}
@Test
fun theInboxDoesNotWidenAnAccountThatAlreadyPublishesSomewhere() {
// Reading its own events from where it publishes them is the normal path; the inbox is a
// fallback, not an addition, so a working account's relay set is unchanged by this rule.
assertEquals(home, pickAccountDataRelays(home, inbox, defaults))
assertEquals(home, pickAccountDataRelays(home, emptySet(), defaults))
}
}
@@ -48,9 +48,16 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -387,6 +394,48 @@ class RadioBurstEstimatorTest {
}
class LoopbackExclusionTest {
@get:Rule
val temp = TemporaryFolder()
/** Runs [UsageCountingInterceptor] over a chain whose downstream sent [sentUrl] for [url]. */
private fun countedRequests(
url: String,
sentUrl: String,
): Long? {
val accountant = ResourceUsageAccountant(ResourceUsageStore(File(temp.root, "u.json")), CoroutineScope(Dispatchers.Unconfined), epochDay = { 1L })
val interceptor = UsageCountingInterceptor(accountant, isMobile = { true }, isForeground = { true }, nowMs = { 0L })
val request = Request.Builder().url(url).build()
val chain = mockk<Interceptor.Chain>()
every { chain.request() } returns request
every { chain.proceed(any()) } answers {
Response
.Builder()
.request(request.newBuilder().url(sentUrl).build())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody(null))
.build()
}
interceptor.intercept(chain).close()
return runBlocking { accountant.allDaysIncludingLive() }[1L].orEmpty()[UsageKeys.netReqs(UsageKeys.ROLE_OTHER, mobile = true, foreground = true)]
}
@Test
fun requestRewrittenToTheLocalCacheIsNotCounted() {
// This interceptor is outermost: it sees the CDN URL, but the request that actually went
// out (LocalBlossomCacheRedirectInterceptor's rewrite) never touched the radio.
val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
assertNull(countedRequests("https://cdn.example.com/$sha.jpg", "http://127.0.0.1:24242/$sha.jpg"))
}
@Test
fun requestToARealHostIsCounted() {
assertEquals(1L, countedRequests("https://cdn.example.com/a.jpg", "https://cdn.example.com/a.jpg"))
}
@Test
fun loopbackHostsAreRecognizedAndRealHostsAreNot() {
assertTrue(UsageCountingInterceptor.isLoopback("127.0.0.1"))
@@ -0,0 +1,95 @@
/*
* 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.uploads.blossom.bud10
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
class BlossomServerResolverTest {
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
private val author = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@After
fun tearDown() = scope.cancel()
/** A resolver whose author server-list lookups are counted and answered by [serverList]. */
private fun resolver(
lookups: AtomicInteger,
serverList: () -> Flow<BlossomServersEvent>,
) = BlossomServerResolver(
loggedInUsers = { emptyList() },
blossomServers = { addresses ->
lookups.incrementAndGet()
addresses.map { serverList() }
},
httpClientBuilder = mockk<IRoleBasedHttpClientBuilder>(relaxed = true),
scope = scope,
)
@Test
fun concurrentLookupsOfTheSameUriShareOneResolution() =
runBlocking {
val lookups = AtomicInteger(0)
// The author's server list never arrives, like a relay that is slow to answer.
val resolver = resolver(lookups) { flow { awaitCancellation() } }
val uri = "blossom:$sha.jpg?as=$author"
val callers = List(5) { scope.launch { resolver.findServers(uri) } }
delay(300)
assertEquals(1, lookups.get())
callers.forEach { it.cancel() }
}
@Test
fun anUnresolvableUriIsNotRetriedUntilTheMissExpiresOrCachesAreCleared() =
runBlocking {
val lookups = AtomicInteger(0)
// No hints and no authors: resolves to nothing right away.
val resolver = resolver(lookups) { flow { } }
val uri = "blossom:$sha.jpg"
assertNull(resolver.findServers(uri))
assertNull(resolver.findServers(uri))
assertEquals(1, lookups.get())
resolver.clearCaches()
assertNull(resolver.findServers(uri))
assertEquals(2, lookups.get())
}
}
@@ -0,0 +1,242 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.backups
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* Stands in front of an account's replaceable-event backups and decides, for each newer
* version of a backed-up event, whether it may overwrite the saved copy or must be held back
* and questioned as a [ReplaceableBackupConflict].
*
* Headless and platform-free so the rules can be unit-tested; the account's settings own one
* and call [accept] from every backup update.
*
* [isLocallySigned] tells versions this app produced from ones that came from relays; it
* defaults to [LocallySignedEvents]. [onChange] runs whenever the set of open conflicts
* changes, so the owner can persist it ([open] / [restore]). [reapply] hands an event back to
* the update that owns its slot: a conflict restored from storage has no retry closure until
* its version arrives again, and keeping it must work regardless.
*/
class BackupConflictGuard(
private val isLocallySigned: (HexKey) -> Boolean = LocallySignedEvents::contains,
private val onChange: () -> Unit = {},
private val reapply: (Event) -> Unit = {},
) {
private val _conflicts = MutableStateFlow<Map<String, ReplaceableBackupConflict>>(emptyMap())
/**
* Newer versions of backed-up events that came from another client and dropped data the
* backup still has, keyed by [ReplaceableBackupConflict.slot]. While a slot is in here its
* backup is frozen on the saved version, so the user can still restore it. They outlive
* the process through [open] / [restore]: an undecided question must still be there on the
* next launch.
*/
val conflicts: StateFlow<Map<String, ReplaceableBackupConflict>> = _conflicts
// Everything below is only touched under the lock: the backup collectors run on IO
// threads while the user resolves conflicts from the UI thread.
private val lock = KmpLock()
// Re-runs the update that raised each open conflict, once the user keeps the new version.
private val retries = HashMap<String, () -> Unit>()
// Versions whose conflict is being restored. They must not raise the same conflict again
// while the saved version is re-signed.
private val resolving = HashSet<HexKey>()
// Externally signed versions the user explicitly chose to keep over the backup.
private val accepted = HashSet<HexKey>()
// Slots with a restore in flight, and those whose backup moved on meanwhile: a failed
// restore must not reopen a conflict about a version that is no longer the newest.
private val restoringSlots = HashSet<String>()
private val movedOnWhileRestoring = HashSet<String>()
/**
* Decides whether [incoming] may overwrite the [saved] backup. Versions signed by this app
* always may. A newer version signed elsewhere that removed items from the saved one is
* held back and raised as a [ReplaceableBackupConflict]; [retry] re-runs the caller's
* update once the user keeps it.
*
* An [isEmpty] version (a wiped list) always reaches the diff, so wiping everything is
* questioned like any other loss; it only becomes the backup when this app signed it or
* the user chose to keep it.
*/
fun <T : Event> accept(
saved: T?,
incoming: T,
isEmpty: Boolean = false,
retry: () -> Unit,
): Boolean =
lock.withLock {
if (saved?.id == incoming.id) return@withLock false
if (incoming.id in resolving) return@withLock false
// Never roll the backup back, e.g. by the retry of a conflict whose version has
// been overtaken since it was raised.
if (saved != null && incoming.createdAt < saved.createdAt) return@withLock false
val slot = backupSlot(incoming)
val pending = _conflicts.value[slot]
if (pending != null) {
// The same version re-emitted by the cache: the open conflict already describes it.
// Its retry is refreshed too: a conflict restored from storage carries none
// until the version it is about comes round again.
if (pending.incoming.id == incoming.id) {
retries[slot] = retry
return@withLock false
}
// An older version (e.g. the retry of a just-accepted conflict that a newer
// external change overtook) must never replace or clear the newer question.
if (incoming.createdAt < pending.incoming.createdAt) return@withLock false
}
val isLocal = isLocallySigned(incoming.id)
val wasAccepted = incoming.id in accepted
// While a conflict is open, even edits made here are built on top of the external
// version, so they are still compared with the version the user may want to restore.
val reference = pending?.saved ?: saved?.takeUnless { isLocal }
if (reference != null && !wasAccepted) {
val diff = ReplaceableBackupDiff.detectLoss(reference, incoming)
if (diff != null) {
// A local edit keeps the external change as the conflict's cause.
val cause = if (isLocal && pending != null) pending.cause else incoming
retries[slot] = retry
_conflicts.update { it + (slot to ReplaceableBackupConflict(reference, incoming, cause, diff)) }
onChange()
return@withLock false
}
}
dropLocked(slot)
// A wipe from elsewhere that lost nothing (e.g. the backup was empty too) doesn't
// become the backup; one signed here or explicitly kept does.
val stored = !isEmpty || isLocal || wasAccepted
if (stored) {
accepted.remove(incoming.id)
if (slot in restoringSlots) movedOnWhileRestoring.add(slot)
}
stored
}
/** Forgets the open conflict of [slot], e.g. because its event was deleted. */
fun drop(slot: String) {
lock.withLock { dropLocked(slot) }
}
private fun dropLocked(slot: String) {
if (_conflicts.value.containsKey(slot)) {
_conflicts.update { it - slot }
onChange()
}
retries.remove(slot)
}
/**
* Re-seeds the open conflicts read back from storage, as (saved, incoming, cause).
*
* Their diff is recomputed rather than stored, so a version that no longer removes
* anything (the other app put it back) quietly stops being a conflict.
*/
fun restore(saved: List<Triple<Event, Event, Event>>) {
if (saved.isEmpty()) return
val restored =
saved.mapNotNull { (savedEvent, incoming, cause) ->
val diff = ReplaceableBackupDiff.detectLoss(savedEvent, incoming) ?: return@mapNotNull null
val conflict = ReplaceableBackupConflict(savedEvent, incoming, cause, diff)
conflict.slot to conflict
}
if (restored.isEmpty()) return
lock.withLock { _conflicts.update { it + restored } }
}
/** The open conflicts, as the three events storage needs to rebuild each one. */
fun open(): List<Triple<Event, Event, Event>> = _conflicts.value.values.map { Triple(it.saved, it.incoming, it.cause) }
/**
* Removes [conflict] if it is still the open conflict of its slot and returns its retry.
* Returns null when it was already resolved or replaced by a newer one, so callers never
* act on a stale conflict.
*/
private fun claimLocked(conflict: ReplaceableBackupConflict): (() -> Unit)? {
if (_conflicts.value[conflict.slot] !== conflict) return null
_conflicts.update { it - conflict.slot }
onChange()
// A conflict restored from storage has no closure to re-run: rebuild it from the event.
// Falling back to {} instead would clear the card and quietly keep the old backup.
return retries.remove(conflict.slot) ?: { reapply(conflict.incoming) }
}
/** Keeps the new version: the backup moves forward to [ReplaceableBackupConflict.incoming]. */
fun keepIncoming(conflict: ReplaceableBackupConflict) {
val retry =
lock.withLock {
val claimed = claimLocked(conflict) ?: return
accepted.add(conflict.incoming.id)
claimed
}
// Outside the lock: the retry re-enters accept.
retry()
}
/**
* Starts restoring the saved version: claims [conflict] and stops its incoming version
* from raising it again while the saved one is re-signed. Returns null when the conflict
* is stale (already resolved, replaced or dropped); otherwise a token to hand back to
* [finishRestoring].
*/
fun startRestoring(conflict: ReplaceableBackupConflict): (() -> Unit)? =
lock.withLock {
val retry = claimLocked(conflict) ?: return@withLock null
resolving.add(conflict.incoming.id)
restoringSlots.add(conflict.slot)
retry
}
/**
* Ends a restore. On failure the conflict is reopened so the user can try again, unless
* something else (a newer change, a deletion) took the slot meanwhile.
*/
fun finishRestoring(
conflict: ReplaceableBackupConflict,
token: () -> Unit,
restored: Boolean,
) {
lock.withLock {
resolving.remove(conflict.incoming.id)
restoringSlots.remove(conflict.slot)
val movedOn = movedOnWhileRestoring.remove(conflict.slot)
if (restored || movedOn || _conflicts.value.containsKey(conflict.slot)) return@withLock
_conflicts.update { it + (conflict.slot to conflict) }
retries[conflict.slot] = token
onChange()
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.backups
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
/**
* Ids of the replaceable/addressable events this app instance produced (signed here, or
* restored from its own on-disk backup) since the process started.
*
* The backup guard uses it to tell "the user edited this list in Amethyst" from "a newer
* version came in from a relay", i.e. from another client. Only the latter can silently
* wipe data another app did not know about, so only the latter is ever questioned.
*
* Fed from the cache's `justConsumeMyOwnEvent` choke point, which every local publish goes
* through. Only replaceable and addressable kinds are tracked, and the ids live in a bounded
* LRU: only the latest few versions of each list matter, since an older version is already
* superseded by the time the backup guard sees a newer one.
*/
object LocallySignedEvents {
private const val MAX_TRACKED = 500
// androidx LruCache synchronizes internally, so the relay and UI threads can share it.
private val ids = LruCache<HexKey, Boolean>(MAX_TRACKED)
fun mark(event: Event) {
if (event.kind.isReplaceable() || event.kind.isAddressable()) {
ids.put(event.id, true)
}
}
fun contains(eventId: HexKey): Boolean = ids[eventId] != null
}
@@ -0,0 +1,203 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.backups
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
/** Which of the account's backed-up events a conflict is about. Drives the dialog's wording. */
enum class BackupEventType {
PROFILE,
FOLLOW_LIST,
MUTE_LIST,
OUTBOX_INBOX_RELAYS,
DM_RELAYS,
KEY_PACKAGE_RELAYS,
SEARCH_RELAYS,
INDEXER_RELAYS,
RELAY_FEEDS,
BLOCKED_RELAYS,
TRUSTED_RELAYS,
PRIVATE_OUTBOX_RELAYS,
APP_SETTINGS,
PUBLIC_CHATS,
COMMUNITIES,
HASHTAGS,
GEOHASHES,
FAVORITE_ALGO_FEEDS,
EPHEMERAL_CHATS,
RELAY_GROUPS,
CONCORD_COMMUNITIES,
TRUST_PROVIDERS,
CASHU_WALLET,
NUTZAP_INFO,
PAYMENT_TARGETS,
BOLT12_OFFERS,
OTHER,
;
companion object {
fun of(kind: Int): BackupEventType =
when (kind) {
MetadataEvent.KIND -> PROFILE
ContactListEvent.KIND -> FOLLOW_LIST
MuteListEvent.KIND -> MUTE_LIST
AdvertisedRelayListEvent.KIND -> OUTBOX_INBOX_RELAYS
ChatMessageRelayListEvent.KIND -> DM_RELAYS
KeyPackageRelayListEvent.KIND -> KEY_PACKAGE_RELAYS
SearchRelayListEvent.KIND -> SEARCH_RELAYS
IndexerRelayListEvent.KIND -> INDEXER_RELAYS
RelayFeedsListEvent.KIND -> RELAY_FEEDS
BlockedRelayListEvent.KIND -> BLOCKED_RELAYS
TrustedRelayListEvent.KIND -> TRUSTED_RELAYS
PrivateOutboxRelayListEvent.KIND -> PRIVATE_OUTBOX_RELAYS
AppSpecificDataEvent.KIND -> APP_SETTINGS
ChannelListEvent.KIND -> PUBLIC_CHATS
CommunityListEvent.KIND -> COMMUNITIES
HashtagListEvent.KIND -> HASHTAGS
GeohashListEvent.KIND -> GEOHASHES
FavoriteAlgoFeedsListEvent.KIND -> FAVORITE_ALGO_FEEDS
EphemeralChatListEvent.KIND -> EPHEMERAL_CHATS
SimpleGroupListEvent.KIND -> RELAY_GROUPS
ConcordCommunityListEvent.KIND -> CONCORD_COMMUNITIES
TrustProviderListEvent.KIND -> TRUST_PROVIDERS
CashuWalletEvent.KIND -> CASHU_WALLET
NutzapInfoEvent.KIND -> NUTZAP_INFO
PaymentTargetsEvent.KIND -> PAYMENT_TARGETS
Bolt12OfferListEvent.KIND -> BOLT12_OFFERS
else -> OTHER
}
}
}
/**
* A newer version of one of the account's backed-up replaceable events arrived from outside
* this app and dropped data the saved version had. The backup keeps [saved] until the user
* either accepts [incoming] or re-signs [saved] on top of it.
*
* @property incoming the current version: what "use new version" keeps. Edits made in this
* app while the conflict is open are built on top of the external version, so they replace
* [incoming] but keep the conflict open.
* @property cause the version from the other app that raised the conflict. It stays the same
* while local edits move [incoming] forward, so the dialog keeps describing the external change.
* @property diff what [incoming] lost compared with [saved].
*/
@Immutable
class ReplaceableBackupConflict(
val saved: Event,
val incoming: Event,
val cause: Event,
val diff: EventDiff,
) {
/** One conflict per replaceable slot: kind for replaceables, kind + d-tag for addressables. */
val slot: String = backupSlot(incoming)
val eventType: BackupEventType get() = BackupEventType.of(incoming.kind)
/**
* Distinct people followed by the saved and the incoming version, for follow-list
* conflicts (0 otherwise). Parsed once: a follow list can have thousands of entries, and
* the Home card, the review screen and its buttons all show these counts.
*/
val followCounts: Pair<Int, Int> by lazy { uniqueFollows(saved) to uniqueFollows(incoming) }
private fun uniqueFollows(event: Event) = (event as? ContactListEvent)?.follows()?.mapTo(HashSet()) { it.pubKey }?.size ?: 0
}
fun backupSlot(event: Event): String = backupSlotOf(event.kind, event.dTag())
fun backupSlotOf(
kind: Int,
dTag: String = "",
): String = "$kind:$dTag"
object ReplaceableBackupDiff {
/**
* What a newer [incoming] version of a backed-up event removed from the [saved] one, as
* computed by the event itself ([DiffableEvent.diffFrom]), or null when it removed
* nothing: it only added or edited entries, which means the other app did read the
* previous version. Events that can't diff themselves never raise a conflict.
*/
fun detectLoss(
saved: Event,
incoming: Event,
): EventDiff? {
if (saved.id == incoming.id) return null
// Older or same-age versions never replace the backup in LocalCache anyway.
if (incoming.createdAt <= saved.createdAt) return null
val diffable = incoming as? DiffableEvent<*> ?: return null
return diffable.diffFrom(saved)?.takeIf { it.removesData() }
}
}
object BackupRestore {
/**
* The tags to re-sign when restoring [saved] over a newer version. Metadata that only
* described the old signing is dropped, not the user's data:
* - `client`: the app that signs now adds its own (it won't when one is already there);
* - `nonce`: NIP-13 proof of work is tied to the old id and would be a false claim;
* - `expiration` already in the past: relays would reject or drop the restored version.
*/
fun tagsToResign(
saved: Event,
now: Long,
): Array<Array<String>> =
saved.tags
.filterNot { tag ->
when (tag.getOrNull(0)) {
ClientTag.TAG_NAME, PoWTag.TAG_NAME -> true
ExpirationTag.TAG_NAME -> ExpirationTag.parse(tag)?.let { it <= now } ?: true
else -> false
}
}.toTypedArray()
}
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.RelayGroupTargetCandidate
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
@@ -3383,7 +3384,10 @@ open class EventCache :
justConsume(event, null, false)
}
override fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true)
override fun justConsumeMyOwnEvent(event: Event): Boolean {
LocallySignedEvents.mark(event)
return justConsumeAndUpdateIndexes(event, null, true)
}
fun justConsume(
event: Event,
@@ -1,225 +0,0 @@
/*
* 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.richtext
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
private val sha256HexRegex = Regex("[0-9a-f]{64}")
private val blossomLastSegmentRegex = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
/**
* Converts this media content into a Coil/ExoPlayer-friendly model string.
*
* When the local-Blossom-cache bridge is active and the content has a
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>&as=<authorPubKey>`
* URI. The Coil pipeline recognises the scheme and routes the request
* through `BlossomServerResolver`, which short-circuits to the local cache
* at `127.0.0.1:24242`.
*
* Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI,
* or a live stream) returns the original URL unchanged so today's
* direct-to-CDN behaviour is preserved.
*/
fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String =
bridgeUrl(
url = url,
useBridge = useLocalBlossomBridge,
mimeType = mimeType,
authorPubKey = authorPubKey,
skipBridge = this is MediaUrlVideo && isLiveStream,
)
/**
* Bridge entry point for raw URL strings (e.g. profile pictures) that go
* through a Coil fetcher routed by type (`ProfilePictureUrl`) and therefore
* bypass [com.vitorpamplona.quartz.nipB7Blossom.BlossomUri] processing
* entirely.
*
* Returns a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pubkey>`
* URL so the request can flow through `NetworkFetcher` unchanged. Falls
* back to the original URL when no sha256 can be recovered from the path
* or when the bridge is off.
*
* @param localCacheBase the local Blossom cache origin (default `http://127.0.0.1:24242`).
* @param authorPubKey 64-char lowercase hex pubkey appended as `as=` so the
* cache can consult that author's BUD-03 server list on miss.
*/
fun bridgeProfilePictureUrl(
url: String?,
useBridge: Boolean,
authorPubKey: String? = null,
localCacheBase: String = DEFAULT_LOCAL_CACHE_BASE,
): String? {
if (url == null) return null
if (!useBridge) return url
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
val sha = extractSha256FromUrlPath(url) ?: return url
val ext = guessExtension(url, null)
val serverBase = extractServerBase(url, sha) ?: return url
val params =
buildList {
add("xs=${percentEncode(serverBase)}")
authorPubKey
?.lowercase()
?.takeIf { sha256HexRegex.matches(it) }
?.let { add("as=$it") }
}
return "${localCacheBase.removeSuffix("/")}/$sha.$ext?${params.joinToString("&")}"
}
const val DEFAULT_LOCAL_CACHE_BASE = "http://127.0.0.1:24242"
private fun bridgeUrl(
url: String,
useBridge: Boolean,
mimeType: String?,
authorPubKey: String?,
skipBridge: Boolean,
): String {
if (!useBridge || skipBridge) return url
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
// The local Blossom cache fetches `<xs>/<sha>.<ext>` on miss per BUD-01,
// which only works when the upstream URL is itself BUD-01 layout — the
// file at `<xs>/<sha>.<ext>` is the one named in the URL path, not the
// imeta `x` hash (which on resizing CDNs may identify a different blob
// than the URL filename, e.g. `x` = post-resize, `ox` = original).
// Always use the URL's sha; never trust the imeta override.
val sha = extractSha256FromUrlPath(url) ?: return url
val ext = guessExtension(url, mimeType)
val serverBase = extractServerBase(url, sha) ?: return url
val authors =
authorPubKey
?.lowercase()
?.takeIf { sha256HexRegex.matches(it) }
?.let { listOf(it) }
?: emptyList()
return BlossomUri(
sha256 = sha,
extension = ext,
servers = listOf(serverBase),
authors = authors,
size = null,
).toUriString()
}
private fun percentEncode(input: String): String {
val sb = StringBuilder(input.length)
for (c in input) {
if (c.isLetterOrDigit() || c in "-._~") {
sb.append(c)
} else {
for (b in c.toString().encodeToByteArray()) {
sb.append('%')
sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase())
}
}
}
return sb.toString()
}
private fun extractSha256FromUrlPath(url: String): String? {
// Per Blossom (BUD-01) the last path segment must be exactly
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
// the bridge must leave them alone — rewriting them would point the
// local cache at a fallback `xs=` server that doesn't host the blob.
val pathPart = url.substringBefore('?').substringBefore('#')
val lastSegment = pathPart.substringAfterLast('/')
val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null
return match.groupValues[1].lowercase()
}
private fun guessExtension(
url: String,
mimeType: String?,
): String {
val pathPart = url.substringBefore('?').substringBefore('#')
val lastDot = pathPart.lastIndexOf('.')
val lastSlash = pathPart.lastIndexOf('/')
if (lastDot > lastSlash && lastDot >= 0) {
val ext = pathPart.substring(lastDot + 1).lowercase()
if (ext.isNotEmpty() && ext.length <= 8 && ext.all { it.isLetterOrDigit() }) {
return ext
}
}
if (mimeType != null) {
for ((extension, mt) in mimeTypeMap) {
if (mt.equals(mimeType, ignoreCase = true)) return extension
}
}
return "bin"
}
/**
* Returns the URL prefix that the local Blossom cache should append `/<sha>`
* to in order to reach the original blob, preserving any path prefix the
* upstream CDN uses (e.g. `https://cdn.nostr.build/i` for nostr.build's
* `/i/<sha>` scheme). Falls back to scheme+host when the sha can't be
* located in the path.
*/
private fun extractServerBase(
url: String,
sha: String,
): String? {
val pathPart = url.substringBefore('?').substringBefore('#')
val schemeEnd = pathPart.indexOf("://")
if (schemeEnd < 0) return null
val hostStart = schemeEnd + 3
if (hostStart >= pathPart.length) return null
val shaIndex = pathPart.lastIndexOf(sha, ignoreCase = true)
if (shaIndex >= 0) {
// Anchor on the slash immediately preceding the sha so the cache
// can append "/<sha>" verbatim per the local-blossom-cache spec.
val slashBeforeSha = pathPart.lastIndexOf('/', shaIndex - 1)
if (slashBeforeSha > hostStart - 1) {
return pathPart.substring(0, slashBeforeSha)
}
}
return extractHostBase(pathPart)
}
private fun extractHostBase(url: String): String? {
val schemeEnd = url.indexOf("://")
if (schemeEnd < 0) return null
val afterScheme = schemeEnd + 3
var end = url.length
for (i in afterScheme until url.length) {
val c = url[i]
if (c == '/' || c == '?' || c == '#') {
end = i
break
}
}
if (end <= afterScheme) return null
return url.substring(0, end)
}
@@ -825,6 +825,10 @@ sealed class Route {
val replyTo: HexKey? = null,
) : Route()
@Serializable data class BackupConflictReview(
val slot: String,
) : Route()
@Serializable data class RelayInfo(
val url: String,
) : Route()
@@ -1,273 +0,0 @@
/*
* 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.richtext
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MediaUrlContentExtTest {
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
@Test
fun bridgeOffReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/$sha.jpg", hash = sha)
assertEquals("https://cdn.example.com/$sha.jpg", image.toCoilModel(useLocalBlossomBridge = false))
}
@Test
fun nullHashReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = null)
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun invalidHashReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = "not-hex")
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun blossomUriReturnedUnchanged() {
val image = MediaUrlImage(url = "blossom:$sha.jpg?xs=https://cdn.example.com", hash = sha)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun liveStreamReturnsOriginalUrl() {
val video = MediaUrlVideo(url = "https://stream.example.com/play.m3u8", hash = sha, isLiveStream = true)
assertEquals("https://stream.example.com/play.m3u8", video.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeOnRewritesPlainHttpsUrl() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://nostr.build/i/abc", result)
}
@Test
fun bridgeOnPreservesNostrBuildPathPrefix() {
val image = MediaUrlImage(url = "https://cdn.nostr.build/i/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.nostr.build/i", result)
}
@Test
fun bridgeOnFlatBlossomPathYieldsHostOnlyXs() {
val image = MediaUrlImage(url = "https://blossom.primal.net/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://blossom.primal.net", result)
}
@Test
fun bridgeOnInfersExtensionFromMimeType() {
// BUD-01 allows `<sha>` without an extension; mimeType supplies one.
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png")
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
}
@Test
fun bridgeOnFallsBackToBinExtension() {
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
}
@Test
fun nonHttpUrlReturnsOriginal() {
val image = MediaUrlImage(url = "ftp://example.com/file", hash = sha)
assertEquals("ftp://example.com/file", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun uppercaseHashNormalisedToLowercase() {
val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase())
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
}
@Test
fun authorPubKeyAddedAsAsParam() {
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
val image =
MediaUrlImage(
url = "https://cdn.example.com/$sha.jpg",
hash = sha,
authorPubKey = authorPub,
)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com&as=$authorPub", result)
}
@Test
fun invalidAuthorPubKeyDropped() {
val image =
MediaUrlImage(
url = "https://cdn.example.com/$sha.jpg",
hash = sha,
authorPubKey = "not-a-pubkey",
)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
}
@Test
fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() {
// The imeta `x` hash refers to a blob whose canonical Blossom location
// is /<sha>.<ext>, but the upstream URL serves it under a different
// path (https://i.nostr.build/M5AwJ.gif). Routing this through the
// local cache would set xs=https://i.nostr.build, and the cache would
// fetch https://i.nostr.build/<sha>.gif on miss, which 404s.
val url = "https://i.nostr.build/M5AwJ.gif"
val image = MediaUrlImage(url = url, hash = sha)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() {
val url = "https://i.nostr.build/M5AwJ.gif"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlNullReturnsNull() {
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlOffReturnsOriginal() {
assertEquals(
"https://cdn.example.com/avatar.jpg",
bridgeProfilePictureUrl("https://cdn.example.com/avatar.jpg", useBridge = false),
)
}
@Test
fun bridgeProfilePictureUrlExtractsShaFromPath() {
val url = "https://nostr.build/i/$sha.jpg"
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build%2Fi&as=$authorPub",
bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub),
)
}
@Test
fun bridgeProfilePictureUrlPreservesNostrBuildPath() {
val url = "https://cdn.nostr.build/i/$sha.jpg"
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
bridgeProfilePictureUrl(url, useBridge = true),
)
}
@Test
fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() {
val url = "https://nostr.build/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlBlossomUriReturnedUnchanged() {
val uri = "blossom:$sha.jpg?xs=https://nostr.build"
assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true))
}
@Test
fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() {
// share.yabu.me layout: <cache-prefix-sha>/<blob-sha>.<ext>
val image =
MediaUrlImage(
url = "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
hash = null,
)
assertEquals(
"blossom:28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
image.toCoilModel(useLocalBlossomBridge = true),
)
}
@Test
fun bridgeProfilePictureUrlRewritesShaInLastPathSegmentWithHexPrefix() {
assertEquals(
"http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
bridgeProfilePictureUrl(
"https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
useBridge = true,
),
)
}
@Test
fun bridgeOnSkipsWhenLastSegmentIsNotSha() {
// Per BUD-01 the last segment is the blob; if it isn't a sha256, the
// URL isn't a Blossom blob even if an earlier segment is hex.
val url = "https://example.com/$sha/avatar.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsWhenLastSegmentIsNotSha() {
val url = "https://example.com/$sha/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
// nostr.build /i/ layout: <prefix>_<sha>.<ext>. The hex inside the
// filename isn't a Blossom blob per BUD-01 — the last segment must
// be exactly <sha> or <sha>.<ext>.
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() {
val url = "https://example.com/${sha}_thumb.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeOnUsesUrlShaNotImetaWhenTheyDiffer() {
// On resizing CDNs the imeta `x` (post-resize) can differ from the
// `ox` (original) embedded in the URL. The upstream file is named
// after the URL's sha, so the cache request must use that — using
// the imeta `x` would point xs= at a non-existent path on miss.
val urlSha = "f24026b7281e598973a775adefb1b9a13b9f037a94ac98dd48ccc91b83f4b7b3"
val imetaX = "6932a918de1bfae3bf6611794ff54dd677013d22b760a9212117a0bd9079badf"
val image = MediaUrlImage(url = "https://image.nostr.build/$urlSha.png", hash = imetaX)
assertEquals(
"blossom:$urlSha.png?xs=https://image.nostr.build",
image.toCoilModel(useLocalBlossomBridge = true),
)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.service.http
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@@ -41,7 +42,7 @@ class DualHttpClientManager(
keyCache: EncryptionKeyCache,
scope: CoroutineScope,
dns: SurgeDns,
shouldBridgeBlossomCache: (() -> Boolean)? = null,
shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = null,
// Required (not nullable): every general-purpose HTTP client we mint must be
// wired into the OnionLocationCache so the app's `.onion`-routing behavior
// is uniform across image, upload, NIP-05, money, preview, and push roles.
@@ -53,8 +54,10 @@ class DualHttpClientManager(
// Signs BUD-01 read-auth to retry auth-gated Blossom downloads on 401.
// See [BlossomReadAuthInterceptor].
blossomReadAuth: Interceptor? = null,
// Told when the local Blossom cache refuses a connection. See [LocalBlossomCacheRedirectInterceptor].
onLocalBlossomCacheUnreachable: () -> Unit = {},
) : IHttpClientManager {
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth)
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth, onLocalBlossomCacheUnreachable)
val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
@@ -96,10 +99,23 @@ class DualHttpClientManager(
/**
* the okhttp can change on the manager without affecting other systems.
*
* [useProxy] is fixed when the factory is built (e.g. per video player pool), from the URL the
* caller knew at the time. The URL that is finally requested can differ: a `blossom:` URI is
* resolved to the local Blossom cache on `127.0.0.1:24242` only when the data source opens. Tor
* refuses to connect to loopback/private addresses, so those requests must never take the proxied
* client — the same rule RoleBasedHttpClientBuilder applies to URLs it sees upfront.
*/
class DynamicCallFactory(
val useProxy: Boolean,
val manager: DualHttpClientManager,
) : Call.Factory {
override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request)
override fun newCall(request: Request): Call = manager.getHttpClient(shouldUseProxy(useProxy, request.url.toString())).newCall(request)
companion object {
fun shouldUseProxy(
useProxy: Boolean,
url: String,
): Boolean = useProxy && !RelayUrlNormalizer.isLocalHost(url) && !RelayUrlNormalizer.isOverlayNetwork(url)
}
}
@@ -20,43 +20,107 @@
*/
package com.vitorpamplona.amethyst.commons.service.http
import okhttp3.Call
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.ConnectException
/**
* App-wide OkHttp interceptor that transparently rewrites HTTP requests for
* OkHttp interceptor that transparently rewrites media downloads of
* sha256-keyed blobs to a local Blossom cache running on `127.0.0.1:24242`,
* per https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md
*
* Activates when [shouldBridge] returns `true` AND the request URL contains
* a 64-char hex sha256 segment in its path AND the host isn't already
* `127.0.0.1`/`localhost`. The original scheme+host is appended as a `xs=`
* proxy hint so the cache can fetch upstream on miss.
* This is the only place feed media, videos and profile pictures are routed
* to the cache: callers keep the real URL, so the Tor decision, Coil/ExoPlayer
* cache keys and decryption-key lookups all see the origin.
*
* Coil's disk cache keys responses by the original `ImageRequest.data`, so
* disk caching continues to work transparently even though the network
* request now goes to localhost.
* Bridging is **opt-in**: only requests the media loaders mark with the
* [MEDIA_HEADER] header are considered (see [LocalBlossomMediaCallFactory]).
* Anything else whose last path segment merely looks like a hash — a BUD-02
* `GET /list/<pubkey>`, uploads, deletes, `HEAD` presence checks — must reach
* its server. The marker header is always stripped, so no server ever sees it;
* that is why Tor-proxied clients carry a non-bridging instance ([bridges] =
* false) instead of none: Tor-routed media skips the cache but is still cleaned.
*
* A marked `GET` is rewritten when [shouldBridge] returns `true`, it carries no
* `Authorization` (an auth-gated blob goes to the host the token was signed
* for), its last path segment is `<sha256>[.ext]`, and the host isn't already
* the cache. The original scheme+host(+path prefix) is passed as the `xs=`
* hint so the cache can fetch upstream on miss.
*
* Encrypted blobs are looked up in [keyCache] by URL after this interceptor
* runs, so their decryption key is registered under the rewritten URL too.
*
* When the cache refuses the connection (the app was closed since the last
* probe), [onUnreachable] is told so the bridge can switch off, and the
* request falls back to its original URL instead of failing.
*/
class LocalBlossomCacheRedirectInterceptor(
private val shouldBridge: () -> Boolean,
private val keyCache: EncryptionKeyCache? = null,
private val onUnreachable: () -> Unit = {},
val bridges: Boolean = true,
// Last, so the `LocalBlossomCacheRedirectInterceptor { enabled }` trailing-lambda form binds here.
// Told whether the request is a profile picture, for the "profile pictures only" setting.
private val shouldBridge: (profilePicture: Boolean) -> Boolean,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val marked = chain.request()
val kind = marked.header(MEDIA_HEADER)
val request = if (kind != null) marked.newBuilder().removeHeader(MEDIA_HEADER).build() else marked
if (!shouldBridge()) return chain.proceed(request)
if (request.method != "GET") return chain.proceed(request)
if (isLocalCache(request.url)) {
// Already addressed to the cache (a resolved `blossom:` URI): there is no
// original URL to fall back to here, but a dead cache must still be reported.
try {
return chain.proceed(request)
} catch (e: ConnectException) {
onUnreachable()
throw e
}
}
if (!bridges || kind == null || request.header("Authorization") != null) return chain.proceed(request)
if (!shouldBridge(kind == PROFILE_PICTURE)) return chain.proceed(request)
val rewritten = rewriteIfApplicable(request.url) ?: return chain.proceed(request)
return chain.proceed(
request
.newBuilder()
.url(rewritten)
.build(),
)
keyCache?.get(request.url.toString())?.let { keyCache.add(rewritten.toString(), it) }
val bridged =
try {
chain.proceed(
request
.newBuilder()
.url(rewritten)
.build(),
)
} catch (e: ConnectException) {
onUnreachable()
return chain.proceed(request)
}
if (bridged.isSuccessful) return bridged
// The cache answered, but not with the blob: it does not hold it and could not fetch it
// from `xs` either (not every cache implements that, and the one that does can be offline,
// still warming, or rate-limited). A miss is the ordinary state of a cache and must never
// be worse than having no cache at all, so the origin is asked directly — without this the
// 404 reached the caller and the image, video or encrypted file simply failed to load.
//
// Not reported through [onUnreachable]: the cache is alive and answering, so switching the
// bridge off would be the wrong conclusion.
bridged.close()
return chain.proceed(request)
}
private fun isLocalCache(url: HttpUrl): Boolean = url.port == LOCAL_CACHE_PORT && (url.host == LOCAL_CACHE_HOST || url.host.equals("localhost", ignoreCase = true))
private fun rewriteIfApplicable(url: HttpUrl): HttpUrl? {
val host = url.host
if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null
@@ -121,5 +185,26 @@ class LocalBlossomCacheRedirectInterceptor(
const val LOCAL_CACHE_PORT = 24242
const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT"
private val BLOSSOM_LAST_SEGMENT_REGEX = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
/** Marks a request as a media download the local cache may serve. Stripped before sending. */
const val MEDIA_HEADER = "X-Amethyst-Local-Blossom"
const val MEDIA = "media"
const val PROFILE_PICTURE = "profile-picture"
}
}
/**
* Marks every call it creates as a media download ([LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER]),
* keeping a marker the request already carries (e.g. a profile picture's).
*/
class LocalBlossomMediaCallFactory(
private val delegate: Call.Factory,
private val kind: String = LocalBlossomCacheRedirectInterceptor.MEDIA,
) : Call.Factory {
override fun newCall(request: Request): Call =
if (request.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER) != null) {
delegate.newCall(request)
} else {
delegate.newCall(request.newBuilder().header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, kind).build())
}
}
@@ -55,12 +55,13 @@ class OkHttpClientFactory(
val userAgent: String,
private val dns: SurgeDns,
/**
* Returns `true` when sha256-keyed HTTP requests should be transparently
* rewritten to the local Blossom cache (master toggle on, profile-pictures-only
* restriction off, probe up). When `null`, the interceptor is disabled —
* useful for tests or pre-configuration call sites.
* Returns `true` when a sha256-keyed HTTP request should be transparently
* rewritten to the local Blossom cache, given whether it is a profile picture
* (master toggle on, probe up, and either not restricted to profile pictures
* or this is one). When `null`, the interceptor is disabled — useful for
* tests or pre-configuration call sites.
*/
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
val shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = null,
private val onionCache: OnionLocationCache,
/**
* Resource-usage ledger counter, installed OUTERMOST on the shared base
@@ -76,11 +77,24 @@ class OkHttpClientFactory(
* anonymous. See [BlossomReadAuthInterceptor].
*/
private val blossomReadAuth: Interceptor? = null,
/**
* Called when the local Blossom cache refuses a connection, so the caller can
* mark it unavailable instead of waiting for the next periodic probe.
*/
onLocalBlossomCacheUnreachable: () -> Unit = {},
) {
// val logging = LoggingInterceptor()
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
// Tor-routed media skips the local cache (the cache would fetch the origin outside Tor, and
// Tor refuses 127.0.0.1 anyway), but its media marker header must still be stripped: this
// non-bridging instance takes the redirect's place on Tor clients, and stands in for it when
// the bridge isn't configured at all.
private val blossomCacheMarkerStripper = LocalBlossomCacheRedirectInterceptor(bridges = false) { false }
private val blossomCacheRedirect =
shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(it) }
shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(keyCache, onLocalBlossomCacheUnreachable, bridges = true, shouldBridge = it) }
?: blossomCacheMarkerStripper
// Most images/videos in a feed come from a small set of hosts (e.g. a single
// Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests
@@ -130,15 +144,13 @@ class OkHttpClientFactory(
.followSslRedirects(true)
.apply { usageInterceptor?.let { addInterceptor(it) } }
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.apply {
blossomCacheRedirect?.let { addInterceptor(it) }
}
// Sits outside the network interceptors so its retry re-runs the
// full stack (content-type, blossom cache, key decryptor) for the
// authenticated response. Only signs on an actual 401.
// Sits before the local-cache redirect and outside the network interceptors, so it
// sees the origin host (a token is scoped to it) and its retry re-runs the rest of
// the stack (blossom cache, key decryptor) for the authenticated response. Only
// signs on an actual 401. A request carrying a token is never bridged.
.apply {
blossomReadAuth?.let { addInterceptor(it) }
}
}.addInterceptor(blossomCacheRedirect)
// .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
// Passively populates [onionCache] from any HTTP/WebSocket response
@@ -172,7 +184,15 @@ class OkHttpClientFactory(
// `.onion`s — clearnet clients must never try to resolve `.onion`
// (DNS would fail, and we don't want fingerprintable lookups).
.apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) }
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
// The local Blossom cache lives on 127.0.0.1, which Tor refuses to reach, and it
// would fetch the origin outside Tor. Tor-routed media keeps going to its origin
// through Tor; the local cache is used by the direct client only.
.apply {
if (proxy != null) {
val index = interceptors().indexOf(blossomCacheRedirect)
if (index >= 0) interceptors()[index] = blossomCacheMarkerStripper
}
}.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.build()
@@ -0,0 +1,427 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.backups
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* The rules that decide whether a newer version of a backed-up event may overwrite the
* backup, and how the user's answers resolve the conflicts it raises.
*/
class BackupConflictGuardTest {
private val signer = NostrSignerSync(KeyPair())
private val local = HashSet<HexKey>()
private var changes = 0
private val reapplied = ArrayList<Event>()
private val guard =
BackupConflictGuard(
isLocallySigned = { it in local },
onChange = { changes++ },
reapply = { reapplied.add(it) },
)
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val carol = "c".repeat(64)
private fun follows(
createdAt: Long,
vararg pubKeys: String,
): Event = signer.sign(createdAt, 3, pubKeys.map { arrayOf("p", it) }.toTypedArray(), "")
private fun signedHere(event: Event) = event.also { local.add(it.id) }
private fun openConflict() =
guard.conflicts.value.values
.single()
/** A backup slot the way AccountSettings drives it: the guard in front of a stored copy. */
private inner class Backup(
var saved: Event?,
) {
var saves = 0
fun update(incoming: Event) {
if (guard.accept(saved, incoming, isEmpty = incoming.tags.isEmpty()) { update(incoming) }) {
saved = incoming
saves++
}
}
}
@Test
fun versionsSignedHereAlwaysReplaceTheBackup() {
val backup = Backup(follows(100, alice, bob))
val edit = signedHere(follows(200, bob))
backup.update(edit)
assertSame(edit, backup.saved)
assertTrue(guard.conflicts.value.isEmpty())
}
@Test
fun externalVersionThatOnlyAddsReplacesTheBackup() {
val backup = Backup(follows(100, alice))
val external = follows(200, alice, bob)
backup.update(external)
assertSame(external, backup.saved)
assertTrue(guard.conflicts.value.isEmpty())
}
@Test
fun firstVersionWithNothingSavedIsAccepted() {
val backup = Backup(null)
val external = follows(200, alice)
backup.update(external)
assertSame(external, backup.saved)
}
@Test
fun externalLossyVersionFreezesTheBackupAndRaisesAConflict() {
val saved = follows(100, alice, bob)
val backup = Backup(saved)
val external = follows(200, bob)
backup.update(external)
assertSame(saved, backup.saved)
val conflict = openConflict()
assertSame(saved, conflict.saved)
assertSame(external, conflict.incoming)
assertSame(external, conflict.cause)
assertEquals(backupSlot(external), conflict.slot)
}
@Test
fun theSameVersionReEmittedKeepsTheSameConflict() {
val backup = Backup(follows(100, alice, bob))
val external = follows(200, bob)
backup.update(external)
val first = openConflict()
backup.update(external)
assertSame(first, openConflict())
}
@Test
fun anOlderVersionNeverReplacesAnOpenConflict() {
val backup = Backup(follows(100, alice, bob, carol))
val newer = follows(300, carol)
backup.update(newer)
backup.update(follows(200, bob, carol))
assertSame(newer, openConflict().incoming)
}
@Test
fun aNewerExternalVersionIsStillComparedWithTheFrozenBackup() {
val saved = follows(100, alice, bob)
val backup = Backup(saved)
backup.update(follows(200, bob))
// Adds to the lossy version, but still lacks alice from the saved one.
val newer = follows(300, bob, carol)
backup.update(newer)
val conflict = openConflict()
assertSame(saved, conflict.saved)
assertSame(newer, conflict.incoming)
assertSame(saved, backup.saved)
}
@Test
fun aLocalEditOnTopOfTheLossyVersionKeepsTheConflictAndItsCause() {
val saved = follows(100, alice, bob)
val backup = Backup(saved)
val external = follows(200, bob)
backup.update(external)
val edit = signedHere(follows(300, bob, carol))
backup.update(edit)
val conflict = openConflict()
assertSame(edit, conflict.incoming)
assertSame(external, conflict.cause)
assertSame(saved, backup.saved)
}
@Test
fun aLocalEditThatBringsEverythingBackClearsTheConflict() {
val backup = Backup(follows(100, alice, bob))
backup.update(follows(200, bob))
val edit = signedHere(follows(300, alice, bob))
backup.update(edit)
assertTrue(guard.conflicts.value.isEmpty())
assertSame(edit, backup.saved)
}
@Test
fun keepingTheNewVersionMovesTheBackupForward() {
val backup = Backup(follows(100, alice, bob))
val external = follows(200, bob)
backup.update(external)
guard.keepIncoming(openConflict())
assertTrue(guard.conflicts.value.isEmpty())
assertSame(external, backup.saved)
}
@Test
fun aStaleOrRepeatedAnswerDoesNothing() {
val backup = Backup(follows(100, alice, bob))
backup.update(follows(200, bob))
val stale = openConflict()
val newer = follows(300)
backup.update(newer)
guard.keepIncoming(stale)
assertSame(newer, openConflict().incoming)
assertNull(guard.startRestoring(stale))
val current = openConflict()
guard.keepIncoming(current)
guard.keepIncoming(current)
assertEquals(1, backup.saves)
}
@Test
fun theRetryOfAnOvertakenAcceptCannotClearTheNewerConflict() {
val backup = Backup(follows(100, alice, bob, carol))
val first = follows(200, bob, carol)
backup.update(first)
val claimed = openConflict()
// A newer lossy change arrives, then the retry of the (stale) first one runs.
val newer = follows(300, carol)
backup.update(newer)
guard.keepIncoming(claimed)
backup.update(first)
assertSame(newer, openConflict().incoming)
}
@Test
fun whileRestoringTheLossyVersionCannotReRaiseTheConflict() {
val backup = Backup(follows(100, alice, bob))
val external = follows(200, bob)
backup.update(external)
val conflict = openConflict()
val token = assertNotNull(guard.startRestoring(conflict))
backup.update(external)
assertTrue(guard.conflicts.value.isEmpty())
guard.finishRestoring(conflict, token, restored = true)
assertTrue(guard.conflicts.value.isEmpty())
}
@Test
fun aFailedRestoreReopensTheConflict() {
val backup = Backup(follows(100, alice, bob))
val external = follows(200, bob)
backup.update(external)
val conflict = openConflict()
val token = assertNotNull(guard.startRestoring(conflict))
guard.finishRestoring(conflict, token, restored = false)
assertSame(conflict, openConflict())
// The reopened conflict can still be answered, and its retry still works.
guard.keepIncoming(conflict)
assertSame(external, backup.saved)
}
@Test
fun aFailedRestoreDoesNotOverwriteANewerConflict() {
val backup = Backup(follows(100, alice, bob, carol))
backup.update(follows(200, bob, carol))
val conflict = openConflict()
val token = assertNotNull(guard.startRestoring(conflict))
val newer = follows(300, carol)
backup.update(newer)
guard.finishRestoring(conflict, token, restored = false)
assertSame(newer, openConflict().incoming)
}
@Test
fun anExternalWipeIsQuestioned() {
val saved = follows(100, alice)
val backup = Backup(saved)
backup.update(follows(200))
assertSame(saved, backup.saved)
assertNotNull(openConflict())
}
@Test
fun anExternalWipeOfAnEmptyBackupIsNotStored() {
val saved = follows(100)
val backup = Backup(saved)
backup.update(follows(200))
assertSame(saved, backup.saved)
assertTrue(guard.conflicts.value.isEmpty())
}
@Test
fun aWipeSignedHereOrKeptByTheUserIsStored() {
val backup = Backup(follows(100, alice))
val wipe = signedHere(follows(200))
backup.update(wipe)
assertSame(wipe, backup.saved)
val other = Backup(follows(100, alice))
// A different timestamp, or it would be the very event signed above.
val externalWipe = follows(201)
other.update(externalWipe)
guard.keepIncoming(openConflict())
assertSame(externalWipe, other.saved)
}
@Test
fun droppingASlotForgetsItsConflict() {
val backup = Backup(follows(100, alice, bob))
backup.update(follows(200, bob))
val conflict = openConflict()
guard.drop(conflict.slot)
assertTrue(guard.conflicts.value.isEmpty())
guard.keepIncoming(conflict)
assertEquals(0, backup.saves)
}
@Test
fun eachEventKindHasItsOwnSlot() {
val followsBackup = Backup(follows(100, alice, bob))
val mutes = Backup(signer.sign<Event>(100, 10000, arrayOf(arrayOf("p", alice)), ""))
followsBackup.update(follows(200, bob))
mutes.update(signer.sign<Event>(200, 10000, arrayOf(), ""))
assertEquals(2, guard.conflicts.value.size)
}
@Test
fun openConflictsRoundTripThroughStorage() {
val saved = follows(100, alice, bob)
val external = follows(200, bob)
Backup(saved).update(external)
val afterRestart = BackupConflictGuard(isLocallySigned = { false })
afterRestart.restore(guard.open())
val conflict =
afterRestart.conflicts.value.values
.single()
assertEquals(saved.id, conflict.saved.id)
assertEquals(external.id, conflict.incoming.id)
assertEquals(external.id, conflict.cause.id)
}
@Test
fun aStoredConflictThatNoLongerLosesDataIsNotRestored() {
val saved = follows(100, alice)
val putBack = follows(200, alice, bob)
guard.restore(listOf(Triple(saved, putBack, putBack)))
assertTrue(guard.conflicts.value.isEmpty())
}
@Test
fun keepingARestoredConflictReappliesItsVersion() {
val external = follows(200, bob)
guard.restore(listOf(Triple(follows(100, alice, bob), external, external)))
guard.keepIncoming(openConflict())
assertEquals(listOf(external), reapplied)
// The reapplied update is now accepted instead of raising the conflict again.
val backup = Backup(follows(100, alice, bob))
backup.update(external)
assertSame(external, backup.saved)
}
@Test
fun aReEmittedVersionGivesARestoredConflictItsRetryBack() {
val saved = follows(100, alice, bob)
val external = follows(200, bob)
guard.restore(listOf(Triple(saved, external, external)))
val backup = Backup(saved)
backup.update(external)
guard.keepIncoming(openConflict())
assertTrue(reapplied.isEmpty())
assertSame(external, backup.saved)
}
@Test
fun everyChangeToTheOpenConflictsIsReported() {
val backup = Backup(follows(100, alice, bob))
val external = follows(200, bob)
backup.update(external)
assertEquals(1, changes)
// A re-emit changes nothing to store.
backup.update(external)
assertEquals(1, changes)
val conflict = openConflict()
val token = assertNotNull(guard.startRestoring(conflict))
assertEquals(2, changes)
guard.finishRestoring(conflict, token, restored = false)
assertEquals(3, changes)
guard.drop(conflict.slot)
assertEquals(4, changes)
guard.drop(conflict.slot)
assertEquals(4, changes)
}
@Test
fun anOlderVersionNeverRollsTheBackupBack() {
val saved = follows(200, alice)
val backup = Backup(saved)
backup.update(signedHere(follows(100, alice, bob)))
assertSame(saved, backup.saved)
}
@Test
fun aFailedRestoreDoesNotReopenAConflictTheBackupMovedPast() {
val saved = follows(100, alice, bob)
val backup = Backup(saved)
backup.update(follows(200, bob))
val conflict = openConflict()
val token = assertNotNull(guard.startRestoring(conflict))
// While the signer is still asking, another app puts everything back and more.
val fixed = follows(300, alice, bob, carol)
backup.update(fixed)
guard.finishRestoring(conflict, token, restored = false)
assertTrue(guard.conflicts.value.isEmpty())
assertSame(fixed, backup.saved)
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.backups
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** The backup guard's use of the events' own diffs; the diffs themselves are tested in quartz. */
class ReplaceableBackupDiffTest {
private val signer = NostrSignerSync(KeyPair())
private fun sign(
kind: Int,
createdAt: Long,
tags: Array<Array<String>>,
content: String = "",
): Event = signer.sign<Event>(createdAt, kind, tags, content)
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
@Test
fun lossyRewriteIsDetected() {
val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob)))
val incoming = sign(3, 200, arrayOf(arrayOf("p", bob)))
val diff = assertIs<ContactListDiff>(ReplaceableBackupDiff.detectLoss(saved, incoming))
assertEquals(listOf(alice), diff.follows.removed.map { it.pubKey })
}
@Test
fun additionsOnlyAreNotALoss() {
val saved = sign(3, 100, arrayOf(arrayOf("p", alice)))
val incoming = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob)))
assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming))
}
@Test
fun clearedPrivateItemsAreALoss() {
val saved = sign(10000, 100, arrayOf(), "encrypted")
val incoming = sign(10000, 200, arrayOf(), "")
assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming))
}
@Test
fun olderVersionIsNeverALoss() {
val saved = sign(3, 200, arrayOf(arrayOf("p", alice)))
val incoming = sign(3, 100, arrayOf())
assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming))
}
@Test
fun locallySignedEventsTrackOnlyReplaceables() {
val list = sign(10000, 100, arrayOf())
val note = sign(1, 100, arrayOf())
LocallySignedEvents.mark(list)
LocallySignedEvents.mark(note)
assertTrue(LocallySignedEvents.contains(list.id))
assertFalse(LocallySignedEvents.contains(note.id))
}
@Test
fun locallySignedEventsForgetTheOldestBeyondCapacity() {
val first = sign(10000, 1, arrayOf())
LocallySignedEvents.mark(first)
repeat(600) { LocallySignedEvents.mark(sign(10000, 2L + it, arrayOf())) }
assertFalse(LocallySignedEvents.contains(first.id))
}
@Test
fun restoreDropsClientPowAndPastExpiration() {
val saved =
sign(
10000,
100,
arrayOf(
arrayOf("p", alice),
arrayOf("client", "OtherApp"),
arrayOf("nonce", "123", "20"),
arrayOf("expiration", "500"),
arrayOf("word", "spam"),
),
"private",
)
val tags = BackupRestore.tagsToResign(saved, now = 1000)
assertEquals(listOf(listOf("p", alice), listOf("word", "spam")), tags.map { it.toList() })
}
@Test
fun restoreKeepsAFutureExpiration() {
val saved = sign(10000, 100, arrayOf(arrayOf("p", alice), arrayOf("expiration", "5000")))
val tags = BackupRestore.tagsToResign(saved, now = 1000)
assertEquals(listOf(listOf("p", alice), listOf("expiration", "5000")), tags.map { it.toList() })
}
}
@@ -0,0 +1,295 @@
/*
* 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.service.http
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.lang.reflect.Proxy
import java.net.ConnectException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class LocalBlossomCacheRedirectSafetyTest {
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
private val origin = "https://blossom.example.com/$sha.jpg"
private val bridged = "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com"
private fun media(
url: String = origin,
kind: String = LocalBlossomCacheRedirectInterceptor.MEDIA,
) = Request.Builder().url(url).header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, kind)
private object IdentityCipher : NostrCipher {
override fun name() = "identity"
override fun encrypt(bytesToEncrypt: ByteArray) = bytesToEncrypt
override fun decrypt(bytesToDecrypt: ByteArray) = bytesToDecrypt
override fun decryptOrNull(bytesToDecrypt: ByteArray) = bytesToDecrypt
}
/** Records every request passed to proceed; [refuse] makes matching ones fail like a closed port. */
private fun chain(
request: Request,
sent: MutableList<Request>,
statusFor: (Request) -> Int = { 200 },
refuse: (Request) -> Boolean = { false },
): Interceptor.Chain =
Proxy.newProxyInstance(
Interceptor.Chain::class.java.classLoader,
arrayOf(Interceptor.Chain::class.java),
) { _, method, args ->
when (method.name) {
"request" -> request
"proceed" -> {
val proceeded = args[0] as Request
sent.add(proceeded)
if (refuse(proceeded)) throw ConnectException("Connection refused")
val status = statusFor(proceeded)
Response
.Builder()
.request(proceeded)
.protocol(Protocol.HTTP_1_1)
.code(status)
.message(if (status == 200) "OK" else "Not Found")
.body("".toResponseBody(null))
.build()
}
else -> throw UnsupportedOperationException(method.name)
}
} as Interceptor.Chain
@Test
fun getIsBridged() {
val sent = mutableListOf<Request>()
LocalBlossomCacheRedirectInterceptor { true }.intercept(chain(media().build(), sent)).close()
assertEquals(bridged, sent.single().url.toString())
}
@Test
fun profilePicturesOnlyBridgesProfilePicturesAlone() {
// The "profile pictures only" setting: bridge a request only when it is a profile picture.
val interceptor = LocalBlossomCacheRedirectInterceptor { profilePicture -> profilePicture }
val feedImage = mutableListOf<Request>()
interceptor.intercept(chain(media().build(), feedImage)).close()
assertEquals(origin, feedImage.single().url.toString())
val avatar = mutableListOf<Request>()
interceptor.intercept(chain(media(kind = LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE).build(), avatar)).close()
assertEquals(bridged, avatar.single().url.toString())
}
@Test
fun unmarkedRequestsAreNeverBridged() {
// BUD-02 `GET /list/<pubkey>` ends in 64 hex chars too, but it is not a blob download.
val list = "https://blossom.example.com/list/$sha"
val sent = mutableListOf<Request>()
LocalBlossomCacheRedirectInterceptor { true }
.intercept(
chain(
Request
.Builder()
.url(list)
.header("Authorization", "Nostr abc")
.build(),
sent,
),
).close()
assertEquals(list, sent.single().url.toString())
}
@Test
fun authGatedDownloadsGoToTheirServer() {
val sent = mutableListOf<Request>()
LocalBlossomCacheRedirectInterceptor { true }
.intercept(chain(media().header("Authorization", "Nostr abc").build(), sent))
.close()
assertEquals(origin, sent.single().url.toString())
}
@Test
fun theMarkerNeverLeavesTheApp() {
listOf(
LocalBlossomCacheRedirectInterceptor { true },
LocalBlossomCacheRedirectInterceptor { false },
LocalBlossomCacheRedirectInterceptor(bridges = false) { true },
).forEach { interceptor ->
val sent = mutableListOf<Request>()
interceptor.intercept(chain(media().build(), sent)).close()
assertNull(sent.single().header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER))
}
}
@Test
fun aNonBridgingInstanceLeavesMediaAlone() {
val sent = mutableListOf<Request>()
LocalBlossomCacheRedirectInterceptor(bridges = false) { true }.intercept(chain(media().build(), sent)).close()
assertEquals(origin, sent.single().url.toString())
}
@Test
fun mediaCallFactoryMarksCallsButKeepsAnExistingMarker() {
val seen = mutableListOf<Request>()
val factory =
LocalBlossomMediaCallFactory({ request ->
seen.add(request)
OkHttpClient().newCall(request)
})
factory.newCall(Request.Builder().url(origin).build())
factory.newCall(media(kind = LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE).build())
assertEquals(
listOf(LocalBlossomCacheRedirectInterceptor.MEDIA, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE),
seen.map { it.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER) },
)
}
@Test
fun serverSpecificMethodsAreNotBridged() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val requests =
listOf(
Request
.Builder()
.url(origin)
.head()
.build(),
Request
.Builder()
.url(origin)
.delete()
.header("Authorization", "Nostr abc")
.build(),
Request
.Builder()
.url(origin)
.put("x".toRequestBody())
.build(),
)
requests.forEach { request ->
val sent = mutableListOf<Request>()
interceptor.intercept(chain(request, sent)).close()
assertEquals(origin, sent.single().url.toString(), "${request.method} must reach its server")
}
}
@Test
fun decryptionKeyFollowsTheRewrite() {
val keys = EncryptionKeyCache()
val info = DecryptInformation(IdentityCipher, "image/jpeg")
keys.add(origin, info)
val sent = mutableListOf<Request>()
LocalBlossomCacheRedirectInterceptor(keyCache = keys) { true }.intercept(chain(media().build(), sent)).close()
// EncryptedBlobInterceptor runs after the rewrite and looks the key up by the URL it sees.
assertSame(info, assertNotNull(keys.get(sent.single().url.toString())))
}
@Test
fun deadCacheFallsBackToOriginAndIsReported() {
var reports = 0
val sent = mutableListOf<Request>()
val response =
LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true }
.intercept(chain(media().build(), sent) { it.url.host == "127.0.0.1" })
assertEquals(200, response.code)
assertEquals(listOf(bridged, origin), sent.map { it.url.toString() })
assertEquals(1, reports)
response.close()
}
@Test
fun deadCacheIsReportedForResolvedBlossomUrls() {
var reports = 0
val sent = mutableListOf<Request>()
val request = media("http://127.0.0.1:24242/$sha.mp4?xs=https://blossom.example.com").build()
assertFailsWith<ConnectException> {
LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true }
.intercept(chain(request, sent) { true })
}
assertEquals(1, reports)
assertTrue(sent.single().url.host == "127.0.0.1")
}
@Test
fun aCacheMissFallsBackToTheOrigin() {
var reports = 0
val sent = mutableListOf<Request>()
val response =
LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true }
.intercept(
chain(media().build(), sent, statusFor = { if (it.url.host == "127.0.0.1") 404 else 200 }),
)
// The blob still arrives: the cache is an optimisation, never a gate.
assertEquals(200, response.code)
assertEquals(origin, response.request.url.toString())
assertEquals(listOf(bridged, origin), sent.map { it.url.toString() })
// The cache answered, so it is alive — the bridge must not be switched off.
assertEquals(0, reports)
response.close()
}
@Test
fun aCacheErrorFallsBackToTheOriginToo() {
val sent = mutableListOf<Request>()
val response =
LocalBlossomCacheRedirectInterceptor { true }
.intercept(
chain(media().build(), sent, statusFor = { if (it.url.host == "127.0.0.1") 500 else 200 }),
)
assertEquals(200, response.code)
assertEquals(listOf(bridged, origin), sent.map { it.url.toString() })
response.close()
}
@Test
fun aServedBlobIsNotRefetchedFromTheOrigin() {
val sent = mutableListOf<Request>()
val response = LocalBlossomCacheRedirectInterceptor { true }.intercept(chain(media().build(), sent))
assertEquals(200, response.code)
// A hit must cost exactly one request, to the cache.
assertEquals(listOf(bridged), sent.map { it.url.toString() })
response.close()
}
}
@@ -0,0 +1,115 @@
/*
* 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.service.http
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.Request
import java.net.ServerSocket
import kotlin.concurrent.thread
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* With "videos via Tor" on, the player pool is built with a Tor-proxied [DynamicCallFactory], but a
* `blossom:` video resolves to the local Blossom cache on 127.0.0.1 only when the data source opens.
* Tor refuses loopback addresses, so the local cache must be reached directly or no video plays.
*/
class LocalBlossomCacheTorRoutingTest {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() = scope.cancel()
private fun manager(proxyPort: Int) =
DualHttpClientManager(
userAgent = "test",
proxyPortProvider = MutableStateFlow(proxyPort),
isMobileDataProvider = MutableStateFlow(false),
keyCache = EncryptionKeyCache(),
scope = scope,
dns = SurgeDns(),
shouldBridgeBlossomCache = { true },
onionCache = OnionLocationCache(),
)
/** A port nothing listens on: stands in for a SOCKS proxy that cannot reach the target. */
private fun deadPort(): Int = ServerSocket(0).use { it.localPort }
/** Minimal one-shot HTTP server standing in for the local Blossom cache. */
private fun localCache(body: String): ServerSocket {
val server = ServerSocket(0)
thread(isDaemon = true) {
server.accept().use { socket ->
// Drain the request headers before answering.
val reader = socket.getInputStream().bufferedReader()
do {
val line = reader.readLine()
} while (!line.isNullOrEmpty())
socket.getOutputStream().write(
"HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body".toByteArray(),
)
}
}
return server
}
@Test
fun proxiedFactoryReachesLoopbackCacheDirectly() {
val cache = localCache("video-bytes")
val factory = manager(deadPort()).getDynamicCallFactory(useProxy = true)
val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
val request = Request.Builder().url("http://127.0.0.1:${cache.localPort}/$sha.mp4?xs=https://blossom.example.com").build()
factory.newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals("video-bytes", response.body.string())
}
cache.close()
}
@Test
fun proxyDecisionUsesTheFinalRequestUrl() {
assertFalse(DynamicCallFactory.shouldUseProxy(true, "http://127.0.0.1:24242/abc.mp4?xs=https://blossom.primal.net"))
assertFalse(DynamicCallFactory.shouldUseProxy(true, "http://localhost:24242/abc.mp4"))
assertTrue(DynamicCallFactory.shouldUseProxy(true, "https://blossom.primal.net/abc.mp4"))
assertFalse(DynamicCallFactory.shouldUseProxy(false, "https://blossom.primal.net/abc.mp4"))
}
@Test
fun onlyTheDirectClientRewritesToTheLocalCache() {
val manager = manager(deadPort())
val direct = manager.getHttpClient(useProxy = false)
val proxied = manager.getHttpClient(useProxy = true)
// Both carry one (it strips the media marker), but only the direct client's bridges.
assertEquals(listOf(true), direct.interceptors.filterIsInstance<LocalBlossomCacheRedirectInterceptor>().map { it.bridges })
assertEquals(listOf(false), proxied.interceptors.filterIsInstance<LocalBlossomCacheRedirectInterceptor>().map { it.bridges })
}
}
@@ -166,6 +166,147 @@
<string name="road_event_traffic_jam">यातायात अचलता</string>
<string name="road_event_unknown">मार्ग घटना</string>
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
<string name="geocache_unnamed">भूकोष</string>
<string name="geocache_type_traditional">पारम्परिक</string>
<string name="geocache_type_multi">बहुकोष</string>
<string name="geocache_type_mystery">रहस्य</string>
<string name="geocache_size_micro">सूक्ष्म</string>
<string name="geocache_size_small">लघु</string>
<string name="geocache_size_regular">सामान्य</string>
<string name="geocache_size_large">बृहत</string>
<string name="geocache_size_other">अन्य आकार</string>
<string name="geocache_difficulty_short">कठिनाई%1$d</string>
<string name="geocache_terrain_short">धरा%1$d</string>
<string name="geocache_first_to_find">खोज में पहला</string>
<string name="geocache_is_art">कलाकृति</string>
<string name="geocache_mission">मुख्य खोज</string>
<string name="geocache_needs_verification">सत्यापित खोज</string>
<string name="geocache_claimed">कथित</string>
<string name="geocache_archived">संगृहीत</string>
<string name="geocache_hint_tap_to_reveal">क्या आप अटक गए। दबाएँ सहायक सूचना देखने के लिए</string>
<string name="geocache_hint_other_reading">क्या पठनशक्य नहीं। दबाएँ अतिरिक्त पठन के लिए</string>
<string name="geocache_nearby">निकट</string>
<string name="geocache_found_it">प्राप्त।</string>
<string name="geocache_verified_find">सत्यापित खोज</string>
<string name="geocache_invalid_proof">प्रमाण ठीक नहीं</string>
<string name="geocache_loading">कोष आवहन…</string>
<string name="geocache_hidden_by">छिपाया गया %1$s द्वारा</string>
<string name="geocache_navigate">मार्गदर्शन</string>
<string name="geocache_copy_coordinates">निर्देशांकों की अनुकृति</string>
<string name="geocache_coordinates_copied">निर्देशांक अनुकृत</string>
<string name="geocache_photos_section">चित्र</string>
<string name="geocache_mission_section">मुख्य खोज अभियान</string>
<string name="geocache_logs_section">अभिलेख</string>
<string name="geocache_no_logs">इस कोष का अभिलेखन अब तक किसी ने नहीं किया। सर्वप्रथम बनें।</string>
<string name="geocache_i_found_it">मैं ने खोज लिया</string>
<string name="geocache_didnt_find_it">खोज असफल</string>
<string name="geocache_add_note">टीका जोडें</string>
<string name="geocache_needs_maintenance">देखरेख आवश्यक वृत्तान्त</string>
<string name="geocache_add_to_hunt">खोज में जोडें</string>
<string name="geocache_hunt_new">नई खोज…</string>
<string name="geocache_hunt_none_yet">आपने कोई खोज नहीं की अब तक।</string>
<string name="geocache_hunt_already_on">पहले से इस खोज में लगे</string>
<string name="geocache_caches_here">%1$d कोष यहाँ</string>
<string name="geocache_photo_remove">इस चित्र को हटाएँ</string>
<string name="geocache_archived_notice">इस कोष को संगृहीत कर दिया गया। यह अब खेल में नहीं है।</string>
<string name="geocache_ftf_won_by">सर्वप्रथम खोज का कथन %1$s द्वारा</string>
<string name="geocache_ftf_available">सर्वप्रथम खोज अभी भी सम्भव</string>
<string name="geocache_owner_actions">स्वत्वधारी</string>
<string name="geocache_owner_edit">कोष सम्पादन</string>
<string name="geocache_owner_archive">कोष को संगृहीत करें</string>
<string name="geocache_owner_archive_confirm">संगृहीत करें। खोजकर्ता देखेंगे कि यह खेल के बाहर हो गया है। तथा सूचीकृत विवरण पठनशक्य रहेगा।</string>
<string name="geocache_owner_lock_ftf">सर्वप्रथम खोजक तालाबद्ध करें</string>
<string name="geocache_owner_lock_ftf_explain">एक एफ सूचक का प्रकाशन करता है %1$s का नाम लेकर। यह स्थायी है।</string>
<string name="geocache_owner_no_finders">कोई सत्यापित खोज नहीं अब तक। इसलिए तालाबद्ध करने के लिए कोई नहीं।</string>
<string name="geocache_owner_show_qr">सत्यापन अक्षरराशि दिखाएँ</string>
<string name="geocache_owner_qr_warning">इसे छाप कर कोष में रखें। अमेथिस्ट इसको नहीं रखेगा। यदि आप इसे खो देते हैं तो खोजक प्रमाणित नहीं कर पाएँगे कि वे वहाँ थे।</string>
<string name="geocache_log_type_dnf">खोज असफल</string>
<string name="geocache_log_type_note">टीका</string>
<string name="geocache_log_type_maintenance">देखरेख आवश्यक</string>
<string name="geocache_log_title">खोज का अभिलेखन</string>
<string name="geocache_log_cache_label">कोष</string>
<string name="geocache_log_your_log">आपका अभिलेख</string>
<string name="geocache_log_placeholder">कैसे चला।</string>
<string name="geocache_log_add_photo">चित्र जोडें</string>
<string name="geocache_log_mission_answer">आपका मुख्य खोज उत्तर</string>
<string name="geocache_log_proof_section">उपस्थिति प्रमाण</string>
<string name="geocache_log_scan_code">कोष में अक्षरराशि परखें</string>
<string name="geocache_log_scan_again">एक अन्य अक्षरराशि परखें</string>
<string name="geocache_log_proof_verified">सत्यापित। आप कोष के स्थान पर उपस्थित थे</string>
<string name="geocache_log_proof_explain">हस्ताक्षरित एक बार पेटीका में प्राप्त अक्षरराशि से। कुंचिका का अभिलेखन कभी भी नहीं किया जाता तथा इस पटल के बाहर नहीं जाता।</string>
<string name="geocache_log_proof_bad_code">वह एक सत्यापन अक्षरराशि नहीं।</string>
<string name="geocache_log_proof_wrong_cache">वह अक्षरराशि एक पृथक कोष का है।</string>
<string name="geocache_log_proof_optional">यह कोष प्रमाण नहीं पूछता। इसलिए आप सीधे इसका अभिलेखन कर सकते हैं।</string>
<string name="geocache_log_no_location_attached">आपका स्थान इस अभिलेख में संलग्न नहीं।</string>
<string name="geocache_log_post">पत्र प्रकाशन</string>
<string name="geocache_log_comment_title">अभिलेख जोडें</string>
<string name="geocache_tab_nearby">निकट</string>
<string name="geocache_tab_map">मानचित्र</string>
<string name="geocache_tab_hunts">खोज</string>
<string name="geocache_tab_finds">सफल खोज</string>
<string name="geocache_tab_mine">मेरा</string>
<string name="geocache_filter_unfound">अप्राप्त</string>
<string name="geocache_filter_difficulty">कठिनाई %1$d समन्यूनवा</string>
<string name="geocache_filter_terrain">धरा %1$d समन्यूनवा</string>
<string name="geocache_filter_has_prize">पुरस्कारयुक्त</string>
<string name="geocache_filter_verified_only">प्रमाण आवश्यक</string>
<string name="geocache_use_my_location">मेरे स्थान का उपयोग करें</string>
<string name="geocache_sorted_by_distance">दूरी अनुसार क्रमबद्ध</string>
<string name="geocache_sorted_by_recent">नवीनतम पहले</string>
<string name="geocache_hide_a_cache">कोष को छिपाएँ</string>
<string name="geocache_empty_nearby">कोई कोष नहीं यहाँ अब तक। अधिक विस्तृत अनुगमन सूची का प्रयास करें। अथवा प्रथम को छिपाएँ।</string>
<string name="geocache_empty_finds">आपने किसी खोज का अभिलेखन नहीं किया अब तक।</string>
<string name="geocache_empty_mine">आपने किसी कोष को छिपाया नहीं अब तक।</string>
<string name="geocache_empty_hunts">कोई खोज नहीं अब तक।</string>
<string name="geocache_stat_finds">सफल खोज</string>
<string name="geocache_stat_ftf">सर्वप्रथम खोज</string>
<string name="geocache_stat_verified">सत्यापित</string>
<string name="geocache_stat_hidden">छिपा हुआ</string>
<string name="geocache_map_recenter">मुझ पर मध्यीकरण</string>
<string name="geocache_map_long_press">मानचित्र पर दीर्घ दबाएँ वहाँ एक कोष छिपाने के लिए</string>
<string name="geocache_map_open">खोलें</string>
<string name="geocache_map_pin_count">%1$d कोष</string>
<string name="geocache_new_section_where">कहाँ</string>
<string name="geocache_new_section_what">क्या</string>
<string name="geocache_new_section_extras">अतिरिक्त</string>
<string name="geocache_new_section_proof">प्रमाण</string>
<string name="geocache_new_section_relays">पुनःप्रसारक</string>
<string name="geocache_new_location_warning">यह एक सार्वजनिक स्थान प्रकाशित करता है जो वास्तविक से पाँच मीटर व्यासार्ध तक की विस्थापन सम्भावना रखता है। नोस्टर पर कोई भी इसे देख सकता है। प्रकाशित घटना को पुनःअप्रकाशित नहीं किया जा सकता।</string>
<string name="geocache_new_pick_location">स्थल का चयन करें</string>
<string name="geocache_new_ladder">प्रकाशित करता है %1$s को। तथा स्थूलतर विभाग तीन अक्षरों तक निकटवर्ती खोज के लिए।</string>
<string name="geocache_new_name">नाम</string>
<string name="geocache_new_description">विवरण</string>
<string name="geocache_new_type">प्रकार</string>
<string name="geocache_new_size">आकार</string>
<string name="geocache_new_difficulty">कठिनाई</string>
<string name="geocache_new_terrain">धरा</string>
<string name="geocache_new_hint">सहायक संकेत</string>
<string name="geocache_new_hint_preview">खोजक देखेंगे</string>
<string name="geocache_new_hint_scramble">सहायक संकेत को अस्तव्यस्त करें (रोट॰तेरह)</string>
<string name="geocache_new_mission">मुख्य खोज अभियान</string>
<string name="geocache_new_modifiers">संशोधक</string>
<string name="geocache_new_require_proof">उपस्थिति प्रमाण आवश्यक</string>
<string name="geocache_new_proof_ready">एक सत्यापन कुंचिका उत्पादित इस कोष के लिए।</string>
<string name="geocache_new_log_relays">खोजक कहाँ अभिलेखन करना चाहिए</string>
<string name="geocache_new_publish">प्रकाशन</string>
<string name="geocache_new_needs_name">कोष नामकरण।</string>
<string name="geocache_new_needs_location">कोष कहाँ छिपाया जाए चयन करें।</string>
<string name="geocache_hunt_progress">%2$d में से %1$d प्राप्त</string>
<string name="geocache_hunt_start">खोज आरम्भ करें</string>
<string name="geocache_hunt_caches">%1$d कोष</string>
<string name="geocache_hunt_title">शीर्षक</string>
<string name="geocache_hunt_description">विवरण</string>
<string name="geocache_hunt_banner">ध्वज चित्र</string>
<string name="geocache_hunt_pick_caches">कोष क्रमानुसार</string>
<string name="geocache_hunt_add_cache">कोष जोडें</string>
<string name="geocache_hunt_move_up">ऊर्ध्वगमन</string>
<string name="geocache_hunt_move_down">अधोगमन</string>
<string name="geocache_hunt_remove">हटाएँ</string>
<string name="geocache_hunt_theme">पृष्ठ शैली</string>
<string name="geocache_hunt_map_style">मानचित्र शैली</string>
<string name="geocache_hunt_needs_title">खोज का एक शीर्षक रखें।</string>
<string name="geocache_hunt_needs_caches">न्यूनतम एक कोष जोडें।</string>
<string name="geocache_publish_failed">प्रकाशन असफल। पुनःप्रसारक संयोजन का जाँच करें तथा पुनःप्रयास करें।</string>
<string name="podcast_value_zap_split_hint">इसको ज्साप इनमें विभाजित :</string>
<string name="podcast_value_split_percent">%1$d प्रतिशतप्रतिशत</string>
<string name="podcast_value_for_value">मूल्य के लिए मूल्य</string>
@@ -214,6 +355,18 @@
<string name="qr_scanner_kind_web">यह एक जाल योजक है।</string>
<string name="qr_scanner_kind_text">यह साधारण लेख है। नोस्टर सम्बन्धित अक्षरराशि नहीं।</string>
<string name="qr_scanner_kind_nostr_unsupported">यह नोस्टर सम्बन्धित अक्षरराशि है। पर इस पटल द्वारा नहीं खोला जा सकता।</string>
<string name="qr_scanner_label_profile">परिचय</string>
<string name="qr_scanner_label_note">टीका</string>
<string name="qr_scanner_label_nostr">नोस्टर अक्षरराशि</string>
<string name="qr_scanner_label_relay">पुनःप्रसारक</string>
<string name="qr_scanner_label_nsec">निजी कुंचिका</string>
<string name="qr_scanner_label_wallet">धनकोष संयोजन</string>
<string name="qr_scanner_label_bunker">दूरस्थ हस्ताक्षरकर्ता पता</string>
<string name="qr_scanner_label_signer">हस्ताक्षरकर्ता संयोजन अनुरोध</string>
<string name="qr_scanner_label_lightning">लैटनिंग चालान</string>
<string name="qr_scanner_label_cashu">काशयू अक्षरराशि</string>
<string name="qr_scanner_label_web">जाल योजक</string>
<string name="qr_scanner_label_text">साधारण लेख</string>
<string name="qr_scanner_open_link">योजक खोलें</string>
<string name="qr_scanner_copy">अनुकृति</string>
<string name="qr_scanner_copied">अनुकृत</string>
@@ -675,14 +828,27 @@
<string name="workout_hours">घण्टे</string>
<string name="workout_minutes">मिनुट</string>
<string name="workout_seconds">सेकण्ड</string>
<string name="workout_suggestion_connect_title">पहले ही अभिलेखित व्यायामों का उपयोग करें</string>
<string name="workout_suggestion_connect_message">स्वास्थ्य संयोजन (सामसंग स्वास्थ्य गूगिल फिट फिटबिट गार्मिन इत्यादि) से जोडें मेरा स्वास्थ्य पटल का निर्माण करने के लिए तथा आपका अपना व्यायाम साराम्श तथा इस व्यायाम को आपकी घडी के अभिलेखों से भरने के लिए प्रत्येक संख्या को हाथ से लिखने के स्थान पर।</string>
<string name="workout_suggestion_connect_button">संयोजन</string>
<string name="workout_suggestion_distance_km">%1$s सहस्रमीटर</string>
<string name="workout_from_health_connect">स्वास्थ्य संयोजन से</string>
<string name="workout_suggestion_connect_details">अमेथिस्ट क्या पढता है</string>
<string name="health_connect_rationale_title">स्वास्थ्य संयोजन तथा अमेथिस्ट</string>
<string name="health_connect_rationale_headline">अमेथिस्ट स्वास्थ्य संयोजन का उपयोग करता है मेरा स्वास्थ्य पटल का निर्माण करने के लिए। आपका अपना अभ्यास साराम्श आपके अपने संचारयन्त्र पर।</string>
<string name="health_connect_rationale_intro">मेरा स्वास्थ्य (विकल्पसूची से आप से मेरा स्वास्थ्य) आपको दिखाता है आप कैसे अभ्यास कर रहे हैं। इस सप्ताह कितना किया पूर्व सप्ताह की तुलना में। आपका समय कैसे बाँटा गया धवन द्विचक्रचालन चलन तरण तथा व्यायामशाला में। आपके सर्वोत्तम प्रयास। कितने दिन आपने अभ्यास किया। तथा आपकी वर्तमान निरन्तर अभ्यास दिनों की स्थिति। अमेथिस्ट इस चित्र का निर्माण करता है उन व्यायामों से जिन्हें आपकी घडी ने अथवा स्वास्थ्य क्रमक ने पहले से स्वास्थ्य संयोजन में अभिलेखित किए हैं। तथा अन्य व्यायाम जो आपने सीधे अमेथिस्ट में अभिलेखित किए। इसका परिकलन आपके संचारयन्त्र पर किया जाता है तथा आपको दिखाया जाता है।</string>
<string name="health_connect_rationale_what_title">अमेथिस्ट क्या पढता है और क्यों</string>
<string name="health_connect_rationale_exercise">व्यायाम। आप ने कौनसी क्रिया की। वह कब आरम्भ हुआ तथा कितने समय तक चला। यह व्यायाम स्वयम है। यह चलाता है आपकी व्यायाम गिनती आपका अभ्यास समय प्रत्येक क्रिया का विश्लेशन आपके सक्रिय दिन तथा निरन्तर अभ्यास दिन।</string>
<string name="health_connect_rationale_distance">दूरी। आप कितने दूर गए। प्रदर्शित इस सप्ताह की दूरी पूर्व सप्ताह की तुलना में। आपका साप्ताहिक मध्यमान। दूरी प्रति क्रिया अनुसार। तथा सर्वोत्तम दूरी।</string>
<string name="health_connect_rationale_calories">सक्रिय तथा कुल उष्ममानइकाई। ऊर्जा जिसका व्यय व्यायाम से किया गया। प्रदर्शित आपकी ऊर्जा इस सप्ताह के रूप में तथा पूर्व सप्ताह की तुलना में। सक्रिय उष्ममानइकाई उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममानइकाई विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं।</string>
<string name="health_connect_rationale_heart_rate">हृदयस्पन्दन गति। मध्यमान तथा उच्चतम हृदयस्पन्दन गति व्यायाम के समय। तथा उस कालवधि का मध्यमान तथा उच्चतम हृदयस्पन्दन गति में संयुक्त। इसी से आप बता सकते हैं एक सरल सप्ताह और कठिन सप्ताह में पृथकता समान दूरी पर।</string>
<string name="health_connect_rationale_steps">पद। पदों की गणना एक धवन चलन अथवा पदयात्रा की। प्रदर्शित आपका साप्ताहिक पद मध्यमान तथा आपकी उच्चतम पद गिनती के रूप में।</string>
<string name="health_connect_rationale_elevation">ऊँचाई वृद्धि। आप कितना ऊपर चढे। प्रदर्शित आपका साप्ताहिक आरोहण मध्यमान तथा अपका बृहत्तम आरोहण के रूप में। यही है पृथकता एक समतल सप्ताह का एक पर्वतीय से।</string>
<string name="health_connect_rationale_limits_title">अमेथिस्ट क्या नहीं करता</string>
<string name="health_connect_rationale_limit_window">केवल पिछले चार सप्ताहों के व्यायामों को पढता है। तथा तब ही जब मेरा स्वास्थ्य अथवा व्यायाम सम्पादक खुला है। पृष्ठभूत होकर कभी पढता नही।</string>
<string name="health_connect_rationale_limit_write">स्वास्थ्य संयोजन पर कुछ भी लिखता नहीं। तथा आपको कभी भी नहीं पूछता आपका व्यायाम मार्ग अथवा स्थल अथवा अन्य किसी भी स्वास्थ्य जानकारी प्रकार के विषय में।</string>
<string name="health_connect_rationale_limit_publish">आपकी स्वास्थ्य जानकारी कहीं अन्यत्र नहीं भेजता। मेरा स्वास्थ्य का परिकलन आपके संचारयन्त्र पर किया जाता है तथा वहीं रहता है। यदि आगे आप एक व्यायाम को प्रकाशित करना चाहते हैं तो उस व्यायाम में बाँटें पर दबाएँ। अमेथिस्ट ने जो भरा उसे पढें। तथा स्वयम प्रकाशित करें। एक व्यायाम प्रत्येक बार। स्वचालित कभी नहीं।</string>
<string name="health_connect_rationale_limit_optional">यह सम्पूर्ण सुविधा विकल्पात्मक है। अनुमतियों का निराकरण करें स्वास्थ्य संयोजन में कभी भी। मेरा स्वास्थ्य अपनी आज्ञा अनुरोध अवस्था तक लौट जाएगा। अथवा स्थापना विकल्पों में सम्पादन पटल में सम्पादक सुझाव निष्क्रिय करें। अमेथिस्ट के शेष भाग कार्य करते रहेंगे।</string>
<string name="health_connect_rationale_privacy_policy">सम्पूर्ण गोपनीयता नीति पढें</string>
<string name="my_fitness_title">मेरा स्वास्थ्य</string>
<string name="my_fitness_window">पिछले चार सप्ताह</string>
@@ -721,7 +887,7 @@
<string name="my_fitness_connect_button">संयोजन</string>
<string name="my_fitness_share">व्यायाम बाँटें</string>
<string name="my_fitness_unit_bpm">स्पन्दनप्रतिमिनुट</string>
<string name="my_fitness_unit_kcal">सहस्रउष्ममानइकाई</string>
<string name="my_fitness_unit_kcal">सहस्रउष्म॰इ</string>
<string name="my_fitness_unit_km">सहस्रमीटर</string>
<string name="my_fitness_unit_mi">मील</string>
<string name="my_fitness_unit_m">मीटर</string>
@@ -1161,6 +1327,8 @@
<string name="poll_closing_date_time">मतदान समापन दिनांक तथा समय</string>
<string name="poll_closing_in">मतदान %1$s में समाप्त होगा</string>
<string name="looking_for_event">घटना %1$s के लिए खोज चल रहा है</string>
<string name="looking_for_event_title">आवहन चालू</string>
<string name="looking_for_private_event">आपके निजी सन्देशों का आवहन की प्रतीक्षा…</string>
<string name="send_zap">ज्साप भेजें</string>
<string name="custom_zaps_add_a_message">सार्वजनिक संदेश जोडें</string>
<string name="custom_zaps_add_a_message_private">निजी संदेश जोडें</string>
@@ -1436,6 +1604,14 @@
<string name="user_preferences">उपयोगकर्ता आद्यताएँ</string>
<string name="settings_search_placeholder">खोज स्थापना विकल्प</string>
<string name="settings_search_no_results">"%1$s" के लिए कोई स्थापना विकल्प प्राप्त नहीं</string>
<string name="marmot_preview_group_updated">समूह अद्यतन किया गया</string>
<string name="marmot_preview_media">संलग्न अभिलेख</string>
<string name="marmot_preview_no_text">सन्देश</string>
<string name="marmot_preview_with_sender">%1$s : %2$s</string>
<string name="marmot_empty_create_action">समूह बनाएँ</string>
<string name="marmot_invite_device_banner">मार्मोट आमन्त्रण अन्य यन्त्र तक जा रहे हैं तथा यहाँ नहीं खोले जा सकते।</string>
<string name="marmot_invite_device_banner_dismiss">हटाएँ</string>
<string name="marmot_invite_device_publish">इस यन्त्र से प्रकाशन</string>
<string name="reset_marmot_confirm_title">क्या मार्मोट॰ स्थिति पुनःस्थापित करें।</string>
<string name="reset_marmot_confirm_body">यह स्थायी रूप से सभी मार्मोट॰ समूह चर्चा तथा सन्देश इतिहास तथा एमएलएस॰ कुंचिका मिटा देगा इस यन्त्र पर वर्तमान लेखा के लिए। समकक्षजन को सूचित नहीं किया जाएगा तथा सम्भाव्यतः आपको समूहों मे देखते रहेंगे उनके अगले स्थायी अभिलेखन तक। इसे पूर्ववत नहीं किया जा सकता। एक नयी कुंचिकापोटली प्रकाशित की जाएगी अगली बार जब क्रमक समचरणीकरण किया जाएगा।</string>
<string name="reset_marmot_confirm_action">पुनःस्थापन</string>
@@ -2025,6 +2201,8 @@
<string name="video_player_settings_action_pip_description">चलचित्र दिखाएँ तैरते गवाक्ष में (अदृश्य यदि अनालम्बित)</string>
<string name="video_player_settings_action_cast">यन्त्र पर निक्षेपण</string>
<string name="video_player_settings_action_cast_description">चलचित्र को क्रेमकास्ट॰ ग्राहक तक भेजें आपके वैफै॰ पर (स्थानीय अभिलेखों के लिए अदृष्ट)</string>
<string name="video_player_settings_action_captions">अवबोधक</string>
<string name="video_player_settings_action_captions_description">उपशीर्षक सक्रिय अथवा निष्क्रिय करें। अदृश्य जब चलचित्र में उपलब्ध नहीं</string>
<string name="audio_visualizer_settings_description">ध्वनि के स्वर बजते समय प्रदर्शनीय चलनाकृति का चयन करें।</string>
<string name="audio_visualizer_off">निष्क्रिय</string>
<string name="audio_visualizer_bars">वर्णक्रम पट्टियाँ</string>
@@ -3003,13 +3181,18 @@
<string name="app_notification_replies_channel_description">आपको सूचित करता है जब किसी ने आपके पत्र को उत्तर लिखा हो</string>
<string name="app_notification_replies_channel_name">प्रतिवचन</string>
<string name="app_notification_replies_summary">नए उत्तर</string>
<string name="app_notification_reply_failed">नहीं भेजा गया</string>
<string name="app_notification_reply_label">उत्तर</string>
<string name="app_notification_reply_retry">पुनःप्रयास</string>
<string name="app_notification_reply_sending">भेजा जा रहा है…</string>
<string name="app_notification_reply_unconfirmed">अभी भी भेजा जा रहा है…</string>
<string name="app_notification_reposts_channel_description">आपको सूचित करता है जब कोई आपके पत्र को पुनःप्रकाशित करते हैं</string>
<string name="app_notification_reposts_channel_message">%1$s ने आपके पत्र को पुनःप्रकाशित किया</string>
<string name="app_notification_reposts_channel_name">पुनःप्रकाशन</string>
<string name="app_notification_reposts_summary">नए पुनःप्रकाशन</string>
<string name="app_notification_scheduled_posts_channel_description">सूचनाएँ जब एक समयनिर्धारित पत्र का प्रकाशन होता है अथवा प्रकाशन असफल।</string>
<string name="app_notification_scheduled_posts_channel_name">प्रकाशन समय निर्धारित पत्र</string>
<string name="app_notification_shortcut_account_removed">इस लेखा को अमेथिस्ट से हटाया गया था।</string>
<string name="app_notification_zaps_channel_description">सूचित करता है जब किसी ने आपको ज्साप किया</string>
<string name="app_notification_zaps_channel_message">%1$s साट्स</string>
<string name="app_notification_zaps_channel_message_for">%1$s के लिए</string>
@@ -3210,6 +3393,10 @@
<string name="call_unmute">मौन हटाएँ</string>
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">प्रत्याह्वान जालपता उपलब्ध नहीं %1$s के उत्तर से</string>
<string name="cancel">निरस्त करें</string>
<string name="captions_off">निष्क्रिय</string>
<string name="captions_turn_off">अवबोधक निष्क्रिय करें</string>
<string name="captions_turn_on">अवबोधक सक्रिय करें</string>
<string name="captions_unnamed_track">अवबोधक</string>
<string name="cashu">काशयू अक्षरराशि</string>
<string name="cashu_checking_mint">टकसाल का जाँच…</string>
<string name="cashu_failed_redemption">काशयू नहीं चुका पाए</string>
@@ -3695,10 +3882,12 @@
<string name="kind_adventure_prologue">साहसिक प्रस्तावना</string>
<string name="kind_adventure_reading">साहसिक पठन</string>
<string name="kind_adventure_scene">साहसिक दृश्य</string>
<string name="kind_app_curation_set">क्रमक सूची</string>
<string name="kind_app_recommendations">क्रमक अनुशंसाएँ</string>
<string name="kind_appointment">समयनियुक्ति</string>
<string name="kind_apps">क्रमक</string>
<string name="kind_appt_rsvp">समयनियुक्ति प्रत्युत्तर अपेक्षित</string>
<string name="kind_article_curation_set">लेख सूची</string>
<string name="kind_audio_header">ध्वनि शीर्षक</string>
<string name="kind_audio_track">ध्वनि अभिलेख</string>
<string name="kind_authored_podcasts">कृत पुटप्रसार</string>
@@ -3711,6 +3900,7 @@
<string name="kind_blossom_auth">ब्लोस्सम॰ प्रमाणीकरण</string>
<string name="kind_blossom_servers">ब्लोस्सम॰ प्रसारसंगणक</string>
<string name="kind_bookmark_list">स्मर्त्तव्य सूची</string>
<string name="kind_bookmark_set">स्मर्त्तव्य सूची</string>
<string name="kind_broadcast_relays">प्रसारण पुनःप्रसारक</string>
<string name="kind_calendar">दिनदर्शिका</string>
<string name="kind_channel_definition">प्रणाली परिभाषा</string>
@@ -3748,6 +3938,7 @@
<string name="kind_emoji_packs">भावचिह्न पोटलियाँ</string>
<string name="kind_ephemeral_chat">अनभिलिखित चर्चा</string>
<string name="kind_ephemeral_chatrooms">अस्थायी चर्चाशालाएँ</string>
<string name="kind_favorite_algo_feeds">इष्ट सूचनावलियाँ</string>
<string name="kind_favorite_podcasts">प्रिय पुटप्रसार</string>
<string name="kind_favorite_relays">इष्ट पुनःप्रसारक</string>
<string name="kind_file_headers">अभिलेख शीर्षक</string>
@@ -3756,28 +3947,36 @@
<string name="kind_follow_packs">अनुचरण पोटलियाँ</string>
<string name="kind_geohash_follows">भूभागसूचक अनुचरित</string>
<string name="kind_gift_wraps">उपहारकोष</string>
<string name="kind_git_authors">गिट लेखक</string>
<string name="kind_git_issue">गिट समस्या</string>
<string name="kind_git_patch">गिट परिहार</string>
<string name="kind_git_pr">कर्षण अनुरोध</string>
<string name="kind_git_pr_update">कर्षण अनुरोध अद्यतन</string>
<string name="kind_git_reply">गिट उत्तर</string>
<string name="kind_git_repo">गिट क्रमलेखकोष</string>
<string name="kind_git_repositories">गिट क्रमलेखकोश</string>
<string name="kind_git_status_applied">गिट स्थिति। प्रयुक्त</string>
<string name="kind_git_status_closed">गिट स्थिति। आवृत</string>
<string name="kind_git_status_draft">गिट स्थिति। कच्चा</string>
<string name="kind_git_status_open">गिट स्थिति। खुला</string>
<string name="kind_good_wiki_authors">विश्वसनीय समूहसम्पाद्य लेखक</string>
<string name="kind_good_wiki_relays">विश्वसनीय समूहसम्पाद्य पुनःप्रसारक</string>
<string name="kind_hashtag_follows">विषयसूचक अनुचरित</string>
<string name="kind_highlights">प्रमुखताएँ</string>
<string name="kind_http_auth">एचटीटीपी॰ प्रमाणीकरण</string>
<string name="kind_index_relay_list">निर्देशिका पुनःप्रसारक सूची</string>
<string name="kind_interest_set">रुचियाँ</string>
<string name="kind_live_chats">वर्तमान बातचीत</string>
<string name="kind_live_streams">वर्तमानप्रवाह</string>
<string name="kind_media_follows">श्रव्यदृश्याभिलेख अनुचरित</string>
<string name="kind_media_starter_pack">श्रव्यदृश्याभिलेख आरम्भ पोटली</string>
<string name="kind_medical_data">चिकित्सा अभिलेख</string>
<string name="kind_meeting_room">मेलन शाला</string>
<string name="kind_meeting_space">मेलन स्थल</string>
<string name="kind_music_playlist">संगीतसूची</string>
<string name="kind_music_track">संगीत अभिलेख</string>
<string name="kind_mute_list">मौन सूची</string>
<string name="kind_mute_set_for_kind">प्रकार %1$s मौनकृत</string>
<string name="kind_named_bookmarks">नाम युक्त स्मर्त्तव्य सूची</string>
<string name="kind_nests_servers">निलय सेवासंगणक</string>
<string name="kind_nip">निप॰</string>
@@ -3792,6 +3991,7 @@
<string name="kind_outbox_relays">निर्गतपेटिका पुनःप्रसारक</string>
<string name="kind_pay_to">के लिए भुगतान</string>
<string name="kind_people_lists">व्यक्ति सूचियाँ</string>
<string name="kind_picture_curation_set">चित्र सूची</string>
<string name="kind_pictures">चित्र</string>
<string name="kind_pins">टाँके</string>
<string name="kind_podcast_episode">पुटप्रसार कडी</string>
@@ -3818,6 +4018,7 @@
<string name="kind_search_relays">खोज पुनःप्रसारक</string>
<string name="kind_shorts">लघु चलचित्र</string>
<string name="kind_shorts_repl">लघु चलचित्र (अनु)</string>
<string name="kind_simple_groups">समूह</string>
<string name="kind_torrent_comments">अनेकत्रावरोहण टिप्पणियाँ</string>
<string name="kind_torrents">अनेकत्रावरोहण</string>
<string name="kind_trusted_providers">विश्वसनीय प्रदाता</string>
@@ -3826,12 +4027,15 @@
<string name="kind_user_settings">प्रयोक्ता स्थापना विकल्प</string>
<string name="kind_user_status">उपयोगकर्ता स्थिति</string>
<string name="kind_video">चलचित्र</string>
<string name="kind_video_collaboration">चलचित्र सहयोग</string>
<string name="kind_video_list">चलचित्र सूची</string>
<string name="kind_video_repl">चलचित्र (अनु)</string>
<string name="kind_video_subtitles">चलचित्र उपशीर्षक</string>
<string name="kind_voice_msg">ध्वनि सन्देश</string>
<string name="kind_voice_reply">ध्वनि उत्तर</string>
<string name="kind_wake">जगाना</string>
<string name="kind_web_bookmark">जाल स्मर्त्तव्यचिह्न</string>
<string name="kind_wiki">विकि॰</string>
<string name="kind_wiki">समूहसम्पाद्य</string>
<string name="kind_workouts">व्यायाम</string>
<string name="kind_zap_goals">ज्साप उद्देश्य</string>
<string name="kind_zap_poll">ज्साप मतदान</string>
@@ -3863,6 +4067,8 @@
<string name="location_change_place">मानचित्र पर स्थल परिवर्तन करें</string>
<string name="location_channels">स्थानीय प्रणालियाँ</string>
<string name="location_pick_on_map">मानचित्र पर एक स्थल चुनें</string>
<string name="login_bunker_not_supported">अमेथिस्ट का आण्ड्रोइड संस्करण एक bunker:// पता से हस्ताक्षर प्रवेश नहीं कर सकता। इनका वितरण करता है जिससे अन्य क्रमक अमेथिस्ट को हस्ताक्षर करने का अनुरोध कर सकते हैं। स्वयम हस्ताक्षर प्रवेश नहीं कर सकता। एक बाहरी हस्ताक्षरकर्ता क्रमक का उपयोग करें अथवा अमेथिस्ट का लेखनपीठ संगणक संस्करण।</string>
<string name="login_nostrconnect_not_supported">वह एक क्रमक है जो आपके हस्ताक्षरकर्ता से संयोजन का अनुरोध कर रहा है। हस्ताक्षर प्रवेश करने की विधि नहीं है। पहले हस्ताक्षर प्रवेश करें। तत्पश्चात उसे निप॰छियालीस हस्ताक्षरण पटल से खोलें।</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को उद्धृत करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
<string name="login_with_a_private_key_to_be_able_to_follow">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। अनुचरण करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
<string name="login_with_a_private_key_to_be_able_to_hide_word">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। शब्द अथवा वाक्य छिपाने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
@@ -4217,6 +4423,7 @@
</plurals>
<string name="nip46_signer_remote_app">दूरस्थ हस्ताक्षरकर्ता क्रमक</string>
<string name="nip46_signer_title">दूरस्थ हस्ताक्षरकर्ता</string>
<string name="nip51_list_has_private_members">इस सूची में निजी सदस्य हैं जो केवल इसका कर्ता देख सकेगा</string>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d सम्पत्ति समावेशित</item>
<item quantity="other">%1$d सम्पत्ति समावेशित</item>
@@ -4753,12 +4960,21 @@
<string name="route_calendars">दिनदर्शिकाएँ</string>
<string name="route_chess">चतुरंग</string>
<string name="route_discover">आविष्करण</string>
<string name="route_edit_geocache">कोष सम्पादन</string>
<string name="route_edit_geocache_hunt">खोज सम्पादन</string>
<string name="route_geocache_detail">कोष</string>
<string name="route_geocache_hunt_detail">खोज</string>
<string name="route_geocache_hunts">कोष खोज</string>
<string name="route_geocaches">भूकोष</string>
<string name="route_home">घर</string>
<string name="route_import_follows">अनुचरित आयात करें</string>
<string name="route_log_geocache_find">एक खोज का अभिलेखन</string>
<string name="route_media">श्रव्यदृश्याभिलेख</string>
<string name="route_messages">संदेश</string>
<string name="route_music_playlists">संगीतसूचियाँ</string>
<string name="route_music_tracks">संगीत</string>
<string name="route_new_geocache">कोष को छिपाएँ</string>
<string name="route_new_geocache_hunt">नई खोज</string>
<string name="route_notifications">सूचनाएँ</string>
<string name="route_podcast_episodes">कडियाँ</string>
<string name="route_podcasts">पुटप्रसार</string>
@@ -4948,6 +5164,18 @@
<string name="vanish_request_sent">अदृश्यीकरण अनुरोध भेजा गया</string>
<string name="video_codec_h265_description">अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं।</string>
<string name="video_codec_h265_label">एच॰२६५ अथवा एचईवीसी॰ संकुचनविस्तारणविधि उपयोग करें</string>
<string name="video_collaboration_accepted_on">सहयोग श्रेय स्वीकृत</string>
<string name="video_collaboration_declined_on">सहयोग श्रेय अस्वीकृत</string>
<string name="video_credit_accepted">श्रेय स्वीकृत</string>
<string name="video_credit_audio_from">ध्वनि इन से</string>
<string name="video_credit_featuring">सहभागी</string>
<string name="video_credit_inspired_by">प्रेरित इनसे</string>
<string name="video_credit_references">उद्धरणसूची</string>
<string name="video_curation_set_private_items">निजी सूची</string>
<plurals name="video_curation_set_video_count">
<item quantity="one">%1$d चलचित्र</item>
<item quantity="other">%1$d चलचित्र</item>
</plurals>
<string name="video_download_has_started_toast">चलचित्र अवरोहण आरम्भ हुआ …</string>
<string name="video_player_settings">चलचित्र चालक घुण्डियाँ</string>
<string name="video_saved_to_the_gallery">चलचित्र को संचारयन्त्र के चलचित्रालय में सुरक्षित रखा गया</string>
@@ -5007,4 +5235,13 @@
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">ग्राहक की लैटनिंग सेवा %1$s पर उपलब्ध नहीं। इसका परिकलन किया गया "%2$s" लैटनिंग पता से। अपक्रम : %3$s। जाँच करें यदि सेवा संगणक चल रहा है तथा यदि लैटनिंग पता सम्यक है</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s से लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं।</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">लैटनिंग चालान नहीं बना पाए। %1$s से संदेश : %2$s</string>
<string name="marmot_invite_device_success">यह यन्त्र अब आपके मार्मोट आमन्त्रणों को प्राप्त करता है।</string>
<string name="marmot_invite_device_failure">कुंचिकापोटली प्रकाशन असफल। %1$s</string>
<string name="marmot_invite_device_rejected">किसी भी पुनःप्रसारक ने कुंचिकापोटली स्वीकारा नहीं। अपने पुनःप्रसारक स्थापना विकल्पों का जाँच करें तथा पुनःप्रयास करें।</string>
<string name="chat_delivery_sending">भेजा जा रहा है\u2026</string>
<string name="chat_delivery_failed">भेजने में असफल। पुनःप्रयास करने के लिए दबाएँ।</string>
<plurals name="marmot_unread_messages">
<item quantity="one">%1$d नया सन्देश</item>
<item quantity="other">%1$d नए सन्देश</item>
</plurals>
</resources>
@@ -166,6 +166,147 @@
<string name="road_event_traffic_jam">Forgalmi dugó</string>
<string name="road_event_unknown">Útesemény</string>
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
<string name="geocache_unnamed">Geoláda</string>
<string name="geocache_type_traditional">Hagyományos</string>
<string name="geocache_type_multi">Többpontos</string>
<string name="geocache_type_mystery">Rejtélyes</string>
<string name="geocache_size_micro">Mikro</string>
<string name="geocache_size_small">Kicsi</string>
<string name="geocache_size_regular">Normál</string>
<string name="geocache_size_large">Nagy</string>
<string name="geocache_size_other">Egyéb méret</string>
<string name="geocache_difficulty_short">Nehézség: %1$d</string>
<string name="geocache_terrain_short">Terep: %1$d</string>
<string name="geocache_first_to_find">Első megtaláló</string>
<string name="geocache_is_art">Műalkotás</string>
<string name="geocache_mission">Kulcsküldetés</string>
<string name="geocache_needs_verification">Ellenőrzött megtalálások</string>
<string name="geocache_claimed">Igényelve</string>
<string name="geocache_archived">Archiválva</string>
<string name="geocache_hint_tap_to_reveal">Eltévedt? Koppintson a tippek megjelenítéséhez</string>
<string name="geocache_hint_other_reading">Nem olvasható? Koppintson egy másik olvasatért</string>
<string name="geocache_nearby">Közeli</string>
<string name="geocache_found_it">Megtaláltam!</string>
<string name="geocache_verified_find">Ellenőrzött megtalálás</string>
<string name="geocache_invalid_proof">A bizonyíték nem érvényes</string>
<string name="geocache_loading">Láda betöltése…</string>
<string name="geocache_hidden_by">Elrejtette: %1$s</string>
<string name="geocache_navigate">Navigálás</string>
<string name="geocache_copy_coordinates">Koordináták másolása</string>
<string name="geocache_coordinates_copied">Koordináták másolva</string>
<string name="geocache_photos_section">Fényképek</string>
<string name="geocache_mission_section">Kulcsküldetés küldetés</string>
<string name="geocache_logs_section">Naplók</string>
<string name="geocache_no_logs">Még senki sem naplózta ezt a ládát. Legyen Ön az első.</string>
<string name="geocache_i_found_it">Megtaláltam</string>
<string name="geocache_didnt_find_it">Nem találtam meg</string>
<string name="geocache_add_note">Megjegyzés hozzáadása</string>
<string name="geocache_needs_maintenance">Karbantartás szükségességének jelentése</string>
<string name="geocache_add_to_hunt">Hozzáadás egy vadászathoz</string>
<string name="geocache_hunt_new">Új vadászat…</string>
<string name="geocache_hunt_none_yet">Még nem hozott létre vadászatot.</string>
<string name="geocache_hunt_already_on">Már szerepel ebben a vadászatban</string>
<string name="geocache_caches_here">%1$d láda van itt</string>
<string name="geocache_photo_remove">Fénykép eltávolítása</string>
<string name="geocache_archived_notice">Ezt a ládát archiválták. Már nem játszik.</string>
<string name="geocache_ftf_won_by">Az első megtalálást %1$s szerezte meg</string>
<string name="geocache_ftf_available">Az első megtalálás még szabad</string>
<string name="geocache_owner_actions">Tulajdonos</string>
<string name="geocache_owner_edit">Láda szerkesztése</string>
<string name="geocache_owner_archive">Láda archiválása</string>
<string name="geocache_owner_archive_confirm">Archiválás. A keresők látni fogják, hogy már nem játszik, de a lista olvasható marad.</string>
<string name="geocache_owner_lock_ftf">Első megtaláló rögzítése</string>
<string name="geocache_owner_lock_ftf_explain">Közzétesz egy F címkét %1$s névvel. Ez végleges.</string>
<string name="geocache_owner_no_finders">Még nincs ellenőrzött megtalálás, így nincs kit rögzíteni.</string>
<string name="geocache_owner_show_qr">Ellenőrző kód megjelenítése</string>
<string name="geocache_owner_qr_warning">Nyomtassa ki és tegye a ládába. Az Amethyst nem tárolja — ha elveszíti, a keresők többé nem tudják bizonyítani, hogy ott jártak.</string>
<string name="geocache_log_type_dnf">Nem találta meg</string>
<string name="geocache_log_type_note">Megjegyzés</string>
<string name="geocache_log_type_maintenance">Karbantartást igényel</string>
<string name="geocache_log_title">Megtalálás naplózása</string>
<string name="geocache_log_cache_label">Láda</string>
<string name="geocache_log_your_log">Saját napló</string>
<string name="geocache_log_placeholder">Hogy ment?</string>
<string name="geocache_log_add_photo">Fénykép hozzáadása</string>
<string name="geocache_log_mission_answer">Saját kulcsküldetés-válasz</string>
<string name="geocache_log_proof_section">Jelenlét bizonyítása</string>
<string name="geocache_log_scan_code">Olvassa be a ládában talált kódot</string>
<string name="geocache_log_scan_again">Másik kód beolvasása</string>
<string name="geocache_log_proof_verified">Ellenőrizve — Ön járt a ládánál</string>
<string name="geocache_log_proof_explain">Egyszer aláírva a dobozban lévő kóddal. A kulcs soha nem kerül mentésre, és soha nem hagyja el ezt a képernyőt.</string>
<string name="geocache_log_proof_bad_code">Ez nem egy ellenőrző kód.</string>
<string name="geocache_log_proof_wrong_cache">Ez a kód egy másik ládához tartozik.</string>
<string name="geocache_log_proof_optional">Ez a láda nem kér bizonyítékot, így nyugodtan naplózhatja.</string>
<string name="geocache_log_no_location_attached">Az Ön saját helyzete nincs hozzácsatolva ehhez a naplóhoz.</string>
<string name="geocache_log_post">Közzététel</string>
<string name="geocache_log_comment_title">Napló hozzáadása</string>
<string name="geocache_tab_nearby">Közeli</string>
<string name="geocache_tab_map">Térkép</string>
<string name="geocache_tab_hunts">Vadászatok</string>
<string name="geocache_tab_finds">Megtalálások</string>
<string name="geocache_tab_mine">Sajátjaim</string>
<string name="geocache_filter_unfound">Megtalálatlan</string>
<string name="geocache_filter_difficulty">Nehézség ≤ %1$d</string>
<string name="geocache_filter_terrain">Terep ≤ %1$d</string>
<string name="geocache_filter_has_prize">Van nyereménye</string>
<string name="geocache_filter_verified_only">Bizonyíték szükséges</string>
<string name="geocache_use_my_location">Saját helyzet használata</string>
<string name="geocache_sorted_by_distance">Rendezés távolság szerint</string>
<string name="geocache_sorted_by_recent">Legújabb elöl</string>
<string name="geocache_hide_a_cache">Láda elrejtése</string>
<string name="geocache_empty_nearby">Itt még nincsenek ládák. Próbáljon szélesebb követési listát, vagy rejtse el az elsőt.</string>
<string name="geocache_empty_finds">Még nem naplózott megtalálást.</string>
<string name="geocache_empty_mine">Még nem rejtett el ládát.</string>
<string name="geocache_empty_hunts">Még nincsenek vadászatok.</string>
<string name="geocache_stat_finds">Megtalálások</string>
<string name="geocache_stat_ftf">Első megtalálások</string>
<string name="geocache_stat_verified">Ellenőrzött</string>
<string name="geocache_stat_hidden">Elrejtett</string>
<string name="geocache_map_recenter">Középre</string>
<string name="geocache_map_long_press">Egy láda elrejtéséhez nyomja meg hossza a térképet</string>
<string name="geocache_map_open">Megnyitás</string>
<string name="geocache_map_pin_count">%1$d láda</string>
<string name="geocache_new_section_where">Hol</string>
<string name="geocache_new_section_what">Mi</string>
<string name="geocache_new_section_extras">Extrák</string>
<string name="geocache_new_section_proof">Bizonyíték</string>
<string name="geocache_new_section_relays">Átjátszók</string>
<string name="geocache_new_location_warning">Ez egy nyilvános helyzetet tesz közzé, körülbelül 5 méteres pontossággal. Bárki láthatja a Nostron, ez egy közzétett esemény nem vonható vissza..</string>
<string name="geocache_new_pick_location">Válassza ki a helyet</string>
<string name="geocache_new_ladder">Közzéteszi a(z) %1$s értéket, valamint durvább cellákat egészen 3 karakterig a közelségi kereséshez.</string>
<string name="geocache_new_name">Név</string>
<string name="geocache_new_description">Leírás</string>
<string name="geocache_new_type">Típus</string>
<string name="geocache_new_size">Méret</string>
<string name="geocache_new_difficulty">Nehézség</string>
<string name="geocache_new_terrain">Terep</string>
<string name="geocache_new_hint">Tipp</string>
<string name="geocache_new_hint_preview">Ezt látják majd a keresők</string>
<string name="geocache_new_hint_scramble">Tipp összekeverése (ROT13)</string>
<string name="geocache_new_mission">Kulcsküldetés küldetés</string>
<string name="geocache_new_modifiers">Módosítók</string>
<string name="geocache_new_require_proof">Jelenlét bizonyítékának megkövetelése</string>
<string name="geocache_new_proof_ready">Ehhez a ládához elő lett állítva egy ellenőrzőkulcs.</string>
<string name="geocache_new_log_relays">Hol naplózzanak a keresők</string>
<string name="geocache_new_publish">Közzététel</string>
<string name="geocache_new_needs_name">Adjon nevet a ládának.</string>
<string name="geocache_new_needs_location">Válassza ki, hogy hol van elrejtve a láda.</string>
<string name="geocache_hunt_progress">%2$d / %1$d megtalálva</string>
<string name="geocache_hunt_start">Vadászat indítása</string>
<string name="geocache_hunt_caches">%1$d láda</string>
<string name="geocache_hunt_title">Cím</string>
<string name="geocache_hunt_description">Leírás</string>
<string name="geocache_hunt_banner">Borítókép</string>
<string name="geocache_hunt_pick_caches">Ládák sorrendben</string>
<string name="geocache_hunt_add_cache">Láda hozzáadása</string>
<string name="geocache_hunt_move_up">Mozgatás fel</string>
<string name="geocache_hunt_move_down">Mozgatás le</string>
<string name="geocache_hunt_remove">Eltávolítás</string>
<string name="geocache_hunt_theme">Oldal témája</string>
<string name="geocache_hunt_map_style">Térkép stílusa</string>
<string name="geocache_hunt_needs_title">Adjon címet a vadászatnak.</string>
<string name="geocache_hunt_needs_caches">Adjon hozzá legalább egy ládát.</string>
<string name="geocache_publish_failed">Nem sikerült közzétenni. Ellenőrizze a kapcsolatot az átjátszóval, és próbálja újra.</string>
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Értéket az értékért</string>
@@ -214,6 +355,18 @@
<string name="qr_scanner_kind_web">Ez egy webhivatkozás.</string>
<string name="qr_scanner_kind_text">Ez egy egyszerű szöveg, nem Nostr-kód.</string>
<string name="qr_scanner_kind_nostr_unsupported">Ez egy Nostr-kód, de ez a képernyő nem tudja megnyitni.</string>
<string name="qr_scanner_label_profile">Profil</string>
<string name="qr_scanner_label_note">Megjegyzés</string>
<string name="qr_scanner_label_nostr">Nostr-kód</string>
<string name="qr_scanner_label_relay">Átjátszó</string>
<string name="qr_scanner_label_nsec">Privát kulcs</string>
<string name="qr_scanner_label_wallet">Pénztárca-kapcsolat</string>
<string name="qr_scanner_label_bunker">Távoli aláíró címe</string>
<string name="qr_scanner_label_signer">Aláírási kapcsolatkérés</string>
<string name="qr_scanner_label_lightning">Lightning számla</string>
<string name="qr_scanner_label_cashu">Cashu token</string>
<string name="qr_scanner_label_web">Webes hivatkozás</string>
<string name="qr_scanner_label_text">Egyszerű szöveg</string>
<string name="qr_scanner_open_link">Hivatkozás megnyitása</string>
<string name="qr_scanner_copy">Másolás</string>
<string name="qr_scanner_copied">Másolva</string>
@@ -676,17 +829,31 @@
<string name="workout_hours">Óra</string>
<string name="workout_minutes">Perc</string>
<string name="workout_seconds">Másodperc</string>
<string name="workout_suggestion_connect_title">Használja a már rögzített edzéseit</string>
<string name="workout_suggestion_connect_message">Kapcsolja össze a Health Connectet (Samsung Health, Google Fit, Fitbit, Garmin…), hogy felépítse a Saját Fitneszt, a saját edzésösszegzését, és hogy ezt az edzést egy már rögzített órai edzésből töltse ki ahelyett, hogy minden számot kézzel írna be.</string>
<string name="workout_suggestion_connect_button">Kapcsolódás</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">Health Connectből</string>
<string name="workout_suggestion_connect_details">Mihez fér hozzá az Amethyst</string>
<string name="health_connect_rationale_title">Health Connect és Amethyst</string>
<string name="health_connect_rationale_headline">Az Amethyst a Health Connectet használja a Saját Fitness felépítéséhez — az Ön saját edzésösszegzéséhez, az eszközén.</string>
<string name="health_connect_rationale_intro">A Saját fitnesz (menü → Ön → Saját fitnesz megmutatja, hogyan edzett: mennyit tett ezen a héten a múlt héthez képest, hogyan oszlik meg az ideje futás, kerékpározás, gyaloglás, úszás és edzőterem között, a legjobb erőfeszítéseit, hány napot edzett, és a jelenlegi sorozatát. Az Amethyst ezt a képet az órája vagy fitneszalkalmazása által a Health Connectbe már mentett edzésekből, valamint az Amethystben naplózott edzésekből építi fel. Az eszközén számítódik ki, és csak Önnek jelenik meg.</string>
<string name="health_connect_rationale_what_title">Mihez fér hozzá az Amethyst, és miért</string>
<string name="health_connect_rationale_exercise">Edzés · milyen tevékenységet végzett, mikor kezdte és mennyi ideig tartott. Ez maga az edzés: ez hajtja az edzésszámot, az edzésidőt, a tevékenységenkénti bontást, az aktív napokat és a sorozatot.</string>
<string name="health_connect_rationale_distance">Távolság · milyen messzire ment, megjelenítve az e heti és a múlt heti távolságát, a heti átlagát, a tevékenységenkénti távolságot és a leghosszabb távolságát.</string>
<string name="health_connect_rationale_calories">Aktív és teljes kalória · az edzés által elégetett energia, megjelenítve az e heti energiáját és annak összehasonlítását a múlt héttel. Az aktív kalóriákat akkor használja, ha a forrás rögzíti azokat; a teljes kalória azoknak a forrásoknak a tartaléka, amelyek csak a teljes energiát rögzítik.</string>
<string name="health_connect_rationale_heart_rate">Pulzusszám · az átlag és a maximum minden edzésen, az időszakra vonatkozó átlagos és legmagasabb pulzusszámba kombinálva. Ez alapján különbözteti meg a könnyű hetet a kemény héttől azonos távolság mellett.</string>
<string name="health_connect_rationale_steps">Lépések · egy futás, séta vagy túra lépésszáma, megjelenítve a heti lépésátlagát és a legmagasabb lépésszámát.</string>
<string name="health_connect_rationale_elevation">Emelkedés · mennyit mászott, megjelenítve a heti mászási átlagát és a legnagyobb mászását. Ez különbözteti meg a sík hetet a dombos héttől.</string>
<string name="health_connect_rationale_limits_title">Mit nem csinál az Amethyst</string>
<string name="health_connect_rationale_limit_window">Csak az elmúlt 4 hét edzéseit olvassa, és csak akkor, amikor a „Saját fitnesz” vagy az edzésszerkesztő nyitva van. Soha nem olvas a háttérben.</string>
<string name="health_connect_rationale_limit_write">Soha nem ír semmit a Health Connectbe, és soha nem kéri le az edzési útvonalat, a tartózkodási helyet vagy bármilyen egyéb egészségügyi adatot.</string>
<string name="health_connect_rationale_limit_publish">Soha nem küldi el az egészségügyi adatait sehová. A „Saját fitnesz” a telefonján számítódik ki, és ott is marad. Ha később közzé szeretne tenni egy edzést, koppintson a „Megosztás” gombra azon az edzésen, olvassa el az Amethyst által kitöltött bejegyzést, és tegye közzé saját maga (egyszerre csak egy edzést, és soha nem automatikusan).</string>
<string name="health_connect_rationale_limit_optional">Az egész funkció nem kötelező. Bármikor visszavonhatja az engedélyeket a Health Connectben — a Saját fitnesz visszaáll a beállítására —, vagy kikapcsolhatja a szerkesztő javaslatait a Beállítások → Szerkesztési beállítások alatt. Az Amethyst többi része tovább működik.</string>
<string name="health_connect_rationale_privacy_policy">Olvassa el a teljes adatvédelmi nyilatkozatot</string>
<string name="my_fitness_title">Fitnesz</string>
<string name="my_fitness_window">Elmúlt 4 hét</string>
<string name="my_fitness_loading_metrics">Távolság, kalória és pulzusszám betöltése…</string>
<string name="my_fitness_window_note">Az edzés utolsó négy teljes hete: a közzétett edzések, valamint minden egyéb adat, amelyet a Health Connect rögzít ezen az eszközön. Az Amethyst az eszközön állítja össze az összefoglalót, és abból semmit sem tesz közzé.</string>
<string name="my_fitness_this_week">Ezen a héten</string>
<string name="my_fitness_vs_last_week">elmúlt héten</string>
@@ -1161,6 +1328,8 @@
<string name="poll_closing_date_time">Szavazás lezárásának dátuma és ideje</string>
<string name="poll_closing_in">A szavazás lezárul %1$s múlva</string>
<string name="looking_for_event">%1$s esemény keresése</string>
<string name="looking_for_event_title">Betöltés</string>
<string name="looking_for_private_event">Várakozás a privát üzenetek betöltésére…</string>
<string name="send_zap">Zap küldése</string>
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
<string name="custom_zaps_add_a_message_private">Privát üzenet hozzáadása</string>
@@ -1436,6 +1605,14 @@
<string name="user_preferences">Felhasználói beállítások</string>
<string name="settings_search_placeholder">Keresési beállítások</string>
<string name="settings_search_no_results">Nincs találat a beállításokban erre: „%1$s”</string>
<string name="marmot_preview_group_updated">Csoport frissítve</string>
<string name="marmot_preview_media">Melléklet</string>
<string name="marmot_preview_no_text">Üzenet</string>
<string name="marmot_preview_with_sender">%1$s: %2$s</string>
<string name="marmot_empty_create_action">Csoport létrehozása</string>
<string name="marmot_invite_device_banner">A Marmot meghívók egy másik eszközre mennek, és itt nem nyithatók meg.</string>
<string name="marmot_invite_device_banner_dismiss">Eltüntetés</string>
<string name="marmot_invite_device_publish">Közzététel erről az eszközről</string>
<string name="reset_marmot_confirm_title">Visszaállítja a Marmot-állapotot?</string>
<string name="reset_marmot_confirm_body">Ez a művelet véglegesen töröl minden Marmot csoportos csevegést, üzenetelőzményt és MLS-kulcsot ezen az eszközön a jelenlegi fiókhoz. A partnerek nem kapnak erről értesítést, és a következő véglegesítésükig továbbra is láthatják Önt a csoportokban. Ez a művelet nem vonható vissza. Egy új kulcscsomag lesz közzétéve az alkalmazás következő szinkronizálásakor.</string>
<string name="reset_marmot_confirm_action">Visszaállítás</string>
@@ -2025,6 +2202,8 @@
<string name="video_player_settings_action_pip_description">Videó lejátszása lebegő ablakban (rejtett, ha a rendszer nem támogatja)</string>
<string name="video_player_settings_action_cast">Közvetítés eszközre</string>
<string name="video_player_settings_action_cast_description">Videó küldése a Wi-Fi hálózaton található Chromecast eszközre (helyi fájloknál nem érhető el)</string>
<string name="video_player_settings_action_captions">Feliratok</string>
<string name="video_player_settings_action_captions_description">Feliratok be- vagy kikapcsolása (rejtve, ha a videónak nincs)</string>
<string name="audio_visualizer_settings_description">Válassza ki a hangjegyzetek lejátszása közben megjelenő animációt.</string>
<string name="audio_visualizer_off">Ki</string>
<string name="audio_visualizer_bars">Spektrumsávok</string>
@@ -3003,13 +3182,18 @@
<string name="app_notification_replies_channel_description">Értesítés, amikor valaki válaszol az egyik bejegyzésre</string>
<string name="app_notification_replies_channel_name">Válaszok</string>
<string name="app_notification_replies_summary">Új válaszok</string>
<string name="app_notification_reply_failed">Nincs elküldve</string>
<string name="app_notification_reply_label">Válasz</string>
<string name="app_notification_reply_retry">Újra</string>
<string name="app_notification_reply_sending">Küldés…</string>
<string name="app_notification_reply_unconfirmed">Küldés…</string>
<string name="app_notification_reposts_channel_description">Értesítés ha valaki megosztja az Ön bejegyzését</string>
<string name="app_notification_reposts_channel_message">%1$s megosztotta az Ön bejegyzését</string>
<string name="app_notification_reposts_channel_name">Továbbosztások</string>
<string name="app_notification_reposts_summary">Új továbbosztások</string>
<string name="app_notification_scheduled_posts_channel_description">Értesítések, amikor egy ütemezett bejegyzés megjelenik vagy nem sikerül közzététenni.</string>
<string name="app_notification_scheduled_posts_channel_name">Ütemezett bejegyzések</string>
<string name="app_notification_shortcut_account_removed">Ezt a fiókot eltávolították az Amethystből.</string>
<string name="app_notification_zaps_channel_description">Értesítés, amikor valaki zapet küld Önnek</string>
<string name="app_notification_zaps_channel_message">%1$s satoshi</string>
<string name="app_notification_zaps_channel_message_for">neki: %1$s</string>
@@ -3210,6 +3394,10 @@
<string name="call_unmute">Némítás megszüntetése</string>
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nem található a visszahívási webcím a(z) %1$s válaszából</string>
<string name="cancel">Mégse</string>
<string name="captions_off">Ki</string>
<string name="captions_turn_off">Feliratok kikapcsolása</string>
<string name="captions_turn_on">Feliratok bekapcsolása</string>
<string name="captions_unnamed_track">Feliratok</string>
<string name="cashu">Cashu token</string>
<string name="cashu_checking_mint">Pénzverde ellenőrzése…</string>
<string name="cashu_failed_redemption">Nem sikerült a Cashut beváltani</string>
@@ -3695,10 +3883,12 @@
<string name="kind_adventure_prologue">Kalandprológus</string>
<string name="kind_adventure_reading">Kalandos olvasmány</string>
<string name="kind_adventure_scene">Kalandjelenet</string>
<string name="kind_app_curation_set">Alkalmazáslista</string>
<string name="kind_app_recommendations">Ajánlott alkalmazások </string>
<string name="kind_appointment">Találkozó</string>
<string name="kind_apps">Alkalmazások</string>
<string name="kind_appt_rsvp">Időpont-visszaigazolás</string>
<string name="kind_article_curation_set">Cikklista</string>
<string name="kind_audio_header">Audio-fejléc</string>
<string name="kind_audio_track">Hangsáv</string>
<string name="kind_authored_podcasts">Saját készítésű hangműsorok</string>
@@ -3711,6 +3901,7 @@
<string name="kind_blossom_auth">Blossom-hitelesítés</string>
<string name="kind_blossom_servers">Blossom kiszolgálók</string>
<string name="kind_bookmark_list">Könyvjelzőlista</string>
<string name="kind_bookmark_set">Könyvjelzőkészlet</string>
<string name="kind_broadcast_relays">Közvetítési átjátszók</string>
<string name="kind_calendar">Naptár</string>
<string name="kind_channel_definition">Csatorna alapadatai</string>
@@ -3748,6 +3939,7 @@
<string name="kind_emoji_packs">Emodzsicsomagok</string>
<string name="kind_ephemeral_chat">Eltűnő csevegés</string>
<string name="kind_ephemeral_chatrooms">Eltűnő csevegőszobák</string>
<string name="kind_favorite_algo_feeds">Kedvenc hírfolyamok</string>
<string name="kind_favorite_podcasts">Kedvenc hangműsorok</string>
<string name="kind_favorite_relays">Kedvenc átjátszók</string>
<string name="kind_file_headers">Fájl-fejléc</string>
@@ -3756,28 +3948,36 @@
<string name="kind_follow_packs">Követési csomagok</string>
<string name="kind_geohash_follows">Területi követések</string>
<string name="kind_gift_wraps">Ajándékcsomagolások</string>
<string name="kind_git_authors">Git-szerzők</string>
<string name="kind_git_issue">Git hibajegy</string>
<string name="kind_git_patch">Git javítófolt</string>
<string name="kind_git_pr">Beolvasztási kérés</string>
<string name="kind_git_pr_update">Beolvasztási kérés frissítése</string>
<string name="kind_git_reply">Git válasz</string>
<string name="kind_git_repo">Git tároló</string>
<string name="kind_git_repositories">Git-tárolók</string>
<string name="kind_git_status_applied">Git-állapot: Elfogadva</string>
<string name="kind_git_status_closed">Git-állapot: Lezárva</string>
<string name="kind_git_status_draft">Git-állapot: Piszkozat</string>
<string name="kind_git_status_open">Git-állapot: Nyitott</string>
<string name="kind_good_wiki_authors">Megbízható wiki-szerzők</string>
<string name="kind_good_wiki_relays">Megbízható wiki-átjátszók</string>
<string name="kind_hashtag_follows">Kulcsszókövetések</string>
<string name="kind_highlights">Kiemelések</string>
<string name="kind_http_auth">Http-hitelesítés</string>
<string name="kind_index_relay_list">Indexelő átjátszók listája</string>
<string name="kind_interest_set">Érdeklődési körök</string>
<string name="kind_live_chats">Élő csevegések</string>
<string name="kind_live_streams">Élő közvetítések</string>
<string name="kind_media_follows">Médiakövetések</string>
<string name="kind_media_starter_pack">Média-kezdőcsomag</string>
<string name="kind_medical_data">Egészségügyi adatok</string>
<string name="kind_meeting_room">Tárgyalószoba</string>
<string name="kind_meeting_space">Megbeszélési helyszín</string>
<string name="kind_music_playlist">Zenei lejátszási lista</string>
<string name="kind_music_track">Zeneszám</string>
<string name="kind_mute_list">Lista némítása</string>
<string name="kind_mute_set_for_kind">Némítva a(z) %1$s típusra</string>
<string name="kind_named_bookmarks">Címkézett könyvjelzők</string>
<string name="kind_nests_servers">Hangszoba-kiszolgálók</string>
<string name="kind_nip">NIP</string>
@@ -3792,6 +3992,7 @@
<string name="kind_outbox_relays">Kimenő átjátszók</string>
<string name="kind_pay_to">fizetés kedvezményezettje</string>
<string name="kind_people_lists">Személylisták</string>
<string name="kind_picture_curation_set">Képlista</string>
<string name="kind_pictures">Képek</string>
<string name="kind_pins">Rögzítettek</string>
<string name="kind_podcast_episode">Hangműsor-rész</string>
@@ -3818,6 +4019,7 @@
<string name="kind_search_relays">Keresési átjátszók</string>
<string name="kind_shorts">Rövidek</string>
<string name="kind_shorts_repl">Rövidek (válasz)</string>
<string name="kind_simple_groups">Csoportok</string>
<string name="kind_torrent_comments">Torrent-megjegyzések</string>
<string name="kind_torrents">torentek</string>
<string name="kind_trusted_providers">Megbízható szolgáltatók</string>
@@ -3826,7 +4028,10 @@
<string name="kind_user_settings">Felhasználói beállítások</string>
<string name="kind_user_status">Felhasználói állapot</string>
<string name="kind_video">Videó</string>
<string name="kind_video_collaboration">Videó-együttműködés</string>
<string name="kind_video_list">Videólista</string>
<string name="kind_video_repl">Videó (válasz)</string>
<string name="kind_video_subtitles">Videófeliratok</string>
<string name="kind_voice_msg">Hangüzenet</string>
<string name="kind_voice_reply">Hangos válasz</string>
<string name="kind_wake">Ébredés</string>
@@ -3863,6 +4068,8 @@
<string name="location_change_place">Helyszín módosítása a térképen</string>
<string name="location_channels">Helyszíncsatornák</string>
<string name="location_pick_on_map">Válasszon egy helyet a térképen</string>
<string name="login_bunker_not_supported">Az Amethyst Androidra nem tud bunker:// címmel bejelentkezni. Kiad ilyeneket, hogy más alkalmazások kérhessék tőle az aláírást, de ő maga nem tud ilyennel bejelentkezni. Használjon külső aláíró alkalmazást vagy az Amethyst Desktopot.</string>
<string name="login_nostrconnect_not_supported">Ez egy alkalmazás, amely az aláírójához szeretne csatlakozni, nem bejelentkezési mód. Először jelentkezzen be, majd nyissa meg a NIP-46 aláíró képernyőről.</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket</string>
<string name="login_with_a_private_key_to_be_able_to_hide_word">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat el tudjon rejteni</string>
@@ -4217,6 +4424,7 @@
</plurals>
<string name="nip46_signer_remote_app">Távoli aláíró alkalmazás</string>
<string name="nip46_signer_title">Távoli aláíró</string>
<string name="nip51_list_has_private_members">Ennek a listának vannak privát tagjai, amelyeket csak a szerzője láthat</string>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d összetevő egybecsomagolva</item>
<item quantity="other">%1$d asset egybecsomagolva</item>
@@ -4753,12 +4961,21 @@
<string name="route_calendars">Naptárak</string>
<string name="route_chess">Sakk</string>
<string name="route_discover">Felfedezés</string>
<string name="route_edit_geocache">Láda szerkesztése</string>
<string name="route_edit_geocache_hunt">Vadászat szerkesztése</string>
<string name="route_geocache_detail">Láda</string>
<string name="route_geocache_hunt_detail">Vadászat</string>
<string name="route_geocache_hunts">Ládavadászatok</string>
<string name="route_geocaches">Geoládák</string>
<string name="route_home">Főoldal</string>
<string name="route_import_follows">Követettek importálása</string>
<string name="route_log_geocache_find">Megtalálás naplózása</string>
<string name="route_media">Média</string>
<string name="route_messages">Üzenetek</string>
<string name="route_music_playlists">Lejátszási listák</string>
<string name="route_music_tracks">Zene</string>
<string name="route_new_geocache">Láda elrejtése</string>
<string name="route_new_geocache_hunt">Új vadászat</string>
<string name="route_notifications">Értesítések</string>
<string name="route_podcast_episodes">Részek</string>
<string name="route_podcasts">Hangműsor</string>
@@ -4948,6 +5165,18 @@
<string name="vanish_request_sent">Törlési kérés elküldve</string>
<string name="video_codec_h265_description">Jobb minőség kisebb fájlméret mellett, de nem minden eszköz támogatja a H.265 lejátszást.</string>
<string name="video_codec_h265_label">H.265/HEVC-kodek használata</string>
<string name="video_collaboration_accepted_on">Elfogadott egy együttműködési kreditet</string>
<string name="video_collaboration_declined_on">Elutasított egy együttműködési kreditet</string>
<string name="video_credit_accepted">Kredit elfogadva</string>
<string name="video_credit_audio_from">Hang innen:</string>
<string name="video_credit_featuring">Közreműködik</string>
<string name="video_credit_inspired_by">Ihlette:</string>
<string name="video_credit_references">Hivatkozások</string>
<string name="video_curation_set_private_items">Privát lista</string>
<plurals name="video_curation_set_video_count">
<item quantity="one">%1$d videó</item>
<item quantity="other">%1$d videó</item>
</plurals>
<string name="video_download_has_started_toast">A videó letöltése megkezdődött…</string>
<string name="video_player_settings">Videólejátszó gombjai</string>
<string name="video_saved_to_the_gallery">Videó mentve a videógalériába</string>
@@ -5007,4 +5236,13 @@
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">A kedvezményezett lightning-szolgáltatása ennél: %1$s nem érhető el. A(z) „%2$s” lightning-cím lett kiszámítva. Hiba: %3$s. Ellenőrizze, hogy a kiszolgáló működik-e, és hogy a lightning-cím helyes-e</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Nem sikerült Lightning-számlát létrehozni a következőből: %1$s: A „PR” elem nem található a kapott JSON-ban.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Nem lehetett lightning-számlát létrehozni. Üzenet a következőtől: %1$s: %2$s</string>
<string name="marmot_invite_device_success">Ez az eszköz mostantól fogadja a Marmot meghívóit.</string>
<string name="marmot_invite_device_failure">Nem sikerült közzétenni a KeyPackage-et: %1$s</string>
<string name="marmot_invite_device_rejected">Egyetlen átjátszó sem fogadta el a KeyPackage-et. Ellenőrizze az átjátszó beállításait, és próbálja újra.</string>
<string name="chat_delivery_sending">Küldés\u2026</string>
<string name="chat_delivery_failed">Nem sikerült elküldeni. Koppintson az újrapróbálkozáshoz.</string>
<plurals name="marmot_unread_messages">
<item quantity="one">%1$d új üzenet</item>
<item quantity="other">%1$d új üzenet</item>
</plurals>
</resources>
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
@@ -1033,6 +1034,7 @@ class DesktopLocalCache : ICacheProvider {
override fun justConsumeMyOwnEvent(event: Event): Boolean {
if (!justVerify(event)) return false
LocallySignedEvents.mark(event)
// For addressable/replaceable events, store in the addressable note cache
// so state holders (Nip65RelayListState, etc.) pick it up via their flows
if (event is AddressableEvent) {
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord02Community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
/** Changes to the joined Concord communities. The list is encrypted, so it can only be compared as a whole. */
@Immutable
class ConcordCommunityListDiff(
val communities: ContentChange,
) : EventDiff {
override fun removesData() = communities.isRemoval()
}
@@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.concord.cord02Community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -47,7 +50,13 @@ class ConcordCommunityListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<ConcordCommunityListDiff> {
override fun diffFrom(older: Event): ConcordCommunityListDiff? {
if (older !is ConcordCommunityListEvent || older.pubKey != pubKey) return null
return ConcordCommunityListDiff(ContentChange.between(older.content, content))
}
override fun isContentEncoded() = true
/** Decrypts this list's entries with [signer], or empty on failure / wrong key. */
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
/** Changes to the joined ephemeral chat rooms: public ones, and the private ones as a whole. */
@Immutable
class EphemeralChatListDiff(
val rooms: ListDiff<RoomId>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(rooms.hasRemovals()) || privateItems.isRemoval()
}
@@ -24,9 +24,12 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -43,7 +46,16 @@ class EphemeralChatListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<EphemeralChatListDiff> {
override fun diffFrom(older: Event): EphemeralChatListDiff? {
if (older !is EphemeralChatListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return EphemeralChatListDiff(
ListDiff.of(older.publicRooms(), publicRooms(), { it }),
privateItemsChangeFrom(older),
)
}
fun publicRooms() = tags.rooms()
fun publicRoomSet() = tags.roomSet()
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.nipA3
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
/** Changes to the NIP-A3 payment targets (the ways others can pay the user). */
@Immutable
class PaymentTargetsDiff(
val targets: ListDiff<PaymentTarget>,
) : EventDiff {
override fun removesData() = targets.hasRemovals()
}
@@ -22,7 +22,10 @@ package com.vitorpamplona.quartz.experimental.nipA3
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -33,7 +36,13 @@ class PaymentTargetsEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<PaymentTargetsDiff> {
override fun diffFrom(older: Event): PaymentTargetsDiff? {
if (older !is PaymentTargetsEvent || older.pubKey != pubKey) return null
return PaymentTargetsDiff(ListDiff.of(older.paymentTargets(), paymentTargets(), { it }))
}
fun paymentTargets(): List<PaymentTarget> = tags.mapNotNull { PaymentTargetTag.parse(it) }
companion object {
@@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -48,7 +53,13 @@ class KeyPackageRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is KeyPackageRelayListEvent || older.pubKey != pubKey) return null
return RelayListDiff(ListDiff.of(older.relays(), relays(), { it }), ContentChange.NONE)
}
/**
* Relays from this list, with local-network entries dropped.
*
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.diff
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* What changed between two versions of the same replaceable/addressable event. Each event
* kind defines its own diff class holding its own parsed objects (a follow list diffs
* `ContactTag`s, a nutzap info diffs `NutzapMintTag`s and its P2PK key…), built with the
* [ListDiff], [ValueChange] and [ContentChange] containers.
*/
interface EventDiff {
/** The newer version dropped something the older one had. */
fun removesData(): Boolean
}
/**
* An event that knows how to compare itself with an older version of itself.
*
* [diffFrom] returns null when [older] isn't a version of the same event: a different kind,
* author or, for addressables, d-tag.
*/
interface DiffableEvent<D : EventDiff> {
fun diffFrom(older: Event): D?
}
/** An item present in both versions whose details differ. */
@Immutable
class ItemChange<T>(
val before: T,
val after: T,
)
/**
* The difference between two lists of parsed items. Items are matched by an identity key;
* a matched pair whose details differ is a change, not a removal plus an addition.
*/
@Immutable
class ListDiff<T>(
val removed: List<T>,
val added: List<T>,
val changed: List<ItemChange<T>>,
) {
fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty()
fun hasRemovals() = removed.isNotEmpty()
companion object {
/**
* @param key the identity of an item inside the list.
* @param same whether two items with the same key carry the same details.
*/
fun <T, K> of(
older: List<T>,
newer: List<T>,
key: (T) -> K,
same: (T, T) -> Boolean = { a, b -> a == b },
): ListDiff<T> {
val olderByKey = LinkedHashMap<K, T>(older.size)
older.forEach { olderByKey.getOrPut(key(it)) { it } }
val newerByKey = LinkedHashMap<K, T>(newer.size)
newer.forEach { newerByKey.getOrPut(key(it)) { it } }
val removed = mutableListOf<T>()
val changed = mutableListOf<ItemChange<T>>()
olderByKey.forEach { (k, before) ->
val after = newerByKey[k]
if (after == null) {
removed.add(before)
} else if (!same(before, after)) {
changed.add(ItemChange(before, after))
}
}
val added = newerByKey.filterKeys { it !in olderByKey }.values.toList()
return ListDiff(removed, added, changed)
}
}
}
/** A single value that differs between two versions; null on a side means it wasn't set. */
@Immutable
class ValueChange<T>(
val before: T?,
val after: T?,
) {
fun isRemoval() = before != null && after == null
companion object {
/** Null when both sides are the same. */
fun <T> of(
before: T?,
after: T?,
same: (T, T) -> Boolean = { a, b -> a == b },
): ValueChange<T>? =
when {
before == null && after == null -> null
before != null && after != null && same(before, after) -> null
else -> ValueChange(before, after)
}
}
}
/**
* How content that can't be read without decrypting (NIP-44 private list items, encrypted
* wallets and settings) changed as a whole.
*/
enum class ContentChange {
NONE,
ADDED,
CHANGED,
CLEARED,
;
fun isRemoval() = this == CLEARED
/**
* Whether public items that disappeared alongside this change are really gone. When the
* private section only just appeared, they were most likely moved into it (made
* private), which loses nothing. A rewritten private section can't vouch for them: NIP-44
* re-encrypts on every save, so it looks changed even when it holds the same items.
*/
fun publicRemovalsAreLoss(hasPublicRemovals: Boolean) = hasPublicRemovals && this != ADDED
companion object {
fun between(
older: String,
newer: String,
): ContentChange =
when {
older == newer -> NONE
older.isBlank() -> ADDED
newer.isBlank() -> CLEARED
else -> CHANGED
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.metadata
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
/**
* Changes to a kind:0 profile, field by field as parsed by [UserMetadata]. A field that
* went blank counts as removed. JSON fields [UserMetadata] doesn't model are kept as raw
* `name → value` pairs in [otherFields], and NIP-39 identity claims are diffed as tags.
*/
@Immutable
class MetadataDiff(
val name: ValueChange<String>?,
val displayName: ValueChange<String>?,
val picture: ValueChange<String>?,
val banner: ValueChange<String>?,
val website: ValueChange<String>?,
val about: ValueChange<String>?,
val pronouns: ValueChange<String>?,
val nip05: ValueChange<String>?,
val lud06: ValueChange<String>?,
val lud16: ValueChange<String>?,
val clinkOffer: ValueChange<String>?,
val bot: ValueChange<Boolean>?,
val birthday: ValueChange<Birthday>?,
val otherFields: ListDiff<Pair<String, String>>,
val identityClaims: ListDiff<IdentityClaimTag>,
) : EventDiff {
private fun fieldChanges(): List<ValueChange<*>> = listOfNotNull(name, displayName, picture, banner, website, about, pronouns, nip05, lud06, lud16, clinkOffer, bot, birthday)
// Dropping the deprecated NIP-24 aliases (displayName, username) is a cleanup, not a loss:
// clients that follow NIP-24 remove them and keep display_name / name.
override fun removesData() =
fieldChanges().any { it.isRemoval() } ||
otherFields.removed.any { it.first !in DEPRECATED_ALIASES } ||
identityClaims.hasRemovals()
companion object {
/** JSON keys [UserMetadata] parses into typed fields above. */
val DEPRECATED_ALIASES = setOf("displayName", "username")
val MODELED_FIELDS = setOf("name", "display_name", "picture", "banner", "website", "about", "pronouns", "nip05", "lud06", "lud16", "clink_offer", "bot", "birthday")
}
}
@@ -22,10 +22,14 @@ package com.vitorpamplona.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.builder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag
import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag
import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag
@@ -66,7 +70,55 @@ class MetadataEvent(
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<MetadataDiff>,
SearchableEvent {
override fun diffFrom(older: Event): MetadataDiff? {
if (older !is MetadataEvent || older.pubKey != pubKey) return null
val before = older.contactMetaData() ?: UserMetadata()
val after = contactMetaData() ?: UserMetadata()
fun text(
b: String?,
a: String?,
) = ValueChange.of(b?.ifBlank { null }, a?.ifBlank { null })
return MetadataDiff(
name = text(before.name, after.name),
displayName = text(before.displayName, after.displayName),
picture = text(before.picture, after.picture),
banner = text(before.banner, after.banner),
website = text(before.website, after.website),
about = text(before.about, after.about),
pronouns = text(before.pronouns, after.pronouns),
nip05 = text(before.nip05, after.nip05),
lud06 = text(before.lud06, after.lud06),
lud16 = text(before.lud16, after.lud16),
clinkOffer = text(before.clinkOffer, after.clinkOffer),
// "bot": false is the default: dropping it loses nothing, so only `true` counts.
bot = ValueChange.of(before.bot?.takeIf { it }, after.bot?.takeIf { it }),
birthday =
ValueChange.of(before.birthday, after.birthday) { b, a ->
b.year == a.year && b.month == a.month && b.day == a.day
},
otherFields = ListDiff.of(older.unmodeledFields(), unmodeledFields(), { it.first }),
identityClaims =
ListDiff.of(
older.tags.mapNotNull(IdentityClaimTag::parse),
tags.mapNotNull(IdentityClaimTag::parse),
{ it.platformIdentity() },
{ b, a -> b.proof == a.proof },
),
)
}
/** Filled JSON fields [UserMetadata] has no typed property for, as raw pairs. */
private fun unmodeledFields(): List<Pair<String, String>> =
contactMetadataJson()?.mapNotNull { (key, value) ->
if (key in MetadataDiff.MODELED_FIELDS) return@mapNotNull null
val text = if (value is JsonPrimitive) value.content else value.toString()
if (text.isBlank() || text == "null") null else key to text
} ?: emptyList()
override fun isContentEncoded() = true
// Profile content is JSON, so we parse it and index only the
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip02FollowList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
/** Changes to the follow list: people followed and unfollowed, and new relay hints or petnames. The deprecated relay map in the content is ignored. */
@Immutable
class ContactListDiff(
val follows: ListDiff<ContactTag>,
) : EventDiff {
override fun removesData() = follows.hasRemovals()
}
@@ -23,8 +23,11 @@ package com.vitorpamplona.quartz.nip02FollowList
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -44,7 +47,15 @@ class ContactListEvent(
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<ContactListDiff>,
PubKeyHintProvider {
override fun diffFrom(older: Event): ContactListDiff? {
if (older !is ContactListEvent || older.pubKey != pubKey) return null
return ContactListDiff(
ListDiff.of(older.follows(), follows(), { it.pubKey }, { a, b -> a.relayUri == b.relayUri && a.petname == b.petname }),
)
}
override fun pubKeyHints() = tags.mapNotNull(ContactTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(ContactTag::parseKey)
@@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.nip17Dm.settings
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -39,7 +44,13 @@ class ChatMessageRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is ChatMessageRelayListEvent || older.pubKey != pubKey) return null
return RelayListDiff(ListDiff.of(older.relays(), relays(), { it }), ContentChange.NONE)
}
fun relays(): List<NormalizedRelayUrl> = tags.mapNotNull(RelayTag::parse)
/** Every relay in this list, local ones included. For reading back our OWN list. */
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip28PublicChat.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
/** Changes to the joined public chats: public channels as parsed tags, private ones as a whole. */
@Immutable
class ChannelListDiff(
val channels: ListDiff<ChannelTag>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(channels.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip28PublicChat.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -47,7 +50,16 @@ class ChannelListEvent(
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<ChannelListDiff>,
EventHintProvider {
override fun diffFrom(older: Event): ChannelListDiff? {
if (older !is ChannelListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return ChannelListDiff(
ListDiff.of(older.tags.channels(), tags.channels(), { it.eventId }, { a, b -> a.relay == b.relay }),
privateItemsChangeFrom(older),
)
}
override fun eventHints() = tags.mapNotNull(ChannelTag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ChannelTag::parseId)
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip37Drafts.privateOutbox
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
@@ -48,7 +52,13 @@ class PrivateOutboxRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is PrivateOutboxRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
}
fun publicRelays() = tags.mapNotNull(RelayTag::parse)
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.mapNotNull(RelayTag::parse)
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip50Search
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.searchRelays
@@ -48,7 +52,13 @@ class SearchRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is SearchRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
}
fun publicRelays() = tags.relays()
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
@@ -41,6 +42,12 @@ abstract class PrivateTagArrayEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
override fun isContentEncoded() = true
/**
* How the NIP-44 encrypted private items changed since [older]. They can't be compared
* item by item without decrypting, so only as a whole.
*/
fun privateItemsChangeFrom(older: PrivateTagArrayEvent) = ContentChange.between(older.content, content)
suspend fun decrypt(signer: NostrSigner): TagArray {
if (signer.pubKey != pubKey) throw SignerExceptions.UnauthorizedDecryptionException()
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
/** Changes to the favorite algorithmic feeds: public ones as parsed bookmarks, private ones as a whole. */
@Immutable
class FavoriteAlgoFeedsListDiff(
val feeds: ListDiff<AddressBookmark>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(feeds.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -42,7 +45,16 @@ class FavoriteAlgoFeedsListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<FavoriteAlgoFeedsListDiff> {
override fun diffFrom(older: Event): FavoriteAlgoFeedsListDiff? {
if (older !is FavoriteAlgoFeedsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return FavoriteAlgoFeedsListDiff(
ListDiff.of(older.publicFavoriteAlgoFeeds(), publicFavoriteAlgoFeeds(), { it.address }, { a, b -> a.relayHint == b.relayHint }),
privateItemsChangeFrom(older),
)
}
fun publicFavoriteAlgoFeeds(): List<AddressBookmark> = tags.mapNotNull(AddressBookmark::parse)
suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List<AddressBookmark>? = privateTags(signer)?.mapNotNull(AddressBookmark::parse)
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.geohashList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
/** Changes to the followed locations (geohashes): public ones, and the private ones as a whole. */
@Immutable
class GeohashListDiff(
val geohashes: ListDiff<String>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(geohashes.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.geohashList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -45,7 +48,16 @@ class GeohashListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<GeohashListDiff> {
override fun diffFrom(older: Event): GeohashListDiff? {
if (older !is GeohashListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return GeohashListDiff(
ListDiff.of(older.publicGeohashes(), publicGeohashes(), { it.lowercase() }),
privateItemsChangeFrom(older),
)
}
fun publicGeohashes() = tags.geohashList()
suspend fun decryptPrivateGeohashes(signer: NostrSigner) = privateTags(signer)?.geohashList()
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.hashtagList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
/** Changes to the followed hashtags: public ones, and the private ones as a whole. */
@Immutable
class HashtagListDiff(
val hashtags: ListDiff<String>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(hashtags.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.hashtagList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -45,7 +48,16 @@ class HashtagListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<HashtagListDiff> {
override fun diffFrom(older: Event): HashtagListDiff? {
if (older !is HashtagListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return HashtagListDiff(
ListDiff.of(older.publicHashtags(), publicHashtags(), { it.lowercase() }),
privateItemsChangeFrom(older),
)
}
fun publicHashtags() = tags.mapNotNull(HashtagTag::parse)
companion object {
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.muteList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
/** Changes to the mute list: public mutes (people, words, hashtags, threads) as parsed tags, and the encrypted private mutes as a whole. */
@Immutable
class MuteListDiff(
val publicMutes: ListDiff<MuteTag>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(publicMutes.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.muteList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -46,7 +49,16 @@ class MuteListEvent(
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<MuteListDiff>,
PubKeyHintProvider {
override fun diffFrom(older: Event): MuteListDiff? {
if (older !is MuteListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return MuteListDiff(
ListDiff.of(older.publicMutes(), publicMutes(), { it.toTagIdOnly().toList() }, { a, b -> a.toTagArray().contentEquals(b.toTagArray()) }),
privateItemsChangeFrom(older),
)
}
override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey)
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -48,7 +51,13 @@ class BlockedRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is BlockedRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
}
fun publicRelays() = tags.relays()
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -48,7 +51,13 @@ class IndexerRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is IndexerRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
}
fun publicRelays() = tags.relays()
suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -48,7 +51,13 @@ class RelayFeedsListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is RelayFeedsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
}
fun publicRelays() = tags.relays()
suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.relayLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/** Changes to a list of relays (DM inbox, search, indexer, blocked, trusted, private outbox…): the public relays, and the encrypted private ones as a whole. */
@Immutable
class RelayListDiff(
val relays: ListDiff<NormalizedRelayUrl>,
val privateRelays: ContentChange,
) : EventDiff {
override fun removesData() = privateRelays.publicRemovalsAreLoss(relays.hasRemovals()) || privateRelays.isRemoval()
}
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -48,7 +51,13 @@ class TrustedRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<RelayListDiff> {
override fun diffFrom(older: Event): RelayListDiff? {
if (older !is TrustedRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
}
fun publicRelays() = tags.relays()
suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.simpleGroupList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
/** Changes to the joined NIP-29 groups: public ones as parsed group tags (renames included), private ones as a whole. */
@Immutable
class SimpleGroupListDiff(
val groups: ListDiff<GroupTag>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(groups.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,8 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.simpleGroupList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
@@ -39,7 +43,16 @@ class SimpleGroupListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<SimpleGroupListDiff> {
override fun diffFrom(older: Event): SimpleGroupListDiff? {
if (older !is SimpleGroupListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return SimpleGroupListDiff(
ListDiff.of(older.publicGroups(), publicGroups(), { it.groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.url ?: it.relayUrl) }, { a, b -> a.name == b.name }),
privateItemsChangeFrom(older),
)
}
fun publicGroups() = tags.mapNotNull(GroupTag::parse)
suspend fun privateGroups(signer: NostrSigner) = privateTags(signer)?.mapNotNull(GroupTag::parse)
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.wallet
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
/** Changes to the NIP-60 wallet. Its mints and keys are encrypted, so it can only be compared as a whole. */
@Immutable
class CashuWalletDiff(
val wallet: ContentChange,
) : EventDiff {
override fun removesData() = wallet.isRemoval()
}
@@ -23,8 +23,11 @@ package com.vitorpamplona.quartz.nip60Cashu.wallet
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -47,7 +50,13 @@ class CashuWalletEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<CashuWalletDiff> {
override fun diffFrom(older: Event): CashuWalletDiff? {
if (older !is CashuWalletEvent || older.pubKey != pubKey) return null
return CashuWalletDiff(ContentChange.between(older.content, content))
}
/**
* Decrypts the content to get the wallet's private tags (mints and privkey).
*/
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip61Nutzaps.info
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
/** Changes to the NIP-61 nutzap receiving settings: accepted mints (and their units), relays, and the P2PK key nutzaps are locked to. */
@Immutable
class NutzapInfoDiff(
val mints: ListDiff<NutzapMintTag>,
val relays: ListDiff<NormalizedRelayUrl>,
val p2pkPubkey: ValueChange<HexKey>?,
) : EventDiff {
// A mint that stops accepting a unit (sat, usd…) loses that unit's nutzaps.
override fun removesData() =
mints.hasRemovals() ||
mints.changed.any { !it.after.units.containsAll(it.before.units) } ||
relays.hasRemovals() ||
p2pkPubkey?.isRemoval() == true
}
@@ -23,8 +23,12 @@ package com.vitorpamplona.quartz.nip61Nutzaps.info
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
@@ -38,7 +42,17 @@ class NutzapInfoEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<NutzapInfoDiff> {
override fun diffFrom(older: Event): NutzapInfoDiff? {
if (older !is NutzapInfoEvent || older.pubKey != pubKey) return null
return NutzapInfoDiff(
ListDiff.of(older.mints(), mints(), { it.mintUrl.trimEnd('/').lowercase() }, { a, b -> a.units.toSet() == b.units.toSet() }),
ListDiff.of(older.relays(), relays(), { it }),
ValueChange.of(older.p2pkPubkey(), p2pkPubkey()),
)
}
fun mints() = tags.mints()
fun relays() = tags.relays()
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip65RelayList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
/** Changes to the NIP-65 relay list: relays added, removed, or switched between read, write and both. */
@Immutable
class AdvertisedRelayListDiff(
val relays: ListDiff<AdvertisedRelayInfo>,
) : EventDiff {
// A relay that stops being read (inbox) or write (outbox) loses that role even though it
// is still listed: going from read+write to write-only drops the user's inbox there.
override fun removesData() =
relays.hasRemovals() ||
relays.changed.any { (it.before.type.isRead() && !it.after.type.isRead()) || (it.before.type.isWrite() && !it.after.type.isWrite()) }
}
@@ -23,11 +23,16 @@ package com.vitorpamplona.quartz.nip65RelayList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -38,7 +43,24 @@ class AdvertisedRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<AdvertisedRelayListDiff> {
override fun diffFrom(older: Event): AdvertisedRelayListDiff? {
if (older !is AdvertisedRelayListEvent || older.pubKey != pubKey) return null
return AdvertisedRelayListDiff(ListDiff.of(older.relaysByUrl(), relaysByUrl(), { it.relayUrl }, { a, b -> a.type == b.type }))
}
/** One entry per relay: a separate read tag and write tag for the same URL mean both. */
private fun relaysByUrl(): List<AdvertisedRelayInfo> {
val byUrl = LinkedHashMap<NormalizedRelayUrl, AdvertisedRelayType>()
tags.forEach { tag ->
val info = AdvertisedRelayInfo.parse(tag) ?: return@forEach
val current = byUrl[info.relayUrl]
byUrl[info.relayUrl] = if (current == null || current == info.type) info.type else AdvertisedRelayType.BOTH
}
return byUrl.map { (url, type) -> AdvertisedRelayInfo(url, type) }
}
fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse)
fun relaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseNorm)
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip72ModCommunities.follow
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag
/** Changes to the joined communities: public ones as parsed tags, private ones as a whole. */
@Immutable
class CommunityListDiff(
val communities: ListDiff<CommunityTag>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(communities.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip72ModCommunities.follow
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -45,7 +48,16 @@ class CommunityListEvent(
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<CommunityListDiff>,
AddressHintProvider {
override fun diffFrom(older: Event): CommunityListDiff? {
if (older !is CommunityListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return CommunityListDiff(
ListDiff.of(older.publicCommunities(), publicCommunities(), { it.address }, { a, b -> a.relayHint == b.relayHint }),
privateItemsChangeFrom(older),
)
}
override fun addressHints() = tags.mapNotNull(CommunityTag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(CommunityTag::parseAddressId)
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip78AppData
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
/** Changes to an app's NIP-78 data. It is usually encrypted, so it is compared as a whole. */
@Immutable
class AppSpecificDataDiff(
val data: ContentChange,
) : EventDiff {
override fun removesData() = data.isRemoval()
}
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip78AppData
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
@@ -36,7 +39,13 @@ class AppSpecificDataEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<AppSpecificDataDiff> {
override fun diffFrom(older: Event): AppSpecificDataDiff? {
if (older !is AppSpecificDataEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return AppSpecificDataDiff(ContentChange.between(older.content, content))
}
override fun isContentEncoded() = true
companion object {
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip85TrustedAssertions.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
/** Changes to the NIP-85 trusted service providers: who computes each service, and from which relay. */
@Immutable
class TrustProviderListDiff(
val providers: ListDiff<ServiceProviderTag>,
val privateItems: ContentChange,
) : EventDiff {
override fun removesData() = privateItems.publicRemovalsAreLoss(providers.hasRemovals()) || privateItems.isRemoval()
}
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip85TrustedAssertions.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -42,7 +45,16 @@ class TrustProviderListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<TrustProviderListDiff> {
override fun diffFrom(older: Event): TrustProviderListDiff? {
if (older !is TrustProviderListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return TrustProviderListDiff(
ListDiff.of(older.serviceProviders(), serviceProviders(), { it.service to it.pubkey }, { a, b -> a.relayUrl == b.relayUrl }),
privateItemsChangeFrom(older),
)
}
fun serviceProviders() = tags.serviceProviders()
companion object {

Some files were not shown because too many files have changed in this diff Show More