diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md new file mode 100644 index 0000000000..b7cc7e9823 --- /dev/null +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -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` (quartz, `nip01Core/diff/`): `diffFrom(older)` returns its own diff + class built from its own parsed objects, e.g. `ContactListDiff(follows: + ListDiff)`, `MuteListDiff(publicMutes: ListDiff, privateItems)`, + `AdvertisedRelayListDiff(relays: ListDiff)`, `NutzapInfoDiff(mints: + ListDiff, relays, p2pkPubkey: ValueChange)`, `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` (items matched by an identity key; a matched pair with different details + is a change, not a removal plus an addition), `ValueChange`, 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index e654cc89b3..a76ebb2365 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index fa0a05f6a2..4d5e3c0fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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(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 signAndSendPrivately( template: EventTemplate, relayList: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index af1c120fdc..e2613fbe36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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>) = backupGuard.restore(saved) + + /** The open conflicts, as the three events storage needs to rebuild each one. */ + fun openBackupConflicts(): List> = 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) { 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt index f0cf782cca..298fff1dff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -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, + ) { + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt new file mode 100644 index 0000000000..b6ba88290e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt @@ -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, String>? = null + + fun encode(conflicts: List>): 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>): 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> { + if (stored.isNullOrBlank()) return emptyList() + val rows = + try { + JsonMapper.fromJson>>(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 + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt index 14d3f6e18f..b402ab56bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -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(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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index 1bab5f6660..4543f53330 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -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, + inbox: Set, + defaults: Set, +): Set = + 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 = + pickAccountDataRelays( + home = relayFlow(query).value, + inbox = query.account.nip65RelayList.inboxFlow.value, + defaults = Constants.eventFinderRelays, + ) + override fun updateFilter( keys: List, since: SincePerRelayMap?, ): List { val accountsPerRelay = mutableMapOf>() 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() } + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0311505eae..8189648607 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { RelayFeedScreen(it, accountViewModel, nav) } composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { BackupConflictReviewScreen(it.slot, accountViewModel, nav) } composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9bd0ebeed4..790b3ea745 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 12ac6367b0..eabefc9ef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt new file mode 100644 index 0000000000..f9d9248e6e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -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, + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt new file mode 100644 index 0000000000..39b5408657 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt @@ -0,0 +1,1183 @@ +/* + * 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.offset +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.OutlinedTextField +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.layout.ContentScale +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.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +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.NoteCompose +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel +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.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +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.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType + +private const val GRID_COLUMNS = 4 +private const val PEOPLE_PREVIEW = 6 + +/** UI state of the review screen that must survive the lazy list recomposing. */ +@Stable +class ReviewUiState { + var tab by mutableIntStateOf(0) + var query by mutableStateOf("") + var showAllPeople by mutableStateOf(false) +} + +/** + * The review list of a conflict, laid out for the kind of event that changed. Each layout + * leads with one picture of the change (a shrink bar, a shield, two profiles side by side, + * relay lanes, a key swap, group tiles) and lists the details below. All layouts contribute + * lazy items, so lists with hundreds of entries scroll smoothly. + */ +internal fun LazyListScope.eventDiffItems( + conflict: ReplaceableBackupConflict, + presentation: DiffPresentation, + ui: ReviewUiState, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (val diff = conflict.diff) { + is ContactListDiff -> followListItems(diff, conflict, ui, accountViewModel, nav) + is MuteListDiff -> muteListItems(diff, ui, accountViewModel, nav) + is MetadataDiff -> profileItems(diff, conflict, presentation, accountViewModel, nav) + is AdvertisedRelayListDiff -> nip65Items(conflict, nav) + is NutzapInfoDiff -> nutzapItems(diff, conflict, presentation, accountViewModel, nav) + is SimpleGroupListDiff -> groupItems(diff, conflict, accountViewModel, nav) + else -> if (!listDiffItems(conflict, accountViewModel, nav)) genericDiffItems(buildRows(presentation), accountViewModel, nav) + } +} + +private val Pad = Modifier.padding(horizontal = 16.dp) + +// --------------------------------------------------------------------------------------- +// Follow list: 523 → 120, a split bar, tabs and a searchable grid of faces. +// --------------------------------------------------------------------------------------- + +private fun LazyListScope.followListItems( + diff: ContactListDiff, + conflict: ReplaceableBackupConflict, + ui: ReviewUiState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val (saved, new) = conflict.followCounts + val dropped = diff.follows.removed.size + val gained = diff.follows.added.size + val edited = diff.follows.changed.size + + item(key = "follow-hero", contentType = "hero") { + val tones = conflictTones() + Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + HeroCounts(stringRes(R.string.backup_review_saved), saved, stringRes(R.string.backup_review_new), new) + SplitBar( + listOf( + BarSegment(saved - dropped, tones.kept), + BarSegment(dropped, tones.removed), + BarSegment(gained, tones.added), + ), + ) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + LegendItem(tones.kept, stringRes(R.string.backup_review_kept_count, (saved - dropped).toString())) + LegendItem(tones.removed, stringRes(R.string.backup_review_dropped_count, dropped.toString())) + LegendItem(tones.added, stringRes(R.string.backup_review_new_count, gained.toString())) + } + } + } + + item(key = "follow-tabs", contentType = "tabs") { + ReviewTabs( + labels = + listOf( + stringRes(R.string.backup_review_tab_dropped, dropped.toString()), + stringRes(R.string.backup_review_tab_new, gained.toString()), + stringRes(R.string.backup_review_tab_edited, edited.toString()), + ), + selected = ui.tab, + onSelect = { + // The search box only shows on long tabs; a query typed there must not keep + // filtering a short tab that has no box to clear it from. + ui.tab = it + ui.query = "" + }, + modifier = Pad.padding(top = 16.dp), + ) + } + + val (people, badge) = + when (ui.tab) { + 1 -> diff.follows.added.map { it.pubKey } to Badge.ADDED + 2 -> diff.follows.changed.map { it.after.pubKey } to Badge.CHANGED + else -> diff.follows.removed.map { it.pubKey } to Badge.REMOVED + } + + if (people.size > GRID_COLUMNS * 2) { + item(key = "follow-search", contentType = "search") { + PeopleSearch(ui.query, people.size, { ui.query = it }, Pad.padding(top = 12.dp)) + } + } + + val query = ui.query.trim() + val filtered = if (query.isEmpty()) people else people.filter { matchesPerson(it, query) } + + peopleGrid("follow-${ui.tab}", filtered, badge, accountViewModel, nav) + + if (filtered.isEmpty()) { + item(key = "follow-empty", contentType = "note") { + DetailText(stringRes(R.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp)) + } + } +} + +private fun matchesPerson( + pubKey: HexKey, + query: String, +): Boolean { + val user = LocalCache.getUserIfExists(pubKey) + val name = user?.toBestDisplayName() ?: pubKey + return name.contains(query, ignoreCase = true) || pubKey.startsWith(query, ignoreCase = true) +} + +private enum class Badge { REMOVED, ADDED, CHANGED } + +/** Faces in rows of [GRID_COLUMNS], each row a lazy item. */ +private fun LazyListScope.peopleGrid( + keyPrefix: String, + people: List, + badge: Badge, + accountViewModel: AccountViewModel, + nav: INav, +) { + items(people.chunked(GRID_COLUMNS), key = { keyPrefix + it.first() }, contentType = { "people-row" }) { row -> + Row(Pad.fillMaxWidth().padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { PersonCell(it, badge, accountViewModel, nav, Modifier.weight(1f)) } + repeat(GRID_COLUMNS - row.size) { Spacer(Modifier.weight(1f)) } + } + } +} + +@Composable +private fun PersonCell( + pubKey: HexKey, + badge: Badge, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier, +) { + val tones = conflictTones() + val (sign, color) = + when (badge) { + Badge.REMOVED -> "−" to tones.removed + Badge.ADDED -> "+" to tones.added + Badge.CHANGED -> "~" to tones.changed + } + LoadUser(pubKey, accountViewModel) { user -> + Column( + modifier.clip(RoundedCornerShape(12.dp)).clickable { nav.nav(Route.Profile(pubKey)) }.padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box { + if (user != null) { + UserPicture(user, 56.dp, accountViewModel = accountViewModel, nav = nav) + } else { + Box(Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHighest)) + } + Box( + Modifier + .align(Alignment.BottomEnd) + .offset(x = 2.dp, y = 2.dp) + .size(22.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .padding(2.dp) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center, + ) { + Text(sign, color = MaterialTheme.colorScheme.background, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp, lineHeight = 14.sp) + } + } + ProvideTextStyle(MaterialTheme.typography.labelMedium) { + if (user != null) { + UsernameDisplay(user, fontWeight = FontWeight.SemiBold, accountViewModel = accountViewModel) + } else { + Text(pubKey.toShortDisplay(), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun ReviewTabs( + labels: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + labels.forEachIndexed { index, label -> + val isSelected = index == selected + Box( + Modifier + .weight(1f) + .height(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.surfaceContainerHighest else Color.Transparent) + .clickable { onSelect(index) }, + contentAlignment = Alignment.Center, + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText, + ) + } + } + } +} + +@Composable +private fun PeopleSearch( + query: String, + count: Int, + onChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = query, + onValueChange = onChange, + modifier = modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null) }, + placeholder = { Text(stringRes(R.string.backup_review_search_people, count.toString())) }, + ) +} + +// --------------------------------------------------------------------------------------- +// Mute list: a shield, per-kind tiles, faces, struck-through words, the private section. +// --------------------------------------------------------------------------------------- + +@OptIn(ExperimentalLayoutApi::class) +private fun LazyListScope.muteListItems( + diff: MuteListDiff, + ui: ReviewUiState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val removed = diff.publicMutes.removed + val added = diff.publicMutes.added + val people = removed.filterIsInstance().map { it.pubKey } + val words = removed.filterIsInstance().map { it.word } + val hashtags = removed.filterIsInstance().map { "#" + it.hashtag } + val threads = removed.filterIsInstance().map { it.eventId } + + item(key = "mute-hero", contentType = "hero") { + val tones = conflictTones() + TintedPanel(tones.removedContainer(), Pad.padding(top = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(symbol = MaterialSymbols.Shield, contentDescription = null, tint = tones.removed, modifier = Modifier.size(44.dp)) + Column { + Text( + stringRes(R.string.backup_review_unmuted, removed.size.toString()), + fontSize = 30.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold, + color = tones.removed, + ) + Text(stringRes(R.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + if (added.isNotEmpty()) { + Text(stringRes(R.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added) + } + } + } + } + } + + item(key = "mute-tiles", contentType = "tiles") { + val tones = conflictTones() + Row(Pad.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatTile("−${people.size}", stringRes(R.string.backup_entry_person), tones.removed, Modifier.weight(1f)) + StatTile("−${words.size}", stringRes(R.string.backup_entry_word), tones.removed, Modifier.weight(1f)) + StatTile("−${hashtags.size}", stringRes(R.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f)) + StatTile("−${threads.size}", stringRes(R.string.backup_entry_thread), tones.removed, Modifier.weight(1f)) + } + } + + if (people.isNotEmpty()) { + item(key = "mute-people", contentType = "facepile") { + Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionCaption(stringRes(R.string.backup_entry_person)) + FacePile(people, accountViewModel) { ui.showAllPeople = !ui.showAllPeople } + } + } + if (ui.showAllPeople) peopleGrid("mute-people-", people, Badge.REMOVED, accountViewModel, nav) + } + + if (words.isNotEmpty() || hashtags.isNotEmpty()) { + item(key = "mute-words", contentType = "pills") { + val tones = conflictTones() + Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionCaption(stringRes(R.string.backup_review_words_and_hashtags)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + (words.map { "\"$it\"" } + hashtags).forEach { StruckPill(it, tones.removed) } + } + } + } + } + + if (threads.isNotEmpty()) { + item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_thread), Pad.padding(top = 18.dp)) } + items(threads, key = { "mute-thread-$it" }, contentType = { "note" }) { id -> + LoadNote(id, accountViewModel) { note -> + if (note != null) { + NoteCompose(note, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav) + } else { + TextEntry(id.toShortDisplay(), null) + } + } + } + } + + if (diff.privateItems != ContentChange.NONE) { + item(key = "mute-private", contentType = "private") { PrivateItemsCard(diff.privateItems, Pad.padding(top = 18.dp)) } + } +} + +@Composable +private fun FacePile( + people: List, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + // Names and faces are observed, so people this device hasn't loaded yet resolve from relays. + val names = people.take(2).map { observedName(it, accountViewModel) } + val summary = + if (people.size > 2) { + stringRes(R.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString()) + } else { + names.joinToString(", ") + } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.height(48.dp)) { + people.take(PEOPLE_PREVIEW).forEachIndexed { index, pubKey -> + Box( + Modifier + .padding(start = (index * 32).dp) + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .padding(3.dp), + ) { + RobohashFallbackAsyncImage( + robot = pubKey, + model = observedPicture(pubKey, accountViewModel), + contentDescription = null, + modifier = Modifier.size(42.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + } + } + } + Text( + summary, + modifier = Modifier.padding(start = 12.dp).weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The user's metadata, observed: loads the [User] if needed, subscribes to their metadata + * on relays and recomposes when it arrives or changes in LocalCache. + */ +@Composable +internal fun observedUserInfo( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): UserMetadata? { + var user by remember(pubKey) { mutableStateOf(accountViewModel.getUserIfExists(pubKey)) } + if (user == null) { + LaunchedEffect(pubKey) { user = accountViewModel.checkGetOrCreateUser(pubKey) } + } + val loaded = user ?: return null + val info by observeUserInfo(loaded, accountViewModel) + return info?.info +} + +@Composable +internal fun observedName( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): String = observedUserInfo(pubKey, accountViewModel)?.bestName() ?: pubKey.toShortDisplay() + +@Composable +internal fun observedPicture( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): String? = observedUserInfo(pubKey, accountViewModel)?.profilePicture() + +@Composable +private fun StruckPill( + text: String, + color: Color, +) { + Text( + text, + modifier = + Modifier + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + color = color, + textDecoration = TextDecoration.LineThrough, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +internal fun PrivateItemsCard( + change: ContentChange, + modifier: Modifier = Modifier, +) { + val tones = conflictTones() + val (title, body) = + when (change) { + ContentChange.CLEARED -> R.string.backup_review_private_wiped_title to R.string.backup_review_private_wiped_body + ContentChange.ADDED -> R.string.backup_review_private_added_title to R.string.backup_review_private_added_body + else -> R.string.backup_review_private_changed_title to R.string.backup_review_private_changed_body + } + Row( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.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(tones.changedContainer()), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.Lock, contentDescription = null, tint = tones.changed) + } + Column(Modifier.weight(1f)) { + Text(stringRes(title), style = MaterialTheme.typography.titleSmall) + Text(stringRes(body), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + } +} + +// --------------------------------------------------------------------------------------- +// Profile: both versions side by side, then field-by-field inline diffs. +// --------------------------------------------------------------------------------------- + +private class ProfileField( + val labelRes: Int, + val change: ValueChange, + val isImage: Boolean = false, +) + +private fun profileFields(diff: MetadataDiff): List = + listOfNotNull( + diff.picture?.let { ProfileField(R.string.backup_profile_field_picture, it, isImage = true) }, + diff.banner?.let { ProfileField(R.string.backup_profile_field_banner, it, isImage = true) }, + diff.name?.let { ProfileField(R.string.backup_profile_field_name, it) }, + diff.displayName?.let { ProfileField(R.string.backup_profile_field_display_name, it) }, + diff.about?.let { ProfileField(R.string.backup_profile_field_about, it) }, + diff.nip05?.let { ProfileField(R.string.backup_profile_field_nip05, it) }, + diff.website?.let { ProfileField(R.string.backup_profile_field_website, it) }, + diff.lud16?.let { ProfileField(R.string.backup_profile_field_lud16, it) }, + diff.lud06?.let { ProfileField(R.string.backup_profile_field_lud06, it) }, + diff.clinkOffer?.let { ProfileField(R.string.backup_profile_field_clink_offer, it) }, + diff.pronouns?.let { ProfileField(R.string.backup_profile_field_pronouns, it) }, + diff.bot?.let { ProfileField(R.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) }, + diff.birthday?.let { + ProfileField( + R.string.backup_profile_field_birthday, + ValueChange( + it.before?.let { b -> listOfNotNull(b.year, b.month, b.day).joinToString("-") }, + it.after?.let { a -> listOfNotNull(a.year, a.month, a.day).joinToString("-") }, + ), + ) + }, + ) + +private fun LazyListScope.profileItems( + diff: MetadataDiff, + conflict: ReplaceableBackupConflict, + presentation: DiffPresentation, + accountViewModel: AccountViewModel, + nav: INav, +) { + val saved = (conflict.saved as? MetadataEvent)?.contactMetaData() ?: UserMetadata() + val incoming = (conflict.incoming as? MetadataEvent)?.contactMetaData() ?: UserMetadata() + val pubKey = conflict.incoming.pubKey + + item(key = "profile-cards", contentType = "profile-cards") { + val tones = conflictTones() + Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MiniProfileCard(saved, pubKey, stringRes(R.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f)) + MiniProfileCard(incoming, pubKey, stringRes(R.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f)) + } + } + + val fields = profileFields(diff) + if (fields.isNotEmpty()) { + item(key = "profile-fields", contentType = "profile-fields") { + Column( + Pad + .padding(top = 14.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + fields.forEach { FieldDiffRow(it, accountViewModel) } + } + } + } + // Linked identities and fields this app doesn't model use the generic sections. + genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_profile_field)), accountViewModel, nav) +} + +@Composable +private fun MiniProfileCard( + meta: UserMetadata, + pubKey: HexKey, + label: String, + borderColor: Color, + accountViewModel: AccountViewModel, + modifier: Modifier, +) { + Column( + modifier + .clip(RoundedCornerShape(20.dp)) + .border(1.dp, borderColor, RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + BannerImage(meta.banner, Modifier.fillMaxWidth().height(64.dp), accountViewModel) + Column(Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Box( + Modifier + .offset(y = (-26).dp) + .size(52.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(3.dp), + ) { + RobohashFallbackAsyncImage( + robot = pubKey, + model = meta.picture, + contentDescription = null, + modifier = Modifier.size(46.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + } + Column(Modifier.offset(y = (-20).dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(meta.bestName() ?: pubKey.toShortDisplay(), style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + meta.nip05?.let { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis) } + meta.about?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 3, overflow = TextOverflow.Ellipsis) } + StatusTag(label, MaterialTheme.colorScheme.placeholderText, Modifier.padding(top = 6.dp)) + } + } + } +} + +@Composable +private fun FieldDiffRow( + field: ProfileField, + accountViewModel: AccountViewModel, +) { + val tones = conflictTones() + val before = field.change.before + val after = field.change.after + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringRes(field.labelRes), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText) + if (field.isImage) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ImageThumb(before, tones.removed, accountViewModel) + Text("→", color = MaterialTheme.colorScheme.placeholderText) + ImageThumb(after, tones.added, accountViewModel) + } + } else { + FlowRowDiff(before, after) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FlowRowDiff( + before: String?, + after: String?, +) { + val tones = conflictTones() + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), itemVerticalAlignment = Alignment.CenterVertically) { + if (before != null) DiffChip(clip(before), tones.removed, struck = true) + when { + after == null -> StatusTag(stringRes(R.string.backup_review_gone), tones.removed) + before == null -> { + DiffChip(clip(after), tones.added, struck = false) + StatusTag(stringRes(R.string.backup_review_new_tag), tones.added) + } + else -> { + Text("→", color = MaterialTheme.colorScheme.placeholderText) + DiffChip(clip(after), tones.added, struck = false) + } + } + } +} + +@Composable +private fun DiffChip( + text: String, + color: Color, + struck: Boolean, +) { + Text( + text, + modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.14f)).padding(horizontal = 8.dp, vertical = 2.dp), + color = color, + textDecoration = if (struck) TextDecoration.LineThrough else null, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun ImageThumb( + url: String?, + color: Color, + accountViewModel: AccountViewModel, +) { + val shape = RoundedCornerShape(10.dp) + Box( + Modifier + .size(72.dp) + .clip(shape) + .border(2.dp, color.copy(alpha = 0.6f), shape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + if (url == null) { + Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + } else { + MyAsyncImage( + imageUrl = url, + contentDescription = null, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.size(72.dp), + loadedImageModifier = Modifier.size(72.dp), + accountViewModel = accountViewModel, + onLoadingBackground = null, + onError = { Text(clip(url), style = MaterialTheme.typography.labelSmall, maxLines = 3) }, + ) + } + } +} + +// --------------------------------------------------------------------------------------- +// NIP-65: reach numbers, then outbox and inbox lanes with every relay's fate. +// --------------------------------------------------------------------------------------- + +private enum class LaneState { KEPT, DROPPED, ADDED, DEMOTED } + +private class LaneRow( + val url: String, + val state: LaneState, +) + +private fun lane( + saved: Map, + new: Map, + uses: (AdvertisedRelayType) -> Boolean, +): List { + val rows = mutableListOf() + saved.forEach { (url, type) -> + if (!uses(type)) return@forEach + val now = new[url] + rows.add( + LaneRow( + url, + when { + now == null -> LaneState.DROPPED + uses(now) -> LaneState.KEPT + else -> LaneState.DEMOTED + }, + ), + ) + } + new.forEach { (url, type) -> + if (uses(type) && saved[url]?.let(uses) != true) rows.add(LaneRow(url, LaneState.ADDED)) + } + return rows.sortedBy { it.state.ordinal } +} + +private fun LazyListScope.nip65Items( + conflict: ReplaceableBackupConflict, + nav: INav, +) { + val saved = (conflict.saved as? AdvertisedRelayListEvent)?.relays()?.associate { it.relayUrl.url to it.type } ?: emptyMap() + val new = (conflict.incoming as? AdvertisedRelayListEvent)?.relays()?.associate { it.relayUrl.url to it.type } ?: emptyMap() + val writes = { t: AdvertisedRelayType -> t != AdvertisedRelayType.READ } + val reads = { t: AdvertisedRelayType -> t != AdvertisedRelayType.WRITE } + val outbox = lane(saved, new, writes) + val inbox = lane(saved, new, reads) + + laneItems("outbox", stringRes = R.string.backup_review_outbox, subRes = R.string.backup_review_outbox_explainer, rows = outbox, nav = nav) + laneItems("inbox", stringRes = R.string.backup_review_inbox, subRes = R.string.backup_review_inbox_explainer, rows = inbox, nav = nav) +} + +private fun LazyListScope.laneItems( + key: String, + stringRes: Int, + subRes: Int, + rows: List, + nav: INav, +) { + if (rows.isEmpty()) return + item(key = "lane-$key", contentType = "lane-header") { + Row(Pad.padding(top = 18.dp, bottom = 6.dp).fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + Text(stringRes(stringRes), style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + Text(stringRes(subRes), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + } + } + items(rows, key = { "lane-$key-${it.url}" }, contentType = { "lane-row" }) { LaneRelayRow(it, nav) } +} + +@Composable +private fun LaneRelayRow( + row: LaneRow, + nav: INav, +) { + val tones = conflictTones() + val (color, tag) = + when (row.state) { + LaneState.KEPT -> tones.kept to null + LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to R.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to R.string.backup_review_demoted_tag + } + Row( + Pad + .padding(vertical = 3.dp) + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { nav.nav(Route.RelayInfo(row.url)) } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .then(if (row.state == LaneState.DROPPED) Modifier.border(2.dp, color, CircleShape) else Modifier.background(color)), + ) + Text( + row.url.removePrefix("wss://").removeSuffix("/"), + modifier = Modifier.weight(1f), + color = if (row.state == LaneState.KEPT) MaterialTheme.colorScheme.onSurface else color, + textDecoration = if (row.state == LaneState.DROPPED) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + tag?.let { StatusTag(stringRes(it), color) } + } +} + +// --------------------------------------------------------------------------------------- +// Nutzap info: the receiving key as a fingerprint swap, then mints. +// --------------------------------------------------------------------------------------- + +private fun LazyListScope.nutzapItems( + diff: NutzapInfoDiff, + conflict: ReplaceableBackupConflict, + presentation: DiffPresentation, + accountViewModel: AccountViewModel, + nav: INav, +) { + val key = diff.p2pkPubkey + if (key != null) { + item(key = "nutzap-key", contentType = "key-swap") { + val tones = conflictTones() + Column( + Pad + .padding(top = 8.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(symbol = MaterialSymbols.Key, contentDescription = null, tint = tones.changed) + Text(stringRes(if (key.after == null) R.string.backup_review_key_removed else R.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + KeyColumn(key.before, stringRes(R.string.backup_review_saved), tones.kept) + Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(horizontal = 18.dp)) + KeyColumn(key.after, stringRes(R.string.backup_review_new), tones.changed) + } + if (key.after != null) { + Row( + Modifier + .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_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) + } + } + } + } + } + + // Matched the way the diff matches them, and once per mint: a list can repeat a mint, + // and a repeated row key crashes the LazyColumn. + val mintKey = { m: NutzapMintTag -> m.mintUrl.trimEnd('/').lowercase() } + val saved = (conflict.saved as? NutzapInfoEvent)?.mints().orEmpty().distinctBy(mintKey) + val removedUrls = + diff.mints.removed + .map(mintKey) + .toSet() + val changedUrls = diff.mints.changed.associateBy { mintKey(it.before) } + val mintRows = + saved.map { m -> + when (mintKey(m)) { + in removedUrls -> Triple(m.mintUrl, m.units.joinToString(), LaneState.DROPPED) + in changedUrls -> Triple(m.mintUrl, changedUrls.getValue(mintKey(m)).let { it.before.units.joinToString() + " → " + it.after.units.joinToString() }, LaneState.DEMOTED) + else -> Triple(m.mintUrl, m.units.joinToString(), LaneState.KEPT) + } + } + diff.mints.added.map { Triple(it.mintUrl, it.units.joinToString(), LaneState.ADDED) } + + if (mintRows.isNotEmpty()) { + item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) } + items(mintRows, key = { "mint-" + it.first }, contentType = { "mint" }) { (url, units, state) -> MintRow(url, units, state) } + } + genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_mint, R.string.backup_entry_nutzap_key)), accountViewModel, nav) +} + +@Composable +private fun KeyColumn( + hex: String?, + label: String, + color: Color, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (hex != null) { + KeyFingerprint(hex, color) + } else { + Box(Modifier.size(96.dp).clip(RoundedCornerShape(18.dp)).border(2.dp, color, RoundedCornerShape(18.dp)), contentAlignment = Alignment.Center) { + Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + } + } + StatusTag(label, color) + } +} + +@Composable +private fun MintRow( + url: String, + units: String, + state: LaneState, +) { + val tones = conflictTones() + val (color, tag) = + when (state) { + LaneState.KEPT -> MaterialTheme.colorScheme.onSurface to null + LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to R.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to R.string.backup_review_units_tag + } + Row( + Pad + .padding(vertical = 4.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.AccountBalanceWallet, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp)) + } + Column(Modifier.weight(1f)) { + Text( + url.removePrefix("https://").removeSuffix("/"), + color = color, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textDecoration = if (state == LaneState.DROPPED) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (units.isNotEmpty()) Text(units, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + } + tag?.let { StatusTag(stringRes(it), color) } + } +} + +// --------------------------------------------------------------------------------------- +// Groups: a tile per group, faded when you left it, badged when new or renamed. +// --------------------------------------------------------------------------------------- + +private class GroupTile( + val group: GroupTag, + val state: LaneState, + val oldName: String? = null, +) + +private fun LazyListScope.groupItems( + diff: SimpleGroupListDiff, + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Matched the way the diff matches them, and once per group: a repeated row key crashes + // the LazyColumn. + val key = { g: GroupTag -> g.groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(g.relayUrl)?.url ?: g.relayUrl) } + val removed = + diff.groups.removed + .map(key) + .toSet() + val changed = diff.groups.changed.associateBy { key(it.before) } + val saved = (conflict.saved as? SimpleGroupListEvent)?.publicGroups().orEmpty().distinctBy(key) + val tiles = + saved.map { g -> + when (key(g)) { + in removed -> GroupTile(g, LaneState.DROPPED) + in changed -> changed.getValue(key(g)).let { GroupTile(it.after, LaneState.DEMOTED, it.before.name) } + else -> GroupTile(g, LaneState.KEPT) + } + } + diff.groups.added.map { GroupTile(it, LaneState.ADDED) } + + item(key = "group-stats", contentType = "tiles") { + val tones = conflictTones() + Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatTile( + diff.groups.removed.size + .toString(), + stringRes(R.string.backup_review_left), + tones.removed, + Modifier.weight(1f), + ) + StatTile( + diff.groups.added.size + .toString(), + stringRes(R.string.backup_review_joined), + tones.added, + Modifier.weight(1f), + ) + StatTile( + diff.groups.changed.size + .toString(), + stringRes(R.string.backup_review_renamed), + tones.changed, + Modifier.weight(1f), + ) + } + } + items(tiles.chunked(2), key = { "group-" + key(it.first().group) }, contentType = { "group-row" }) { row -> + Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEach { GroupTileCard(it, accountViewModel, nav, Modifier.weight(1f)) } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } +} + +@Composable +private fun GroupTileCard( + tile: GroupTile, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier, +) { + val relay = RelayUrlNormalizer.normalizeOrNull(tile.group.relayUrl) + if (relay == null) { + GroupTileContent(tile, tile.group.name ?: tile.group.groupId, null, accountViewModel, modifier, null) + return + } + LoadRelayGroupChannel(GroupId(tile.group.groupId, relay), accountViewModel) { channel -> + // Subscribes to the group's relay-signed metadata (even when not joined) and recomposes + // when its name or picture arrives. + val state by observeChannel(channel, accountViewModel) + val current = (state?.channel as? RelayGroupChannel) ?: channel + val name = current.event?.name() ?: tile.group.name ?: tile.group.groupId + GroupTileContent(tile, name, current.profilePicture(), accountViewModel, modifier) { nav.nav(routeFor(current)) } + } +} + +@Composable +private fun GroupTileContent( + tile: GroupTile, + name: String, + picture: String?, + 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 (tile.state == LaneState.DROPPED) 0.45f else 1f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + RobohashFallbackAsyncImage( + robot = tile.group.groupId + "@" + tile.group.relayUrl, + model = picture, + contentDescription = null, + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + Column { + Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + tile.oldName?.let { stringRes(R.string.backup_review_was_named, it) } ?: tile.group.relayUrl + .removePrefix("wss://") + .removeSuffix("/"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + when (tile.state) { + LaneState.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd)) + LaneState.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd)) + LaneState.DEMOTED -> StatusTag(stringRes(R.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd)) + LaneState.KEPT -> {} + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt new file mode 100644 index 0000000000..557d39df95 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -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 fates( + saved: List, + diff: ListDiff, + key: (T) -> K, +): List> { + val removed = diff.removed.mapTo(HashSet(), key) + val changed = diff.changed.associateBy { key(it.after) } + val seen = HashSet() + val result = mutableListOf>() + 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 LazyListScope.countTiles( + items: List>, + 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 = + 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>, + 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 LazyListScope.spaceGrid( + keyPrefix: String, + items: List>, + 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, + saved: List, + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt new file mode 100644 index 0000000000..6c7772ba36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -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, + val added: List, + val changed: List, +) { + 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, + 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 listGroup( + label: Int, + diff: ListDiff, + item: (T) -> ReviewItem, + change: (ItemChange) -> 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 listGroup( + label: Int, + diff: ListDiff, + 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 ListDiff<*>.only() = + ListDiff( + removed.filterIsInstance(), + added.filterIsInstance(), + 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?>>, +): DiffGroup { + val removed = mutableListOf() + val added = mutableListOf() + val changed = mutableListOf() + 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(), { ReviewItem.Person(it.pubKey) }), + listGroup(R.string.backup_entry_word, diff.publicMutes.only(), { ReviewItem.Text("\"" + it.word + "\"") }), + listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only(), { ReviewItem.Text("#" + it.hashtag) }), + listGroup(R.string.backup_entry_thread, diff.publicMutes.only(), { 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() to diff.wallet + is ConcordCommunityListDiff -> emptyList() to diff.communities + is AppSpecificDataDiff -> emptyList() to diff.data + else -> emptyList() to ContentChange.NONE + } + return DiffPresentation(groups.filterNot { it.isEmpty() }, content) +} + +private fun nutzapKeyGroup(change: ValueChange?): DiffGroup { + val none = emptyList() + 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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt new file mode 100644 index 0000000000..9571148df7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt @@ -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 = emptySet(), +): List { + val rows = mutableListOf() + + fun section( + titleRes: Int, + count: Int, + tone: Tone, + contentNote: Int?, + items: (DiffGroup) -> List, + ) { + 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, + 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 = + 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 = + 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictVisuals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictVisuals.kt new file mode 100644 index 0000000000..1d03ece3de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictVisuals.kt @@ -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, + 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() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 6d29193c44..1265e2e4a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7fb92bcb67..1104e9be34 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -677,4 +677,322 @@ Sound on. Click to mute Play Pause + You changed your %1$s in another app + Removed (%1$s) + Added (%1$s) + Changed (%1$s) + You removed all private (encrypted) items. + You also changed private (encrypted) items. They can\'t be compared here. + You added private (encrypted) items. + Restore saved version + On this device + New version + Not set + %1$s removed + %1$s added + %1$s changed + + You unfollowed %1$d person + You unfollowed %1$d people + + Accept + %1$s updated + %1$s cleared + Changed in another app · %1$s + Keep %1$s + Restore %1$s + Re-mute %1$s + Rejoin %1$s + Restore profile + Restore relays + %1$s kept + %1$s dropped + %1$s new + Dropped · %1$s + New · %1$s + Edited · %1$s + Search %1$s people + Nothing here + %1$s unmuted + These can reach your feeds again + %1$s newly muted + Words & hashtags + %1$s and %2$s more + You removed all private items + They\'re encrypted, so only you can see what they were. Restoring brings them all back. + You made items private + They\'re encrypted now, which is likely where the public items above went. + You changed private items + They\'re encrypted, so they can\'t be compared here. + Gone + New + Dropped + Moved + Units + Left + Renamed + Outbox + where you publish + Inbox + where others reach you + Receiving key replaced + Receiving key removed + Nutzaps will be locked to this new key. If this device doesn\'t have it, you may not be able to redeem them here. + left + joined + renamed + was “%1$s” + Block %1$s again + Restore feeds + Restore topics + Restore places + Restore providers + Restore wallet + kept + Receiving + Listed + Searching + Storing + Trusted + Untrusted + %1$s unblocked + Amethyst will connect to them again + No longer blocked + Newly blocked + Still blocked + Allowed + Moved + Feed + topics gone + places gone + new + Hashtags you follow + Places you follow + %1$s of %2$s services changed + They now come from someone else, or no one + Service + Rank + Followers + Topics + Ranks, follower counts and spam filtering you see come from these providers. + + %1$d way to pay you removed + %1$d ways to pay you removed + + + %1$d offer removed + %1$d offers removed + + People paying you only see what\'s in this list. + BOLT12 offer + You emptied it + You created it + You changed it + 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. + Your Concord communities are encrypted to you, so they can\'t be compared here. Restoring puts back exactly what this device saved. + + You unmuted %1$d thing + You unmuted %1$d things + + + You removed %1$d profile field + You removed %1$d profile fields + + + You removed %1$d public relay + You removed %1$d public relays + + + You removed %1$d DM relay + You removed %1$d DM relays + + + You removed %1$d group-chat key relay + You removed %1$d group-chat key relays + + + You removed %1$d search relay + You removed %1$d search relays + + + You removed %1$d indexer relay + You removed %1$d indexer relays + + + You removed %1$d relay feed + You removed %1$d relay feeds + + + You removed %1$d private relay + You removed %1$d private relays + + + You stopped trusting %1$d relay + You stopped trusting %1$d relays + + + You unblocked %1$d relay + You unblocked %1$d relays + + + You left %1$d public chat + You left %1$d public chats + + + You left %1$d community + You left %1$d communities + + + You left %1$d chat room + You left %1$d chat rooms + + + You left %1$d group + You left %1$d groups + + + You removed %1$d favorite feed + You removed %1$d favorite feeds + + + You unfollowed %1$d hashtag + You unfollowed %1$d hashtags + + + You unfollowed %1$d place + You unfollowed %1$d places + + + You changed the provider of %1$d trust service + You changed the provider of %1$d trust services + + + You removed %1$d nutzap mint + You removed %1$d nutzap mints + + + You removed %1$d way to get paid + You removed %1$d ways to get paid + + + You removed %1$d BOLT12 offer + You removed %1$d BOLT12 offers + + + %1$s and added %2$d + %1$s and added %2$d + + + %1$s and followed %2$d + %1$s and followed %2$d + + + %1$s and muted %2$d + %1$s and muted %2$d + + + %1$s and joined %2$d + %1$s and joined %2$d + + + %1$s and blocked %2$d + %1$s and blocked %2$d + + Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems. + You replaced your nutzap key + You removed your nutzap key + You emptied your Cashu wallet + You changed your Cashu wallet + You emptied your Concord list + You changed your Concord list + Use new version + read only + write only + read and write + profile + Your name, picture, bio and payment addresses, as everyone sees them. + follow list + Decides whose posts appear in your home feed. + mute list + Hides people, words, hashtags and threads from everything you see. + public relay list + Tells everyone where to find your posts and where to send you replies and mentions. + DM relay list + Where others deliver your private messages. + group-chat key relays + Where others find the keys to add you to encrypted group chats. + search relay list + The relays used when you search. + indexer relay list + The relays used to find other people\'s profiles and relay lists. + favorite relay feeds + The relays you browse as feeds. + blocked relay list + Relays this app never connects to. + trusted relay list + Relays you trust to connect to without asking. + private relay list + Where your drafts and private data are stored. + synced app settings + Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared. + public chat list + The public chats you joined. + community list + The communities you joined. + followed hashtags + Hashtags whose posts appear in your feeds. + followed locations + Locations whose posts appear in your feeds. + favorite feeds list + The algorithmic feeds you saved. + ephemeral chat list + The ephemeral chat rooms you joined. + group list + The relay-based groups you joined. + Concord community list + The Concord communities you joined. They are encrypted, so only their presence can be compared. + trust provider list + The services you trust to rank and verify people for you. + Cashu wallet + Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared. + nutzap receiving settings + The mints and key others use to send you nutzaps. + payment addresses + The ways other people can pay you. + BOLT12 offers + The lightning offers others use to pay you. + list + A list saved on this device. + Profile fields + People + Hashtags + Words + Threads + Public chats + Communities + Feeds + Locations + Relays + Groups + Chat rooms + Trust providers + Mints + Nutzap keys + Payment addresses + Offers + Linked identities + Other entries + Username + Display name + About + Picture + Banner + Website + Nostr address + Lightning address + LNURL + Pronouns + Birthday + CLINK offer + Bot flag diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorageTest.kt new file mode 100644 index 0000000000..dd2aa22c3d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorageTest.kt @@ -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>, + content: String = "", + ): Event = signer.sign(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 = + 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()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/PickAccountDataRelaysTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/PickAccountDataRelaysTest.kt new file mode 100644 index 0000000000..052d4266b1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/PickAccountDataRelaysTest.kt @@ -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)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt new file mode 100644 index 0000000000..ca4caa338f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt @@ -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>(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> = _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 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() + + // Externally signed versions the user explicitly chose to keep over the backup. + private val accepted = HashSet() + + // 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() + private val movedOnWhileRestoring = HashSet() + + /** + * 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 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>) { + 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> = _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() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt new file mode 100644 index 0000000000..401a992b83 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt @@ -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(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 +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt new file mode 100644 index 0000000000..dd348ce0da --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt @@ -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 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> = + 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() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 9775a4a596..c9e15d642c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -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, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt new file mode 100644 index 0000000000..ef034b2f0f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt @@ -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() + private var changes = 0 + private val reapplied = ArrayList() + 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(100, 10000, arrayOf(arrayOf("p", alice)), "")) + followsBackup.update(follows(200, bob)) + mutes.update(signer.sign(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) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt new file mode 100644 index 0000000000..ece67f29b7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt @@ -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>, + content: String = "", + ): Event = signer.sign(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(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() }) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 48bcea77b6..1d201d6c6c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -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) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt new file mode 100644 index 0000000000..6bc2df62f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt @@ -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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt index fff6f69bbb..9f001418c9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.kt new file mode 100644 index 0000000000..c3e149e94a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(rooms.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt index 02e98a628b..9ff4efd9e2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt new file mode 100644 index 0000000000..29ddcc0f85 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt @@ -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, +) : EventDiff { + override fun removesData() = targets.hasRemovals() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt index 7ee3be366f..c1982a6d02 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 = tags.mapNotNull { PaymentTargetTag.parse(it) } companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt index 613cba6650..e9eb595b97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt new file mode 100644 index 0000000000..1d5b596544 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt @@ -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 { + fun diffFrom(older: Event): D? +} + +/** An item present in both versions whose details differ. */ +@Immutable +class ItemChange( + 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( + val removed: List, + val added: List, + val changed: List>, +) { + 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 of( + older: List, + newer: List, + key: (T) -> K, + same: (T, T) -> Boolean = { a, b -> a == b }, + ): ListDiff { + val olderByKey = LinkedHashMap(older.size) + older.forEach { olderByKey.getOrPut(key(it)) { it } } + val newerByKey = LinkedHashMap(newer.size) + newer.forEach { newerByKey.getOrPut(key(it)) { it } } + + val removed = mutableListOf() + val changed = mutableListOf>() + 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( + val before: T?, + val after: T?, +) { + fun isRemoval() = before != null && after == null + + companion object { + /** Null when both sides are the same. */ + fun of( + before: T?, + after: T?, + same: (T, T) -> Boolean = { a, b -> a == b }, + ): ValueChange? = + 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 + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt new file mode 100644 index 0000000000..e4660df8ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt @@ -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?, + val displayName: ValueChange?, + val picture: ValueChange?, + val banner: ValueChange?, + val website: ValueChange?, + val about: ValueChange?, + val pronouns: ValueChange?, + val nip05: ValueChange?, + val lud06: ValueChange?, + val lud16: ValueChange?, + val clinkOffer: ValueChange?, + val bot: ValueChange?, + val birthday: ValueChange?, + val otherFields: ListDiff>, + val identityClaims: ListDiff, +) : EventDiff { + private fun fieldChanges(): List> = 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") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 1bf9afe078..1729917c97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -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, 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> = + 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt new file mode 100644 index 0000000000..248e6551da --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt @@ -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, +) : EventDiff { + override fun removesData() = follows.hasRemovals() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index 293fc606d1..ceb0d9c66b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -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, 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt index 10847c6d38..da8184c850 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 = tags.mapNotNull(RelayTag::parse) /** Every relay in this list, local ones included. For reading back our OWN list. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt new file mode 100644 index 0000000000..69ab74f29c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(channels.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt index 5f97ccde41..27f84bb63c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt @@ -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, 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt index 30a0f6e6ee..9734cb1d4f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt index fe90d885d0..f9dc36de5a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt index 2f901168e4..a47a2d4a74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt new file mode 100644 index 0000000000..d46cf70ff7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(feeds.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt index e7a00838fe..a7a8ec1d19 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 = tags.mapNotNull(AddressBookmark::parse) suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt new file mode 100644 index 0000000000..4e0c5ec47e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt @@ -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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(geohashes.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt index 4822e39119..936a691d84 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt new file mode 100644 index 0000000000..21faacdee8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt @@ -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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(hashtags.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt index 8d862ce132..67af6cddc7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt new file mode 100644 index 0000000000..079e89f180 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(publicMutes.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt index 1230fe16b4..500ab86628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt @@ -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, 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt index 00ed9542e4..621d164b38 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt index 56dd3a3a4b..ad137a9fd9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt index 598361a923..a758db06d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt new file mode 100644 index 0000000000..a4b04e7192 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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, + val privateRelays: ContentChange, +) : EventDiff { + override fun removesData() = privateRelays.publicRemovalsAreLoss(relays.hasRemovals()) || privateRelays.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt index 1fd5c7fd58..98edcc200b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt new file mode 100644 index 0000000000..a141de829a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt @@ -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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(groups.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt index bc7620a372..8fb1843951 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt new file mode 100644 index 0000000000..977ec8d256 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt @@ -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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt index 9b2fd15b27..538df0a7f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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). */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt new file mode 100644 index 0000000000..ad8ab36916 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt @@ -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, + val relays: ListDiff, + val p2pkPubkey: ValueChange?, +) : 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 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt index 8a876ccc7d..20fb2f3be8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt new file mode 100644 index 0000000000..3af1f02cb2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt @@ -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, +) : 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()) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt index 8a619a021f..e9689b3fc0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 { + val byUrl = LinkedHashMap() + 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt new file mode 100644 index 0000000000..5f7fb9b95d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(communities.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt index 7a1536713a..0a25bf51d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt @@ -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, 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt new file mode 100644 index 0000000000..0a810f407e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt @@ -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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt index eba9fd265e..7d7b06d258 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt new file mode 100644 index 0000000000..3eb66fa456 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(providers.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt index c901813730..ff07ed550c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt @@ -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>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt new file mode 100644 index 0000000000..523dbcf127 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt @@ -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, +) : EventDiff { + override fun removesData() = offers.hasRemovals() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt index e1c4c17191..1457dcd55d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt @@ -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>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + 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 = tags.mapNotNull(OfferTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt new file mode 100644 index 0000000000..6f2e981e8f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -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 sign( + kind: Int, + createdAt: Long, + tags: Array>, + content: String = "", + ): T = assertIs(signer.sign(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.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob, "wss://a.com/", "bobby"))) + val newer = sign(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.KIND, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}") + val newer = sign(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.KIND, + 100, + arrayOf(arrayOf("p", alice), arrayOf("t", "spam"), arrayOf("word", "crypto"), arrayOf("e", bob)), + "encrypted-private-items", + ) + val newer = sign(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(removed[0]).pubKey) + assertEquals("spam", assertIs(removed[1]).hashtag) + assertEquals(bob, assertIs(removed[2]).eventId) + assertTrue(diff.publicMutes.added.none { it is WordTag }) + assertEquals(ContentChange.CLEARED, diff.privateItems) + } + + @Test + fun rewrittenPrivateItemsAreChangedNotRemoved() { + val older = sign(MuteListEvent.KIND, 100, arrayOf(), "cipher-a") + val newer = sign(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.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("word", "crypto")), "") + val newer = sign(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.KIND, 100, arrayOf(arrayOf("p", alice)), "cipher-a") + val newer = sign(MuteListEvent.KIND, 200, arrayOf(), "cipher-b") + assertTrue(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun nip65DiffsRelayTypes() { + val older = + sign( + AdvertisedRelayListEvent.KIND, + 100, + arrayOf(arrayOf("r", "wss://a.com/"), arrayOf("r", "wss://b.com/", "read"), arrayOf("r", "wss://c.com/")), + ) + val newer = + sign( + 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.KIND, 100, arrayOf(arrayOf("relay", "wss://spam.com/"))) + val newer = sign(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.KIND, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":"","twitter":"vp"}""") + val newer = sign(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.KIND, 100, arrayOf(), """{"name":"vitor","bot":false}""") + val newer = sign(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.KIND, 100, arrayOf(arrayOf("group", "abc", "wss://g.com", "Friends"), arrayOf("group", "xyz", "wss://g.com"))) + val newer = sign(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.KIND, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com"))) + val newer = sign(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.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice))) + val newer = sign(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.KIND, 100, arrayOf()) + val follows = sign(ContactListEvent.KIND, 200, arrayOf()) + val someoneElse = assertIs(NostrSignerSync(KeyPair()).sign(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.KIND, 200, follows + newerBookkeeping) + .diffFrom(sign(ContactListEvent.KIND, 100, follows + olderBookkeeping)) + assertTrue(assertNotNull(contacts).follows.isEmpty()) + + val mutes = arrayOf(arrayOf("word", "spam")) + val muteDiff = + assertNotNull( + sign(MuteListEvent.KIND, 200, mutes + newerBookkeeping) + .diffFrom(sign(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.KIND, 200, relays + newerBookkeeping) + .diffFrom(sign(AdvertisedRelayListEvent.KIND, 100, relays + olderBookkeeping)) + assertTrue(assertNotNull(nip65).relays.isEmpty()) + + val blocked = arrayOf(arrayOf("relay", "wss://spam.com/")) + val blockedDiff = + sign(BlockedRelayListEvent.KIND, 200, blocked + newerBookkeeping) + .diffFrom(sign(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.KIND, 200, mints + newerBookkeeping) + .diffFrom(sign(NutzapInfoEvent.KIND, 100, mints + olderBookkeeping)), + ) + assertTrue(nutzap.mints.isEmpty() && nutzap.relays.isEmpty()) + assertNull(nutzap.p2pkPubkey) + + val profile = """{"name":"vitor"}""" + val metadata = + assertNotNull( + sign(MetadataEvent.KIND, 200, newerBookkeeping, profile) + .diffFrom(sign(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.KIND, 100, arrayOf(arrayOf("p", alice)) + olderBookkeeping) + val newer = sign(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, + ) = sign(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.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat", "usd"))) + val newer = sign(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com", "sat"))) + assertTrue(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun nutzapMintUrlSpellingAndUnitOrderAreNotChanges() { + val older = sign(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://Mint.com", "sat", "usd"))) + val newer = sign(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.KIND, 100, arrayOf(arrayOf("d", ""), arrayOf("group", "g1", "wss://groups.com"))) + val newer = sign(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.KIND, 100, arrayOf(arrayOf("g", "9Q8"))) + val newer = sign(GeohashListEvent.KIND, 200, arrayOf(arrayOf("g", "9q8"))) + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun droppingDeprecatedProfileAliasesIsNotALoss() { + val older = sign(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","display_name":"Vitor","displayName":"Vitor","username":"vitor"}""") + val newer = sign(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor","display_name":"Vitor"}""") + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } +}