Merge pull request #4174 from vitorpamplona/claude/loving-wright-aqlrvd

Add backup conflict detection and resolution UI
This commit is contained in:
Vitor Pamplona
2026-09-23 11:03:17 -04:00
committed by GitHub
76 changed files with 7512 additions and 102 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.
@@ -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)
@@ -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() }
},
)
}
}
@@ -93,6 +93,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) }
@@ -829,6 +829,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()
@@ -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
@@ -1663,6 +1664,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.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
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.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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
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.amethyst.ui.theme.placeholderText
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.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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
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.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
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.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.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
@@ -73,6 +79,8 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
@@ -88,6 +96,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
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(
+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())
}
}
@@ -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))
}
}
@@ -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,
@@ -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() })
}
}
@@ -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 {
@@ -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.nipB1Bolt12Zaps.offer
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
/** Changes to the published BOLT12 offers. */
@Immutable
class Bolt12OfferListDiff(
val offers: ListDiff<String>,
) : EventDiff {
override fun removesData() = offers.hasRemovals()
}
@@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer
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.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -50,7 +53,13 @@ class Bolt12OfferListEvent(
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<Bolt12OfferListDiff> {
override fun diffFrom(older: Event): Bolt12OfferListDiff? {
if (older !is Bolt12OfferListEvent || older.pubKey != pubKey) return null
return Bolt12OfferListDiff(ListDiff.of(older.offers(), offers(), { it }))
}
/** All canonical raw BOLT12 offers (`lno1...`) this user publishes, in tag order. */
fun offers(): List<String> = tags.mapNotNull(OfferTag::parse)
@@ -0,0 +1,363 @@
/*
* 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 com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
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
class EventDiffTest {
private val signer = NostrSignerSync(KeyPair())
private inline fun <reified T : Event> sign(
kind: Int,
createdAt: Long,
tags: Array<Array<String>>,
content: String = "",
): T = assertIs<T>(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)
@Test
fun followListDiffsContactTags() {
val older = sign<ContactListEvent>(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob, "wss://a.com/", "bobby")))
val newer = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", bob, "wss://b.com/"), arrayOf("p", carol)), "")
val diff = assertNotNull(newer.diffFrom(older))
assertEquals(listOf(alice), diff.follows.removed.map { it.pubKey })
assertEquals(listOf(carol), diff.follows.added.map { it.pubKey })
val change = diff.follows.changed.single()
assertEquals("bobby", change.before.petname)
assertEquals("wss://b.com/", change.after.relayUri?.url)
assertTrue(diff.removesData())
}
@Test
fun followListOnlyAddingDoesNotRemove() {
val older = sign<ContactListEvent>(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}")
val newer = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob)), "")
val diff = assertNotNull(newer.diffFrom(older))
assertFalse(diff.removesData())
}
@Test
fun muteListDiffsMuteTagsAndPrivateItems() {
val older =
sign<MuteListEvent>(
MuteListEvent.KIND,
100,
arrayOf(arrayOf("p", alice), arrayOf("t", "spam"), arrayOf("word", "crypto"), arrayOf("e", bob)),
"encrypted-private-items",
)
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(arrayOf("word", "crypto")), "")
val diff = assertNotNull(newer.diffFrom(older))
val removed = diff.publicMutes.removed
assertEquals(3, removed.size)
assertEquals(alice, assertIs<UserTag>(removed[0]).pubKey)
assertEquals("spam", assertIs<HashtagTag>(removed[1]).hashtag)
assertEquals(bob, assertIs<EventTag>(removed[2]).eventId)
assertTrue(diff.publicMutes.added.none { it is WordTag })
assertEquals(ContentChange.CLEARED, diff.privateItems)
}
@Test
fun rewrittenPrivateItemsAreChangedNotRemoved() {
val older = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf(), "cipher-a")
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(), "cipher-b")
val diff = assertNotNull(newer.diffFrom(older))
assertEquals(ContentChange.CHANGED, diff.privateItems)
assertFalse(diff.removesData())
}
@Test
fun makingPublicItemsPrivateIsNotALoss() {
val older = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("word", "crypto")), "")
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(), "encrypted-now-holding-both")
val diff = assertNotNull(newer.diffFrom(older))
assertEquals(2, diff.publicMutes.removed.size)
assertEquals(ContentChange.ADDED, diff.privateItems)
assertFalse(diff.removesData())
}
@Test
fun droppingPublicItemsWhileRewritingPrivateOnesIsALoss() {
val older = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "cipher-a")
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(), "cipher-b")
assertTrue(assertNotNull(newer.diffFrom(older)).removesData())
}
@Test
fun nip65DiffsRelayTypes() {
val older =
sign<AdvertisedRelayListEvent>(
AdvertisedRelayListEvent.KIND,
100,
arrayOf(arrayOf("r", "wss://a.com/"), arrayOf("r", "wss://b.com/", "read"), arrayOf("r", "wss://c.com/")),
)
val newer =
sign<AdvertisedRelayListEvent>(
AdvertisedRelayListEvent.KIND,
200,
arrayOf(arrayOf("r", "wss://a.com/", "write"), arrayOf("r", "wss://b.com/", "read"), arrayOf("client", "x")),
)
val diff = assertNotNull(newer.diffFrom(older))
assertEquals(listOf("wss://c.com/"), diff.relays.removed.map { it.relayUrl.url })
val change = diff.relays.changed.single()
assertEquals(AdvertisedRelayType.BOTH, change.before.type)
assertEquals(AdvertisedRelayType.WRITE, change.after.type)
}
@Test
fun relayListsDiffNormalizedUrls() {
val older = sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 100, arrayOf(arrayOf("relay", "wss://spam.com/")))
val newer = sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 200, arrayOf())
val diff = assertNotNull(newer.diffFrom(older))
assertEquals(listOf(NormalizedRelayUrl("wss://spam.com/")), diff.relays.removed)
}
@Test
fun profileDiffsTypedFields() {
val older = sign<MetadataEvent>(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":"","twitter":"vp"}""")
val newer = sign<MetadataEvent>(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""")
val diff = assertNotNull(newer.diffFrom(older))
assertEquals("vitor", diff.name?.before)
assertEquals("vitor2", diff.name?.after)
assertNull(diff.about)
assertTrue(assertNotNull(diff.lud16).isRemoval())
assertEquals("https://b", diff.banner?.after)
assertNull(diff.banner?.before)
assertEquals(listOf("twitter" to "vp"), diff.otherFields.removed)
assertTrue(diff.removesData())
}
@Test
fun droppingTheDefaultBotFlagIsNotALoss() {
val older = sign<MetadataEvent>(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","bot":false}""")
val newer = sign<MetadataEvent>(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor"}""")
val diff = assertNotNull(newer.diffFrom(older))
assertNull(diff.bot)
assertFalse(diff.removesData())
}
@Test
fun groupsDiffGroupTagsIncludingRenames() {
val older = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("group", "abc", "wss://g.com", "Friends"), arrayOf("group", "xyz", "wss://g.com")))
val newer = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 200, arrayOf(arrayOf("group", "abc", "wss://g.com", "Family")))
val diff = assertNotNull(newer.diffFrom(older))
assertEquals(listOf("xyz"), diff.groups.removed.map { it.groupId })
assertEquals(
"Family",
diff.groups.changed
.single()
.after.name,
)
}
@Test
fun trustProvidersDiffServiceProviderTags() {
val older = sign<TrustProviderListEvent>(TrustProviderListEvent.KIND, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com")))
val newer = sign<TrustProviderListEvent>(TrustProviderListEvent.KIND, 200, arrayOf())
val removed = assertNotNull(newer.diffFrom(older)).providers.removed.single()
assertEquals("rank", removed.service.type)
assertEquals(alice, removed.pubkey)
}
@Test
fun nutzapInfoDiffsMintsRelaysAndKey() {
val older = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice)))
val newer = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com", "sat", "usd"), arrayOf("pubkey", bob)))
val diff = assertNotNull(newer.diffFrom(older))
assertEquals(
listOf("sat", "usd"),
diff.mints.changed
.single()
.after.units,
)
assertEquals(alice, diff.p2pkPubkey?.before)
assertEquals(bob, diff.p2pkPubkey?.after)
assertFalse(diff.removesData())
}
@Test
fun otherEventsAreNotComparable() {
val mutes = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf())
val follows = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf())
val someoneElse = assertIs<ContactListEvent>(NostrSignerSync(KeyPair()).sign<Event>(300, ContactListEvent.KIND, arrayOf(), ""))
assertNull(follows.diffFrom(mutes))
assertNull(someoneElse.diffFrom(follows))
}
// Bookkeeping tags other clients rewrite on every save: never user data.
private val olderBookkeeping = arrayOf(arrayOf("client", "Amethyst"), arrayOf("nonce", "123", "20"), arrayOf("expiration", "1000"))
private val newerBookkeeping = arrayOf(arrayOf("client", "OtherApp", "31990:abc:def"), arrayOf("nonce", "999", "16"), arrayOf("expiration", "5000"))
@Test
fun clientPowAndExpirationTagsAreIgnored() {
val follows = arrayOf(arrayOf("p", alice))
val contacts =
sign<ContactListEvent>(ContactListEvent.KIND, 200, follows + newerBookkeeping)
.diffFrom(sign<ContactListEvent>(ContactListEvent.KIND, 100, follows + olderBookkeeping))
assertTrue(assertNotNull(contacts).follows.isEmpty())
val mutes = arrayOf(arrayOf("word", "spam"))
val muteDiff =
assertNotNull(
sign<MuteListEvent>(MuteListEvent.KIND, 200, mutes + newerBookkeeping)
.diffFrom(sign<MuteListEvent>(MuteListEvent.KIND, 100, mutes + olderBookkeeping)),
)
assertTrue(muteDiff.publicMutes.isEmpty())
assertEquals(ContentChange.NONE, muteDiff.privateItems)
val relays = arrayOf(arrayOf("r", "wss://a.com/"))
val nip65 =
sign<AdvertisedRelayListEvent>(AdvertisedRelayListEvent.KIND, 200, relays + newerBookkeeping)
.diffFrom(sign<AdvertisedRelayListEvent>(AdvertisedRelayListEvent.KIND, 100, relays + olderBookkeeping))
assertTrue(assertNotNull(nip65).relays.isEmpty())
val blocked = arrayOf(arrayOf("relay", "wss://spam.com/"))
val blockedDiff =
sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 200, blocked + newerBookkeeping)
.diffFrom(sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 100, blocked + olderBookkeeping))
assertTrue(assertNotNull(blockedDiff).relays.isEmpty())
val mints = arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice))
val nutzap =
assertNotNull(
sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, mints + newerBookkeeping)
.diffFrom(sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, mints + olderBookkeeping)),
)
assertTrue(nutzap.mints.isEmpty() && nutzap.relays.isEmpty())
assertNull(nutzap.p2pkPubkey)
val profile = """{"name":"vitor"}"""
val metadata =
assertNotNull(
sign<MetadataEvent>(MetadataEvent.KIND, 200, newerBookkeeping, profile)
.diffFrom(sign<MetadataEvent>(MetadataEvent.KIND, 100, olderBookkeeping, profile)),
)
assertFalse(metadata.removesData())
assertTrue(metadata.otherFields.isEmpty() && metadata.identityClaims.isEmpty())
assertNull(metadata.name)
}
@Test
fun droppingOnlyBookkeepingTagsIsNotALoss() {
val older = sign<ContactListEvent>(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)) + olderBookkeeping)
val newer = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice)))
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
}
@Test
fun listDiffMatchesByKey() {
val diff = ListDiff.of(listOf(1 to "a", 2 to "b"), listOf(2 to "B", 3 to "c"), { it.first })
assertEquals(listOf(1 to "a"), diff.removed)
assertEquals(listOf(3 to "c"), diff.added)
assertEquals(2 to "B", diff.changed.single().after)
}
private fun nip65(
createdAt: Long,
vararg tags: Array<String>,
) = sign<AdvertisedRelayListEvent>(AdvertisedRelayListEvent.KIND, createdAt, arrayOf(*tags))
@Test
fun nip65RelayThatStopsBeingAnInboxIsALoss() {
val older = nip65(100, arrayOf("r", "wss://a.com/"))
val newer = nip65(200, arrayOf("r", "wss://a.com/", "write"))
assertTrue(assertNotNull(newer.diffFrom(older)).removesData())
}
@Test
fun nip65RelayThatGainsARoleIsNotALoss() {
val older = nip65(100, arrayOf("r", "wss://a.com/", "write"))
val newer = nip65(200, arrayOf("r", "wss://a.com/"))
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
}
@Test
fun nip65SplitReadAndWriteTagsMeanBoth() {
val older = nip65(100, arrayOf("r", "wss://a.com/", "read"), arrayOf("r", "wss://a.com/", "write"))
val newer = nip65(200, arrayOf("r", "wss://a.com/"))
val diff = assertNotNull(newer.diffFrom(older))
assertTrue(diff.relays.isEmpty())
assertFalse(diff.removesData())
}
@Test
fun nutzapMintThatDropsAUnitIsALoss() {
val older = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat", "usd")))
val newer = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com", "sat")))
assertTrue(assertNotNull(newer.diffFrom(older)).removesData())
}
@Test
fun nutzapMintUrlSpellingAndUnitOrderAreNotChanges() {
val older = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://Mint.com", "sat", "usd")))
val newer = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com/", "usd", "sat")))
val diff = assertNotNull(newer.diffFrom(older))
assertTrue(diff.mints.isEmpty())
assertFalse(diff.removesData())
}
@Test
fun groupRelaySpellingIsNotARemoval() {
val older = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("d", ""), arrayOf("group", "g1", "wss://groups.com")))
val newer = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 200, arrayOf(arrayOf("d", ""), arrayOf("group", "g1", "wss://groups.com/")))
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
}
@Test
fun geohashCaseIsNotARemoval() {
val older = sign<GeohashListEvent>(GeohashListEvent.KIND, 100, arrayOf(arrayOf("g", "9Q8")))
val newer = sign<GeohashListEvent>(GeohashListEvent.KIND, 200, arrayOf(arrayOf("g", "9q8")))
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
}
@Test
fun droppingDeprecatedProfileAliasesIsNotALoss() {
val older = sign<MetadataEvent>(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","display_name":"Vitor","displayName":"Vitor","username":"vitor"}""")
val newer = sign<MetadataEvent>(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor","display_name":"Vitor"}""")
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
}
}