From 7683188706a54afcdb48af8e97d1db36c9fe975e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 17:57:24 +0000 Subject: [PATCH 01/31] feat: ask before another app's lossy rewrite replaces a backed-up replaceable event Track the replaceable/addressable events this app signs. When a newer version of a backed-up event (profile, follow/mute/relay lists, wallet, app settings...) arrives from another client and drops tags, profile fields or private items the backup still has, freeze the backup and ask the user to either keep the new version or re-sign the saved one over it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 56 ++++++ .../vitorpamplona/amethyst/model/Account.kt | 20 ++ .../amethyst/model/AccountSettings.kt | 137 +++++++++----- .../ui/screen/loggedIn/LoggedInPage.kt | 4 + .../loggedIn/backups/BackupConflictDialog.kt | 177 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 15 ++ .../model/backups/LocallySignedEvents.kt | 50 +++++ .../backups/ReplaceableBackupConflict.kt | 151 +++++++++++++++ .../commons/model/cache/EventCache.kt | 6 +- .../backups/ReplaceableBackupDiffTest.kt | 124 ++++++++++++ .../desktop/cache/DesktopLocalCache.kt | 2 + 11 files changed, 692 insertions(+), 50 deletions(-) create mode 100644 amethyst/plans/2026-09-22-replaceable-backup-conflicts.md create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt 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..76162f077d --- /dev/null +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -0,0 +1,56 @@ +# 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. +2. **Only question lossy external rewrites.** `AccountSettings.acceptIntoBackup` guards + every `update*` backup method. A newer version that was not signed here is diffed by + `ReplaceableBackupDiff.detectLoss` against the backup: + - tags matched by name + value (a new relay hint or petname is an edit, not a loss; + `alt`/`client`/`d`/`expiration` are ignored); + - kind 0: any filled profile field that is now missing or blank; + - kind 3: content ignored (deprecated relay map); + - everything else: content that went from non-blank to blank (private items wiped). + + If nothing was dropped, the other app evidently built on the previous version, and the + backup is updated silently as before. +3. **Freeze and ask.** On a loss, the backup keeps the saved version and a + `ReplaceableBackupConflict` is published on `AccountSettings.backupConflicts`. + `BackupConflictDialog` (shown from `LoggedInPage`) lists what's missing and 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)` and publishes it. + - **Use new version** — `Account.acceptExternalVersion` whitelists that id and re-runs + the original update, so the backup moves forward. + - **Decide later** — hides it for this session only; the backup stays frozen. + + 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. + +Conflicts are not persisted: the frozen backup is reloaded on start, the newer relay +version replaces it in `LocalCache`, and the same conflict is detected again. + +## 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 dialog yet. 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 3ddf690bc3..dd32fbde31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -1889,6 +1890,25 @@ 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) with a + * timestamp newer than the external one, and publishes it so it replaces that version. + */ + suspend fun restoreBackupOver(conflict: ReplaceableBackupConflict) { + val saved = conflict.saved + val createdAt = maxOf(TimeUtils.now(), conflict.incoming.createdAt + 1) + val resigned = signer.sign(createdAt, saved.kind, saved.tags, saved.content) + settings.dismissBackupConflict(conflict) + sendMyPublicAndPrivateOutbox(resigned) + } + + /** Resolves a [ReplaceableBackupConflict] in favor of the version from the other client. */ + fun acceptExternalVersion(conflict: ReplaceableBackupConflict) { + settings.dismissBackupConflict(conflict) + conflict.keepIncoming() + } + 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 f5ab845350..bc2b8342cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.HomeFeedType +import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupDiff +import com.vitorpamplona.amethyst.commons.model.backups.backupSlot import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm @@ -39,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -51,6 +56,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 @@ -1158,6 +1164,62 @@ class AccountSettings( // Backup Lists // ---- + /** + * 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. Not persisted: + * after a restart the same newer event is detected again against the same frozen backup. + */ + val backupConflicts = MutableStateFlow>(emptyMap()) + + // Externally signed versions the user explicitly chose to keep over the backup. + private val acceptedExternalVersions = ConcurrentSet() + + /** + * 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 accepts it. + */ + private fun acceptIntoBackup( + saved: T?, + incoming: T, + retry: () -> Unit, + ): Boolean { + if (saved?.id == incoming.id) return false + + val slot = backupSlot(incoming) + val pending = backupConflicts.value[slot] + + // 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 { LocallySignedEvents.contains(incoming.id) } + + if (reference != null && !acceptedExternalVersions.contains(incoming.id)) { + val loss = ReplaceableBackupDiff.detectLoss(reference, incoming) + if (loss != null) { + val conflict = + ReplaceableBackupConflict(reference, incoming, loss) { + acceptedExternalVersions.add(incoming.id) + retry() + } + backupConflicts.update { it + (slot to conflict) } + return false + } + } + + if (backupConflicts.value.containsKey(slot)) { + backupConflicts.update { it - slot } + } + return true + } + + fun dismissBackupConflict(conflict: ReplaceableBackupConflict) { + backupConflicts.update { current -> + if (current[conflict.slot] === conflict) current - conflict.slot else current + } + } + fun updateLocalRelayServers(servers: Set) { if (localRelayServers.value != servers) { localRelayServers.update { servers } @@ -1176,8 +1238,7 @@ 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 (acceptIntoBackup(backupUserMetadata, newMetadata) { updateUserMetadata(newMetadata) }) { backupUserMetadata = newMetadata saveAccountSettings() } @@ -1186,8 +1247,7 @@ class AccountSettings( fun updateContactListTo(newContactList: ContactListEvent?) { if (newContactList == null || newContactList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupContactList?.id != newContactList.id) { + if (acceptIntoBackup(backupContactList, newContactList) { updateContactListTo(newContactList) }) { backupContactList = newContactList saveAccountSettings() } @@ -1196,8 +1256,7 @@ class AccountSettings( fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) { if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupDMRelayList?.id != newDMRelayList.id) { + if (acceptIntoBackup(backupDMRelayList, newDMRelayList) { updateDMRelayList(newDMRelayList) }) { backupDMRelayList = newDMRelayList saveAccountSettings() } @@ -1206,8 +1265,7 @@ class AccountSettings( fun updateKeyPackageRelayList(newKeyPackageRelayList: KeyPackageRelayListEvent?) { if (newKeyPackageRelayList == null || newKeyPackageRelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupKeyPackageRelayList?.id != newKeyPackageRelayList.id) { + if (acceptIntoBackup(backupKeyPackageRelayList, newKeyPackageRelayList) { updateKeyPackageRelayList(newKeyPackageRelayList) }) { backupKeyPackageRelayList = newKeyPackageRelayList saveAccountSettings() } @@ -1216,8 +1274,7 @@ class AccountSettings( fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) { if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupNIP65RelayList?.id != newNIP65RelayList.id) { + if (acceptIntoBackup(backupNIP65RelayList, newNIP65RelayList) { updateNIP65RelayList(newNIP65RelayList) }) { backupNIP65RelayList = newNIP65RelayList saveAccountSettings() } @@ -1226,7 +1283,7 @@ 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 (acceptIntoBackup(backupCashuWallet, newWallet) { updateCashuWallet(newWallet) }) { backupCashuWallet = newWallet saveAccountSettings() } @@ -1242,7 +1299,7 @@ class AccountSettings( clearNutzapInfo() return } - if (backupNutzapInfo?.id != newNutzapInfo.id) { + if (acceptIntoBackup(backupNutzapInfo, newNutzapInfo) { updateNutzapInfo(newNutzapInfo) }) { backupNutzapInfo = newNutzapInfo saveAccountSettings() } @@ -1316,8 +1373,7 @@ class AccountSettings( fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) { if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) { + if (acceptIntoBackup(backupNipA3PaymentTargets, newNIPA3PaymentTargets) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) { backupNipA3PaymentTargets = newNIPA3PaymentTargets saveAccountSettings() } @@ -1326,8 +1382,7 @@ class AccountSettings( fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) { if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupBolt12Offers?.id != newBolt12Offers.id) { + if (acceptIntoBackup(backupBolt12Offers, newBolt12Offers) { updateBolt12Offers(newBolt12Offers) }) { backupBolt12Offers = newBolt12Offers saveAccountSettings() } @@ -1336,8 +1391,7 @@ class AccountSettings( fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) { if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupSearchRelayList?.id != newSearchRelayList.id) { + if (acceptIntoBackup(backupSearchRelayList, newSearchRelayList) { updateSearchRelayList(newSearchRelayList) }) { backupSearchRelayList = newSearchRelayList saveAccountSettings() } @@ -1346,8 +1400,7 @@ class AccountSettings( fun updateIndexRelayList(newIndexRelayList: IndexerRelayListEvent?) { if (newIndexRelayList == null || newIndexRelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupIndexRelayList?.id != newIndexRelayList.id) { + if (acceptIntoBackup(backupIndexRelayList, newIndexRelayList) { updateIndexRelayList(newIndexRelayList) }) { backupIndexRelayList = newIndexRelayList saveAccountSettings() } @@ -1356,8 +1409,7 @@ class AccountSettings( fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) { if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupRelayFeedsList?.id != newRelayFeedList.id) { + if (acceptIntoBackup(backupRelayFeedsList, newRelayFeedList) { updateRelayFeedList(newRelayFeedList) }) { backupRelayFeedsList = newRelayFeedList saveAccountSettings() } @@ -1366,8 +1418,7 @@ class AccountSettings( fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) { if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupBlockedRelayList?.id != newBlockedRelayList.id) { + if (acceptIntoBackup(backupBlockedRelayList, newBlockedRelayList) { updateBlockedRelayList(newBlockedRelayList) }) { backupBlockedRelayList = newBlockedRelayList saveAccountSettings() } @@ -1376,8 +1427,7 @@ class AccountSettings( fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) { if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupTrustedRelayList?.id != newTrustedRelayList.id) { + if (acceptIntoBackup(backupTrustedRelayList, newTrustedRelayList) { updateTrustedRelayList(newTrustedRelayList) }) { backupTrustedRelayList = newTrustedRelayList saveAccountSettings() } @@ -1386,8 +1436,7 @@ class AccountSettings( fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) { if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) { + if (acceptIntoBackup(backupPrivateHomeRelayList, newPrivateHomeRelayList) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) { backupPrivateHomeRelayList = newPrivateHomeRelayList saveAccountSettings() } @@ -1398,8 +1447,7 @@ class AccountSettings( override fun updateChannelListTo(newChannelList: ChannelListEvent?) { if (newChannelList == null || newChannelList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupChannelList?.id != newChannelList.id) { + if (acceptIntoBackup(backupChannelList, newChannelList) { updateChannelListTo(newChannelList) }) { backupChannelList = newChannelList saveAccountSettings() } @@ -1408,8 +1456,7 @@ class AccountSettings( fun updateGeohashListTo(newGeohashList: GeohashListEvent?) { if (newGeohashList == null || newGeohashList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupGeohashList?.id != newGeohashList.id) { + if (acceptIntoBackup(backupGeohashList, newGeohashList) { updateGeohashListTo(newGeohashList) }) { backupGeohashList = newGeohashList saveAccountSettings() } @@ -1418,8 +1465,7 @@ class AccountSettings( fun updateHashtagListTo(newHashtagList: HashtagListEvent?) { if (newHashtagList == null || newHashtagList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupHashtagList?.id != newHashtagList.id) { + if (acceptIntoBackup(backupHashtagList, newHashtagList) { updateHashtagListTo(newHashtagList) }) { backupHashtagList = newHashtagList saveAccountSettings() } @@ -1428,8 +1474,7 @@ class AccountSettings( fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) { + if (acceptIntoBackup(backupFavoriteAlgoFeedsList, newFavoriteDvmList) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) { backupFavoriteAlgoFeedsList = newFavoriteDvmList saveAccountSettings() } @@ -1438,8 +1483,7 @@ class AccountSettings( fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { if (newCommunityList == null || newCommunityList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupCommunityList?.id != newCommunityList.id) { + if (acceptIntoBackup(backupCommunityList, newCommunityList) { updateCommunityListTo(newCommunityList) }) { backupCommunityList = newCommunityList saveAccountSettings() } @@ -1450,8 +1494,7 @@ class AccountSettings( override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) { if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupEphemeralChatList?.id != newEphemeralChatList.id) { + if (acceptIntoBackup(backupEphemeralChatList, newEphemeralChatList) { updateEphemeralChatListTo(newEphemeralChatList) }) { backupEphemeralChatList = newEphemeralChatList saveAccountSettings() } @@ -1464,8 +1507,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 (acceptIntoBackup(backupRelayGroupList, newRelayGroupList) { updateRelayGroupListTo(newRelayGroupList) }) { backupRelayGroupList = newRelayGroupList saveAccountSettings() } @@ -1478,7 +1520,7 @@ 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 (acceptIntoBackup(backupConcordList, newConcordList) { updateConcordListTo(newConcordList) }) { backupConcordList = newConcordList saveAccountSettings() } @@ -1487,8 +1529,7 @@ class AccountSettings( fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) { if (trustProviderList == null || trustProviderList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupTrustProviderList?.id != trustProviderList.id) { + if (acceptIntoBackup(backupTrustProviderList, trustProviderList) { updateTrustProviderListTo(trustProviderList) }) { backupTrustProviderList = trustProviderList saveAccountSettings() } @@ -1497,8 +1538,7 @@ class AccountSettings( fun updateMuteList(newMuteList: MuteListEvent?) { if (newMuteList == null || newMuteList.tags.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupMuteList?.id != newMuteList.id) { + if (acceptIntoBackup(backupMuteList, newMuteList) { updateMuteList(newMuteList) }) { backupMuteList = newMuteList saveAccountSettings() } @@ -1510,8 +1550,7 @@ class AccountSettings( ) { if (appSettings == null || appSettings.content.isEmpty()) return - // Events might be different objects, we have to compare their ids. - if (backupAppSpecificData?.id != appSettings.id) { + if (acceptIntoBackup(backupAppSpecificData, appSettings) { updateAppSpecificData(appSettings, newSyncedSettings) }) { backupAppSpecificData = appSettings syncedSettings.updateFrom(newSyncedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 935683f480..c6730afa1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmDiscoveryPreload import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload @@ -129,6 +130,9 @@ fun LoggedInPage( accountViewModel = accountViewModel, accountSessionManager = accountSessionManager, ) + + // Asks before another client's lossy rewrite of a backed-up list/profile becomes permanent. + BackupConflictDialog(accountViewModel) } @OptIn(ExperimentalPermissionsApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt new file mode 100644 index 0000000000..9f403220bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.ui.note.timeAbsolute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +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.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.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent + +private const val MAX_LISTED_ITEMS = 8 + +/** + * Asks the user what to do when another client replaced one of the account's backed-up + * replaceable events with a version that lost data: keep the new one, or re-sign the + * version saved on this device over it. One conflict at a time; "decide later" only hides the + * question for this session: the conflict stays open, so the backup stays frozen on the saved + * version and the question comes back on the next launch. + */ +@Composable +fun BackupConflictDialog(accountViewModel: AccountViewModel) { + val conflicts by accountViewModel.account.settings.backupConflicts + .collectAsStateWithLifecycle() + + val snoozed = remember { mutableStateSetOf() } + + val conflict = conflicts.values.firstOrNull { it.incoming.id !in snoozed } ?: return + + BackupConflictDialog( + conflict = conflict, + onRestore = { accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }, + onKeepNew = { accountViewModel.account.acceptExternalVersion(conflict) }, + onDismiss = { snoozed.add(conflict.incoming.id) }, + ) +} + +@Composable +private fun BackupConflictDialog( + conflict: ReplaceableBackupConflict, + onRestore: () -> Unit, + onKeepNew: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val what = backupLabel(conflict.saved) + val missing = remember(conflict) { describeLoss(conflict) } + val clearedLabel = stringRes(R.string.backup_conflict_private_items_cleared) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.backup_conflict_title, what)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + stringRes( + R.string.backup_conflict_explainer, + what, + timeAbsolute(conflict.incoming.createdAt, context), + ), + ) + Spacer(Modifier.height(12.dp)) + Text(stringRes(R.string.backup_conflict_missing_items), style = MaterialTheme.typography.titleSmall) + if (conflict.loss.contentCleared) { + Text("• $clearedLabel", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + missing.take(MAX_LISTED_ITEMS).forEach { + Text("• $it", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (missing.size > MAX_LISTED_ITEMS) { + Text(stringRes(R.string.backup_conflict_more_items, (missing.size - MAX_LISTED_ITEMS).toString())) + } + } + }, + confirmButton = { + Button(onClick = onRestore) { Text(stringRes(R.string.backup_conflict_restore_mine)) } + }, + dismissButton = { + Column { + TextButton(onClick = onKeepNew) { Text(stringRes(R.string.backup_conflict_keep_new)) } + TextButton(onClick = onDismiss) { Text(stringRes(R.string.backup_conflict_decide_later)) } + } + }, + ) +} + +private fun describeLoss(conflict: ReplaceableBackupConflict): List = + conflict.loss.removedFields + + conflict.loss.removedTags.map { tag -> + val value = tag.getOrNull(1) ?: "" + if (tag[0] == "p") { + LocalCache.getUserIfExists(value)?.toBestDisplayName() ?: value + } else { + "${tag[0]}: $value" + } + } + +@Composable +private fun backupLabel(event: Event): String = + when (event) { + is MetadataEvent -> stringRes(R.string.backup_kind_profile) + is ContactListEvent -> stringRes(R.string.backup_kind_follow_list) + is MuteListEvent -> stringRes(R.string.backup_kind_mute_list) + is AdvertisedRelayListEvent, + is ChatMessageRelayListEvent, + is KeyPackageRelayListEvent, + is SearchRelayListEvent, + is IndexerRelayListEvent, + is RelayFeedsListEvent, + is BlockedRelayListEvent, + is TrustedRelayListEvent, + is PrivateOutboxRelayListEvent, + -> stringRes(R.string.backup_kind_relay_settings) + is AppSpecificDataEvent -> stringRes(R.string.backup_kind_app_settings) + is CashuWalletEvent, + is NutzapInfoEvent, + is PaymentTargetsEvent, + is Bolt12OfferListEvent, + -> stringRes(R.string.backup_kind_wallet) + else -> stringRes(R.string.backup_kind_generic_list, event.kind.toString()) + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 50d09e98e0..87892fb2bd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2829,4 +2829,19 @@ %1$d video %1$d videos + Your %1$s changed elsewhere + Another app published a newer version of your %1$s (%2$s) and it is missing things this device had saved. Apps that don\'t read the previous version before writing can erase your data by accident. + Missing from the new version: + All private (encrypted) items + …and %1$s more + Restore saved version + Use new version + Decide later + profile + follow list + mute list + relay settings + app settings + wallet settings + list (kind %1$s) 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..3e92c64934 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt @@ -0,0 +1,50 @@ +/* + * 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.ConcurrentSet +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, so the set stays small. + */ +object LocallySignedEvents { + private val ids = ConcurrentSet() + + fun mark(event: Event) { + if (event.kind.isReplaceable() || event.kind.isAddressable()) { + ids.add(event.id) + } + } + + fun contains(eventId: HexKey): Boolean = ids.contains(eventId) +} 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..8cad4cbdb0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt @@ -0,0 +1,151 @@ +/* + * 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.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject + +/** + * What a newer, externally signed version of a backed-up replaceable event dropped + * relative to the version this device had saved. + * + * @property removedTags tags of the saved version that the new one no longer carries. + * @property removedFields kind:0 profile fields that were filled and are now gone or blank. + * @property contentCleared the saved version had content (e.g. NIP-44 private list items) + * and the new one has none. + */ +@Immutable +class DataLossReport( + val removedTags: List>, + val removedFields: List, + val contentCleared: Boolean, +) { + fun isEmpty() = removedTags.isEmpty() && removedFields.isEmpty() && !contentCleared +} + +/** + * 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. + */ +@Immutable +class ReplaceableBackupConflict( + val saved: Event, + val incoming: Event, + val loss: DataLossReport, + private val acceptIncoming: () -> Unit, +) { + /** One conflict per replaceable slot: kind for replaceables, kind + d-tag for addressables. */ + val slot: String = backupSlot(incoming) + + fun keepIncoming() = acceptIncoming() +} + +fun backupSlot(event: Event): String = event.kind.toString() + ":" + (event.tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "") + +object ReplaceableBackupDiff { + /** + * Tag names that clients rewrite for their own bookkeeping. Dropping them loses nothing + * the user entered, so they don't count as data loss. + */ + private val IGNORED_TAG_NAMES = setOf("alt", "client", "d", "expiration") + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Compares the [saved] backup with an [incoming] replacement of the same slot and reports + * what the replacement dropped, or null when it dropped nothing (it only added or edited + * entries, which means the other app did look at the previous version). + * + * Tags are matched by name + value, so a changed relay hint or petname on the same entry + * is an edit, not a loss. + */ + fun detectLoss( + saved: Event, + incoming: Event, + ): DataLossReport? { + if (saved.id == incoming.id) return null + if (saved.kind != incoming.kind || saved.pubKey != incoming.pubKey) return null + // Older or same-age versions never replace the backup in LocalCache anyway. + if (incoming.createdAt <= saved.createdAt) return null + + val incomingKeys = HashSet(incoming.tags.size) + incoming.tags.forEach { tag -> tagKey(tag)?.let { incomingKeys.add(it) } } + + val removedTags = + saved.tags.filter { tag -> + val key = tagKey(tag) + key != null && key !in incomingKeys + } + + val removedFields: List + val contentCleared: Boolean + + when (saved.kind) { + MetadataEvent.KIND -> { + removedFields = removedProfileFields(saved.content, incoming.content) + contentCleared = false + } + // kind:3 content is a deprecated relay map many clients drop on purpose. + ContactListEvent.KIND -> { + removedFields = emptyList() + contentCleared = false + } + else -> { + removedFields = emptyList() + contentCleared = saved.content.isNotBlank() && incoming.content.isBlank() + } + } + + val report = DataLossReport(removedTags, removedFields, contentCleared) + return if (report.isEmpty()) null else report + } + + private fun tagKey(tag: Array): String? { + if (tag.isEmpty() || tag[0] in IGNORED_TAG_NAMES) return null + return if (tag.size > 1) tag[0] + "\u0000" + tag[1] else tag[0] + } + + private fun parseObject(content: String): JsonObject? = runCatching { json.parseToJsonElement(content).jsonObject }.getOrNull() + + private fun JsonObject.filledKeys(): Set = + filter { (_, value) -> + when (value) { + is JsonPrimitive -> value.content.isNotBlank() && value.content != "null" + else -> true + } + }.keys + + private fun removedProfileFields( + savedContent: String, + incomingContent: String, + ): List { + val saved = parseObject(savedContent)?.filledKeys() ?: return emptyList() + val incoming = parseObject(incomingContent)?.filledKeys() ?: emptySet() + return saved.filter { it !in incoming }.sorted() + } +} 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..510dc978ce 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Dao +import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus @@ -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/ReplaceableBackupDiffTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt new file mode 100644 index 0000000000..b6e23cc72f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt @@ -0,0 +1,124 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +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) + private val carol = "c".repeat(64) + + @Test + fun followListThatOnlyAddsIsNotALoss() { + val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) + val incoming = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob), arrayOf("p", carol))) + assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + } + + @Test + fun followListRewrittenFromScratchIsALoss() { + val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) + val incoming = sign(3, 200, arrayOf(arrayOf("p", carol))) + val loss = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(listOf(alice, bob), loss.removedTags.map { it[1] }) + } + + @Test + fun changedRelayHintOrPetnameIsAnEditNotALoss() { + val saved = sign(3, 100, arrayOf(arrayOf("p", alice, "wss://a.com", "al"))) + val incoming = sign(3, 200, arrayOf(arrayOf("p", alice, "wss://b.com"))) + assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + } + + @Test + fun bookkeepingTagsDoNotCount() { + val saved = sign(10002, 100, arrayOf(arrayOf("r", "wss://a.com"), arrayOf("client", "Amethyst"), arrayOf("alt", "x"))) + val incoming = sign(10002, 200, arrayOf(arrayOf("r", "wss://a.com"))) + assertNull(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 clearedPrivateItemsAreALoss() { + val saved = sign(10000, 100, arrayOf(arrayOf("p", alice)), "encrypted-private-items") + val incoming = sign(10000, 200, arrayOf(arrayOf("p", alice)), "") + val loss = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertTrue(loss.contentCleared) + assertTrue(loss.removedTags.isEmpty()) + } + + @Test + fun contactListContentIsIgnored() { + val saved = sign(3, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}") + val incoming = sign(3, 200, arrayOf(arrayOf("p", alice)), "") + assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + } + + @Test + fun profileMissingFieldsIsALoss() { + val saved = sign(0, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":""}""") + val incoming = sign(0, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""") + val loss = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(listOf("lud16"), loss.removedFields) + assertFalse(loss.contentCleared) + } + + @Test + fun profileEditThatKeepsFieldsIsNotALoss() { + val saved = sign(0, 100, arrayOf(), """{"name":"vitor","about":"hi"}""") + val incoming = sign(0, 200, arrayOf(), """{"name":"vitor","about":"hello","website":"https://v"}""") + 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)) + } +} 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) { From 9f23d4b2f0d1b2aed715acf97c9f99bf1e5e26e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 18:31:56 +0000 Subject: [PATCH 02/31] style: spotless import order Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../src/main/java/com/vitorpamplona/amethyst/model/Account.kt | 2 +- .../vitorpamplona/amethyst/commons/model/cache/EventCache.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 dd32fbde31..7674daf321 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -39,11 +39,11 @@ 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.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 import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces -import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel 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 510dc978ce..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 @@ -27,13 +27,13 @@ import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Dao -import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note 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 From c1c3e8fcb71e3f058f3e16d84f6210af0d9ef538 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 19:13:33 +0000 Subject: [PATCH 03/31] feat: per-event backup conflict dialog showing what was removed, added and changed BackupDiff now classifies the conflicting event (profile, follow list, mute list, each relay list, wallet, ...) and types every entry (person, relay, hashtag, word, profile field, mint, trust provider, ...), with removed, added and changed lists. The dialog names the event, explains what it is for, and groups the differences by entry type: people by display name, relays with their read/write marker, profile fields old -> new. LocallySignedEvents now keeps its ids in a bounded LRU cache. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 14 +- .../amethyst/model/AccountSettings.kt | 6 +- .../loggedIn/backups/BackupConflictDialog.kt | 352 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 108 +++++- .../model/backups/LocallySignedEvents.kt | 15 +- .../backups/ReplaceableBackupConflict.kt | 324 +++++++++++++--- .../backups/ReplaceableBackupDiffTest.kt | 88 ++++- 7 files changed, 750 insertions(+), 157 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 76162f077d..e11ce3c8c6 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -14,10 +14,15 @@ only surviving copy of the user's data is gone. 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. + 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. **Only question lossy external rewrites.** `AccountSettings.acceptIntoBackup` guards every `update*` backup method. A newer version that was not signed here is diffed by - `ReplaceableBackupDiff.detectLoss` against the backup: + `ReplaceableBackupDiff.diff` against the backup into a `BackupDiff`: the + `BackupEventType` (profile, follow list, mute list, each relay list, wallet…) plus + removed / added / changed `BackupEntry`s, each typed (`BackupEntryType`: person, relay, + hashtag, word, thread, profile field, mint, trust provider…) so the UI can label them. + It only counts as a loss when something was removed: - tags matched by name + value (a new relay hint or petname is an edit, not a loss; `alt`/`client`/`d`/`expiration` are ignored); - kind 0: any filled profile field that is now missing or blank; @@ -28,7 +33,10 @@ only surviving copy of the user's data is gone. backup is updated silently as before. 3. **Freeze and ask.** On a loss, the backup keeps the saved version and a `ReplaceableBackupConflict` is published on `AccountSettings.backupConflicts`. - `BackupConflictDialog` (shown from `LoggedInPage`) lists what's missing and offers: + `BackupConflictDialog` (shown from `LoggedInPage`) is specific to the event: it names it + ("Your mute list changed in another app"), says what that event is for, and lists what + was removed, added and changed, grouped by entry type (people by display name, relays + with their read/write marker, profile fields old → new…). It 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)` and publishes it. 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 bc2b8342cc..3ccd7f007c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1196,10 +1196,10 @@ class AccountSettings( val reference = pending?.saved ?: saved?.takeUnless { LocallySignedEvents.contains(incoming.id) } if (reference != null && !acceptedExternalVersions.contains(incoming.id)) { - val loss = ReplaceableBackupDiff.detectLoss(reference, incoming) - if (loss != null) { + val diff = ReplaceableBackupDiff.detectLoss(reference, incoming) + if (diff != null) { val conflict = - ReplaceableBackupConflict(reference, incoming, loss) { + ReplaceableBackupConflict(reference, incoming, diff) { acceptedExternalVersions.add(incoming.id) retry() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt index 9f403220bb..e50cd4e447 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -35,44 +36,42 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle 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.BackupDiff +import com.vitorpamplona.amethyst.commons.model.backups.BackupEntry +import com.vitorpamplona.amethyst.commons.model.backups.BackupEntryChange +import com.vitorpamplona.amethyst.commons.model.backups.BackupEntryType +import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.ui.note.timeAbsolute import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -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.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.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent -private const val MAX_LISTED_ITEMS = 8 +private const val MAX_ITEMS_PER_GROUP = 6 +private const val MAX_VALUE_LENGTH = 80 /** * Asks the user what to do when another client replaced one of the account's backed-up * replaceable events with a version that lost data: keep the new one, or re-sign the - * version saved on this device over it. One conflict at a time; "decide later" only hides the - * question for this session: the conflict stays open, so the backup stays frozen on the saved - * version and the question comes back on the next launch. + * version saved on this device over it. + * + * The dialog is specific to the event that changed: it names it, says what it is for, and + * lists what was removed, added and changed, grouped by what each entry is (people, relays, + * hashtags, profile fields…). + * + * One conflict at a time; "decide later" only hides the question for this session: the + * conflict stays open, so the backup stays frozen on the saved version and the question + * comes back on the next launch. */ @Composable fun BackupConflictDialog(accountViewModel: AccountViewModel) { @@ -99,33 +98,28 @@ private fun BackupConflictDialog( onDismiss: () -> Unit, ) { val context = LocalContext.current - val what = backupLabel(conflict.saved) - val missing = remember(conflict) { describeLoss(conflict) } - val clearedLabel = stringRes(R.string.backup_conflict_private_items_cleared) + val diff = conflict.diff AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.backup_conflict_title, what)) }, + title = { Text(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(diff.eventType)))) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { Text( - stringRes( - R.string.backup_conflict_explainer, - what, - timeAbsolute(conflict.incoming.createdAt, context), - ), + text = stringRes(eventTypeExplainer(diff.eventType)), + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, ) Spacer(Modifier.height(12.dp)) - Text(stringRes(R.string.backup_conflict_missing_items), style = MaterialTheme.typography.titleSmall) - if (conflict.loss.contentCleared) { - Text("• $clearedLabel", maxLines = 1, overflow = TextOverflow.Ellipsis) - } - missing.take(MAX_LISTED_ITEMS).forEach { - Text("• $it", maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (missing.size > MAX_LISTED_ITEMS) { - Text(stringRes(R.string.backup_conflict_more_items, (missing.size - MAX_LISTED_ITEMS).toString())) - } + Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.incoming.createdAt, context))) + + DiffDetails(diff) + + Spacer(Modifier.height(12.dp)) + Text( + text = stringRes(R.string.backup_conflict_restore_explainer), + style = MaterialTheme.typography.bodySmall, + ) } }, confirmButton = { @@ -140,38 +134,252 @@ private fun BackupConflictDialog( ) } -private fun describeLoss(conflict: ReplaceableBackupConflict): List = - conflict.loss.removedFields + - conflict.loss.removedTags.map { tag -> - val value = tag.getOrNull(1) ?: "" - if (tag[0] == "p") { - LocalCache.getUserIfExists(value)?.toBestDisplayName() ?: value - } else { - "${tag[0]}: $value" - } +@Composable +private fun DiffDetails(diff: BackupDiff) { + if (diff.removed.isNotEmpty() || diff.privateItemsCleared) { + SectionHeader( + stringRes(R.string.backup_conflict_removed, diff.removed.size.toString()), + MaterialTheme.colorScheme.error, + ) + if (diff.privateItemsCleared) { + Text("• " + stringRes(R.string.backup_conflict_private_cleared)) } + EntryGroups(diff.removed) + } + + if (diff.added.isNotEmpty()) { + SectionHeader( + stringRes(R.string.backup_conflict_added, diff.added.size.toString()), + MaterialTheme.colorScheme.primary, + ) + EntryGroups(diff.added) + } + + if (diff.changed.isNotEmpty() || diff.privateItemsChanged) { + SectionHeader( + stringRes(R.string.backup_conflict_changed, diff.changed.size.toString()), + MaterialTheme.colorScheme.tertiary, + ) + if (diff.privateItemsChanged) { + Text("• " + stringRes(R.string.backup_conflict_private_changed)) + } + ChangeGroups(diff.changed) + } +} @Composable -private fun backupLabel(event: Event): String = - when (event) { - is MetadataEvent -> stringRes(R.string.backup_kind_profile) - is ContactListEvent -> stringRes(R.string.backup_kind_follow_list) - is MuteListEvent -> stringRes(R.string.backup_kind_mute_list) - is AdvertisedRelayListEvent, - is ChatMessageRelayListEvent, - is KeyPackageRelayListEvent, - is SearchRelayListEvent, - is IndexerRelayListEvent, - is RelayFeedsListEvent, - is BlockedRelayListEvent, - is TrustedRelayListEvent, - is PrivateOutboxRelayListEvent, - -> stringRes(R.string.backup_kind_relay_settings) - is AppSpecificDataEvent -> stringRes(R.string.backup_kind_app_settings) - is CashuWalletEvent, - is NutzapInfoEvent, - is PaymentTargetsEvent, - is Bolt12OfferListEvent, - -> stringRes(R.string.backup_kind_wallet) - else -> stringRes(R.string.backup_kind_generic_list, event.kind.toString()) +private fun SectionHeader( + text: String, + color: Color, +) { + Spacer(Modifier.height(12.dp)) + Text(text = text, style = MaterialTheme.typography.titleSmall, color = color) +} + +@Composable +private fun GroupLabel(type: BackupEntryType) { + Text( + text = stringRes(entryTypeName(type)), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 6.dp), + ) +} + +@Composable +private fun Item(text: String) { + Text( + text = "• $text", + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp), + ) +} + +@Composable +private fun MoreItems(count: Int) { + if (count > 0) { + Text( + text = stringRes(R.string.backup_conflict_more_items, count.toString()), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@Composable +private fun EntryGroups(entries: List) { + entries.groupBy { it.type }.forEach { (type, group) -> + GroupLabel(type) + group.take(MAX_ITEMS_PER_GROUP).forEach { Item(describe(it)) } + MoreItems(group.size - MAX_ITEMS_PER_GROUP) + } +} + +@Composable +private fun ChangeGroups(changes: List) { + changes.groupBy { it.before.type }.forEach { (type, group) -> + GroupLabel(type) + group.take(MAX_ITEMS_PER_GROUP).forEach { Item(describe(it)) } + MoreItems(group.size - MAX_ITEMS_PER_GROUP) + } +} + +@Composable +private fun describe(change: BackupEntryChange): String { + val before = change.before + val after = change.after + return when (before.type) { + BackupEntryType.PROFILE_FIELD -> + profileFieldName(before.value) + ": " + clip(before.detail) + " → " + clip(after.detail) + BackupEntryType.RELAY -> + before.value + ": " + relayMarker(before.detail) + " → " + relayMarker(after.detail) + else -> entryName(before) + ": " + clip(before.detail) + " → " + clip(after.detail) + } +} + +@Composable +private fun describe(entry: BackupEntry): String = + when (entry.type) { + BackupEntryType.PROFILE_FIELD -> profileFieldName(entry.value) + ": " + clip(entry.detail) + BackupEntryType.RELAY -> entry.value + " (" + relayMarker(entry.detail) + ")" + BackupEntryType.TRUST_PROVIDER -> (entry.detail ?: "") + ": " + personName(entry.value) + BackupEntryType.PAYMENT_TARGET, BackupEntryType.OTHER -> + if (entry.detail != null) entry.value + ": " + clip(entry.detail) else clip(entry.value) + BackupEntryType.CHAT_ROOM -> + if (entry.detail != null) entry.value + " @ " + entry.detail else entry.value + else -> entryName(entry) + } + +private fun entryName(entry: BackupEntry): String = + when (entry.type) { + BackupEntryType.PERSON, BackupEntryType.NUTZAP_KEY -> personName(entry.value) + BackupEntryType.HASHTAG -> "#" + entry.value + BackupEntryType.WORD -> "\"" + entry.value + "\"" + BackupEntryType.PUBLIC_CHAT -> + LocalCache.getPublicChatChannelIfExists(entry.value)?.toBestDisplayName() ?: entry.value.toShortDisplay() + BackupEntryType.COMMUNITY, BackupEntryType.ALGO_FEED, BackupEntryType.ADDRESS -> + Address.parse(entry.value)?.dTag?.ifBlank { null } ?: entry.value.toShortDisplay() + BackupEntryType.RELAY_GROUP -> entry.detail ?: entry.value + BackupEntryType.THREAD, BackupEntryType.EVENT, BackupEntryType.OFFER -> entry.value.toShortDisplay() + else -> clip(entry.value) + } + +private fun personName(pubkey: String): String = LocalCache.getUserIfExists(pubkey)?.toBestDisplayName() ?: pubkey.toShortDisplay() + +private 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 +} + +@Composable +private fun relayMarker(marker: String?): String = + when (marker) { + "read" -> stringRes(R.string.backup_conflict_relay_read_only) + "write" -> stringRes(R.string.backup_conflict_relay_write_only) + else -> stringRes(R.string.backup_conflict_relay_read_write) + } + +@Composable +private fun profileFieldName(field: String): String = + when (field) { + "name" -> stringRes(R.string.backup_profile_field_name) + "display_name", "displayName" -> stringRes(R.string.backup_profile_field_display_name) + "about" -> stringRes(R.string.backup_profile_field_about) + "picture", "image" -> stringRes(R.string.backup_profile_field_picture) + "banner" -> stringRes(R.string.backup_profile_field_banner) + "website" -> stringRes(R.string.backup_profile_field_website) + "nip05" -> stringRes(R.string.backup_profile_field_nip05) + "lud16" -> stringRes(R.string.backup_profile_field_lud16) + "lud06" -> stringRes(R.string.backup_profile_field_lud06) + "pronouns" -> stringRes(R.string.backup_profile_field_pronouns) + "birthday" -> stringRes(R.string.backup_profile_field_birthday) + "bot" -> stringRes(R.string.backup_profile_field_bot) + else -> field + } + +private fun entryTypeName(type: BackupEntryType): Int = + when (type) { + BackupEntryType.PROFILE_FIELD -> R.string.backup_entry_profile_field + BackupEntryType.PERSON -> R.string.backup_entry_person + BackupEntryType.HASHTAG -> R.string.backup_entry_hashtag + BackupEntryType.WORD -> R.string.backup_entry_word + BackupEntryType.THREAD -> R.string.backup_entry_thread + BackupEntryType.PUBLIC_CHAT -> R.string.backup_entry_public_chat + BackupEntryType.COMMUNITY -> R.string.backup_entry_community + BackupEntryType.ALGO_FEED -> R.string.backup_entry_algo_feed + BackupEntryType.LOCATION -> R.string.backup_entry_location + BackupEntryType.RELAY -> R.string.backup_entry_relay + BackupEntryType.RELAY_GROUP -> R.string.backup_entry_relay_group + BackupEntryType.CHAT_ROOM -> R.string.backup_entry_chat_room + BackupEntryType.TRUST_PROVIDER -> R.string.backup_entry_trust_provider + BackupEntryType.MINT -> R.string.backup_entry_mint + BackupEntryType.NUTZAP_KEY -> R.string.backup_entry_nutzap_key + BackupEntryType.PAYMENT_TARGET -> R.string.backup_entry_payment_target + BackupEntryType.OFFER -> R.string.backup_entry_offer + BackupEntryType.EVENT -> R.string.backup_entry_event + BackupEntryType.ADDRESS -> R.string.backup_entry_address + BackupEntryType.OTHER -> R.string.backup_entry_other + } + +private 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 + } + +private 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 87892fb2bd..841b68db73 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2829,19 +2829,105 @@ %1$d video %1$d videos - Your %1$s changed elsewhere - Another app published a newer version of your %1$s (%2$s) and it is missing things this device had saved. Apps that don\'t read the previous version before writing can erase your data by accident. - Missing from the new version: - All private (encrypted) items + Your %1$s changed in another app + Another app published a new version on %1$s. Compared with the copy saved on this device: + Removed (%1$s) + Added (%1$s) + Changed (%1$s) + All private (encrypted) items were removed. + Private (encrypted) items also changed. They can\'t be compared here. …and %1$s more + Restoring brings back what was removed and drops what the other app added or changed. Restore saved version Use new version Decide later - profile - follow list - mute list - relay settings - app settings - wallet settings - list (kind %1$s) + 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 + Events + Items + Other entries + Username + Display name + About + Picture + Banner + Website + Nostr address + Lightning address + LNURL + Pronouns + Birthday + Bot flag 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 index 3e92c64934..401a992b83 100644 --- 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 @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.backups -import com.vitorpamplona.amethyst.commons.util.ConcurrentSet +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 @@ -35,16 +35,21 @@ import com.vitorpamplona.quartz.nip01Core.core.isReplaceable * 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, so the set stays small. + * 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 val ids = ConcurrentSet() + 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.add(event.id) + ids.put(event.id, true) } } - fun contains(eventId: HexKey): Boolean = ids.contains(eventId) + 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 index 8cad4cbdb0..a1b5296fc0 100644 --- 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 @@ -21,30 +21,167 @@ 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.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +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.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.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject +/** 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 + } + } +} + +/** What a single entry of a backed-up event represents, so the UI can label and render it. */ +enum class BackupEntryType { + PROFILE_FIELD, + PERSON, + HASHTAG, + WORD, + THREAD, + PUBLIC_CHAT, + COMMUNITY, + ALGO_FEED, + LOCATION, + RELAY, + RELAY_GROUP, + CHAT_ROOM, + TRUST_PROVIDER, + MINT, + NUTZAP_KEY, + PAYMENT_TARGET, + OFFER, + EVENT, + ADDRESS, + OTHER, +} + /** - * What a newer, externally signed version of a backed-up replaceable event dropped - * relative to the version this device had saved. + * One entry of a backed-up event. * - * @property removedTags tags of the saved version that the new one no longer carries. - * @property removedFields kind:0 profile fields that were filled and are now gone or blank. - * @property contentCleared the saved version had content (e.g. NIP-44 private list items) - * and the new one has none. + * @property value the identity of the entry: a pubkey, relay url, hashtag, profile field name… + * @property detail extra context: the relay marker (read/write), a group's name, a profile + * field's value, the service a trust provider serves… */ @Immutable -class DataLossReport( - val removedTags: List>, - val removedFields: List, - val contentCleared: Boolean, +class BackupEntry( + val type: BackupEntryType, + val value: String, + val detail: String?, +) + +/** An entry present in both versions whose details differ (a new relay marker, an edited bio). */ +@Immutable +class BackupEntryChange( + val before: BackupEntry, + val after: BackupEntry, +) + +/** + * Everything that differs between the version of an event saved on this device and a newer + * version from another client. + * + * @property privateItemsCleared the saved version had encrypted private items and the new one has none. + * @property privateItemsChanged the encrypted private items differ but were not cleared; they can't + * be compared without decrypting. + */ +@Immutable +class BackupDiff( + val eventType: BackupEventType, + val removed: List, + val added: List, + val changed: List, + val privateItemsCleared: Boolean, + val privateItemsChanged: Boolean, ) { - fun isEmpty() = removedTags.isEmpty() && removedFields.isEmpty() && !contentCleared + /** The new version dropped something the saved one had: the only case worth asking about. */ + fun losesData() = removed.isNotEmpty() || privateItemsCleared } /** @@ -56,7 +193,7 @@ class DataLossReport( class ReplaceableBackupConflict( val saved: Event, val incoming: Event, - val loss: DataLossReport, + val diff: BackupDiff, private val acceptIncoming: () -> Unit, ) { /** One conflict per replaceable slot: kind for replaceables, kind + d-tag for addressables. */ @@ -70,7 +207,7 @@ fun backupSlot(event: Event): String = event.kind.toString() + ":" + (event.tags object ReplaceableBackupDiff { /** * Tag names that clients rewrite for their own bookkeeping. Dropping them loses nothing - * the user entered, so they don't count as data loss. + * the user entered, so they are left out of the diff. */ private val IGNORED_TAG_NAMES = setOf("alt", "client", "d", "expiration") @@ -78,74 +215,151 @@ object ReplaceableBackupDiff { /** * Compares the [saved] backup with an [incoming] replacement of the same slot and reports - * what the replacement dropped, or null when it dropped nothing (it only added or edited - * entries, which means the other app did look at the previous version). - * - * Tags are matched by name + value, so a changed relay hint or petname on the same entry - * is an edit, not a loss. + * what it removed, added and changed, or null when it removed nothing (it only added or + * edited entries, which means the other app did look at the previous version). */ fun detectLoss( saved: Event, incoming: Event, - ): DataLossReport? { + ): BackupDiff? = diff(saved, incoming)?.takeIf { it.losesData() } + + /** + * Full diff between [saved] and a newer [incoming] version of the same slot. Null when + * they aren't comparable (different kind/author) or [incoming] isn't newer. + * + * Tags are matched by name + value, so a changed relay hint, marker or petname on the same + * entry is a change, not a removal plus an addition. + */ + fun diff( + saved: Event, + incoming: Event, + ): BackupDiff? { if (saved.id == incoming.id) return null if (saved.kind != incoming.kind || saved.pubKey != incoming.pubKey) return null // Older or same-age versions never replace the backup in LocalCache anyway. if (incoming.createdAt <= saved.createdAt) return null - val incomingKeys = HashSet(incoming.tags.size) - incoming.tags.forEach { tag -> tagKey(tag)?.let { incomingKeys.add(it) } } + val eventType = BackupEventType.of(saved.kind) - val removedTags = - saved.tags.filter { tag -> - val key = tagKey(tag) - key != null && key !in incomingKeys + val savedTags = keyedTags(saved) + val incomingTags = keyedTags(incoming) + + val removed = mutableListOf() + val added = mutableListOf() + val changed = mutableListOf() + + savedTags.forEach { (key, tag) -> + val other = incomingTags[key] + if (other == null) { + removed.add(entryOf(eventType, tag)) + } else if (!other.contentEquals(tag)) { + val before = entryOf(eventType, tag) + val after = entryOf(eventType, other) + if (before.detail != after.detail) changed.add(BackupEntryChange(before, after)) } + } + incomingTags.forEach { (key, tag) -> + if (key !in savedTags) added.add(entryOf(eventType, tag)) + } - val removedFields: List - val contentCleared: Boolean + var privateItemsCleared = false + var privateItemsChanged = false - when (saved.kind) { - MetadataEvent.KIND -> { - removedFields = removedProfileFields(saved.content, incoming.content) - contentCleared = false - } + when (eventType) { + BackupEventType.PROFILE -> diffProfile(saved.content, incoming.content, removed, added, changed) // kind:3 content is a deprecated relay map many clients drop on purpose. - ContactListEvent.KIND -> { - removedFields = emptyList() - contentCleared = false - } + BackupEventType.FOLLOW_LIST -> {} else -> { - removedFields = emptyList() - contentCleared = saved.content.isNotBlank() && incoming.content.isBlank() + privateItemsCleared = saved.content.isNotBlank() && incoming.content.isBlank() + privateItemsChanged = !privateItemsCleared && saved.content != incoming.content && saved.content.isNotBlank() } } - val report = DataLossReport(removedTags, removedFields, contentCleared) - return if (report.isEmpty()) null else report + return BackupDiff(eventType, removed, added, changed, privateItemsCleared, privateItemsChanged) } - private fun tagKey(tag: Array): String? { - if (tag.isEmpty() || tag[0] in IGNORED_TAG_NAMES) return null - return if (tag.size > 1) tag[0] + "\u0000" + tag[1] else tag[0] + private fun keyedTags(event: Event): Map> { + val result = LinkedHashMap>(event.tags.size) + event.tags.forEach { tag -> + if (tag.isNotEmpty() && tag[0] !in IGNORED_TAG_NAMES) { + val key = if (tag.size > 1) tag[0] + "\u0000" + tag[1] else tag[0] + result.putIfAbsent(key, tag) + } + } + return result + } + + private fun entryOf( + eventType: BackupEventType, + tag: Array, + ): BackupEntry { + val name = tag[0] + val value = tag.getOrNull(1) ?: "" + return when { + // NIP-85 provider entries are named ":". + name.contains(':') -> BackupEntry(BackupEntryType.TRUST_PROVIDER, value, name.substringAfter(':')) + name == "p" -> BackupEntry(BackupEntryType.PERSON, value, null) + name == "t" -> BackupEntry(BackupEntryType.HASHTAG, value, null) + name == "word" -> BackupEntry(BackupEntryType.WORD, value, null) + name == "g" -> BackupEntry(BackupEntryType.LOCATION, value, null) + // NIP-65 marks read-only / write-only relays; no marker means both. + name == "r" || name == "relay" -> BackupEntry(BackupEntryType.RELAY, value, tag.getOrNull(2)?.ifBlank { null }) + name == "mint" -> BackupEntry(BackupEntryType.MINT, value, null) + name == "pubkey" -> BackupEntry(BackupEntryType.NUTZAP_KEY, value, null) + name == "payto" -> BackupEntry(BackupEntryType.PAYMENT_TARGET, value, tag.getOrNull(2)) + name == "offer" -> BackupEntry(BackupEntryType.OFFER, value, tag.getOrNull(2)) + name == "group" && eventType == BackupEventType.EPHEMERAL_CHATS -> + BackupEntry(BackupEntryType.CHAT_ROOM, value, tag.getOrNull(2)) + // NIP-51 ["group", id, relay, name?] + name == "group" -> BackupEntry(BackupEntryType.RELAY_GROUP, value, tag.getOrNull(3) ?: tag.getOrNull(2)) + name == "e" -> + when (eventType) { + BackupEventType.MUTE_LIST -> BackupEntry(BackupEntryType.THREAD, value, null) + BackupEventType.PUBLIC_CHATS -> BackupEntry(BackupEntryType.PUBLIC_CHAT, value, tag.getOrNull(2)) + else -> BackupEntry(BackupEntryType.EVENT, value, null) + } + name == "a" -> + when (eventType) { + BackupEventType.COMMUNITIES -> BackupEntry(BackupEntryType.COMMUNITY, value, tag.getOrNull(2)) + BackupEventType.FAVORITE_ALGO_FEEDS -> BackupEntry(BackupEntryType.ALGO_FEED, value, tag.getOrNull(2)) + else -> BackupEntry(BackupEntryType.ADDRESS, value, null) + } + else -> BackupEntry(BackupEntryType.OTHER, value, name) + } } private fun parseObject(content: String): JsonObject? = runCatching { json.parseToJsonElement(content).jsonObject }.getOrNull() - private fun JsonObject.filledKeys(): Set = - filter { (_, value) -> - when (value) { - is JsonPrimitive -> value.content.isNotBlank() && value.content != "null" - else -> true - } - }.keys + private fun JsonObject.filledFields(): Map { + val result = LinkedHashMap() + forEach { (key, value) -> + val text = if (value is JsonPrimitive) value.content else value.toString() + if (text.isNotBlank() && text != "null") result[key] = text + } + return result + } - private fun removedProfileFields( + private fun diffProfile( savedContent: String, incomingContent: String, - ): List { - val saved = parseObject(savedContent)?.filledKeys() ?: return emptyList() - val incoming = parseObject(incomingContent)?.filledKeys() ?: emptySet() - return saved.filter { it !in incoming }.sorted() + removed: MutableList, + added: MutableList, + changed: MutableList, + ) { + val saved = parseObject(savedContent)?.filledFields() ?: return + val incoming = parseObject(incomingContent)?.filledFields() ?: emptyMap() + + saved.keys.sorted().forEach { key -> + val before = BackupEntry(BackupEntryType.PROFILE_FIELD, key, saved[key]) + val newValue = incoming[key] + if (newValue == null) { + removed.add(before) + } else if (newValue != saved[key]) { + changed.add(BackupEntryChange(before, BackupEntry(BackupEntryType.PROFILE_FIELD, key, newValue))) + } + } + incoming.keys.sorted().forEach { key -> + if (key !in saved) added.add(BackupEntry(BackupEntryType.PROFILE_FIELD, key, incoming[key])) + } } } 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 index b6e23cc72f..0a01ca03e1 100644 --- 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 @@ -55,8 +55,11 @@ class ReplaceableBackupDiffTest { fun followListRewrittenFromScratchIsALoss() { val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) val incoming = sign(3, 200, arrayOf(arrayOf("p", carol))) - val loss = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertEquals(listOf(alice, bob), loss.removedTags.map { it[1] }) + val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(BackupEventType.FOLLOW_LIST, diff.eventType) + assertEquals(listOf(alice, bob), diff.removed.map { it.value }) + assertTrue(diff.removed.all { it.type == BackupEntryType.PERSON }) + assertEquals(listOf(carol), diff.added.map { it.value }) } @Test @@ -66,6 +69,42 @@ class ReplaceableBackupDiffTest { assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) } + @Test + fun relayMarkerChangeIsReportedAsChanged() { + val saved = + sign( + 10002, + 100, + arrayOf(arrayOf("r", "wss://a.com"), arrayOf("r", "wss://b.com", "read"), arrayOf("r", "wss://c.com")), + ) + val incoming = sign(10002, 200, arrayOf(arrayOf("r", "wss://a.com", "write"), arrayOf("r", "wss://b.com", "read"))) + val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(BackupEventType.OUTBOX_INBOX_RELAYS, diff.eventType) + assertEquals(listOf("wss://c.com"), diff.removed.map { it.value }) + assertEquals(BackupEntryType.RELAY, diff.removed.single().type) + val change = diff.changed.single() + assertEquals("wss://a.com", change.before.value) + assertNull(change.before.detail) + assertEquals("write", change.after.detail) + } + + @Test + fun muteListEntriesAreTyped() { + val saved = + sign( + 10000, + 100, + arrayOf(arrayOf("p", alice), arrayOf("t", "spam"), arrayOf("word", "crypto"), arrayOf("e", bob)), + ) + val incoming = sign(10000, 200, arrayOf()) + val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(BackupEventType.MUTE_LIST, diff.eventType) + assertEquals( + listOf(BackupEntryType.PERSON, BackupEntryType.HASHTAG, BackupEntryType.WORD, BackupEntryType.THREAD), + diff.removed.map { it.type }, + ) + } + @Test fun bookkeepingTagsDoNotCount() { val saved = sign(10002, 100, arrayOf(arrayOf("r", "wss://a.com"), arrayOf("client", "Amethyst"), arrayOf("alt", "x"))) @@ -84,9 +123,9 @@ class ReplaceableBackupDiffTest { fun clearedPrivateItemsAreALoss() { val saved = sign(10000, 100, arrayOf(arrayOf("p", alice)), "encrypted-private-items") val incoming = sign(10000, 200, arrayOf(arrayOf("p", alice)), "") - val loss = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertTrue(loss.contentCleared) - assertTrue(loss.removedTags.isEmpty()) + val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertTrue(diff.privateItemsCleared) + assertTrue(diff.removed.isEmpty()) } @Test @@ -100,9 +139,42 @@ class ReplaceableBackupDiffTest { fun profileMissingFieldsIsALoss() { val saved = sign(0, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":""}""") val incoming = sign(0, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""") - val loss = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertEquals(listOf("lud16"), loss.removedFields) - assertFalse(loss.contentCleared) + val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(BackupEventType.PROFILE, diff.eventType) + assertEquals(listOf("lud16"), diff.removed.map { it.value }) + assertEquals("v@x.com", diff.removed.single().detail) + assertEquals(listOf("banner"), diff.added.map { it.value }) + val nameChange = diff.changed.single() + assertEquals("vitor", nameChange.before.detail) + assertEquals("vitor2", nameChange.after.detail) + assertFalse(diff.privateItemsCleared) + } + + @Test + fun privateItemsRewrittenButNotClearedAreFlaggedAsChanged() { + val saved = sign(10000, 100, arrayOf(arrayOf("p", alice)), "cipher-a") + val incoming = sign(10000, 200, arrayOf(), "cipher-b") + val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertTrue(diff.privateItemsChanged) + assertFalse(diff.privateItemsCleared) + } + + @Test + fun trustProviderEntriesCarryTheService() { + val saved = sign(10040, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com"))) + val incoming = sign(10040, 200, arrayOf()) + val entry = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)).removed.single() + assertEquals(BackupEntryType.TRUST_PROVIDER, entry.type) + assertEquals(alice, entry.value) + assertEquals("rank", entry.detail) + } + + @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 From 05dcfe064fa0ccc99176bf6ce6a6dd74670086a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 19:30:13 +0000 Subject: [PATCH 04/31] refactor: events compute their own typed diff between versions Move the diff into quartz: Event.diffFrom(older) returns an EventDiff of removed/added/changed DiffEntry values (a sealed vocabulary: Person, Relay with read/write, Hashtag, Word, Geohash, EventRef, AddressRef, ProfileField, RelayGroup, ChatRoom, TrustProvider, Mint, NutzapKey, PaymentTarget, Bolt12Offer, OtherTag) plus a ContentChange for private items. Events own the mapping through diffEntry(tag) and diffContent(older): MetadataEvent diffs its JSON fields, ContactListEvent ignores its relay-map content, and the group, ephemeral chat, trust provider, nutzap info, payment target and BOLT12 offer lists map their own tags. The backup guard and the conflict dialog now consume EventDiff; the dialog renders with an exhaustive when over DiffEntry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 39 +-- .../loggedIn/backups/BackupConflictDialog.kt | 198 ++++++++------- amethyst/src/main/res/values/strings.xml | 4 + .../backups/ReplaceableBackupConflict.kt | 228 +----------------- .../backups/ReplaceableBackupDiffTest.kt | 151 ++---------- .../ephemChat/list/EphemeralChatListEvent.kt | 3 + .../experimental/nipA3/PaymentTargetsEvent.kt | 3 + .../quartz/nip01Core/core/Event.kt | 35 +++ .../quartz/nip01Core/diff/DiffEntries.kt | 50 ++++ .../quartz/nip01Core/diff/DiffEntry.kt | 174 +++++++++++++ .../quartz/nip01Core/diff/EventDiff.kt | 107 ++++++++ .../nip01Core/metadata/MetadataEvent.kt | 21 ++ .../nip02FollowList/ContactListEvent.kt | 5 + .../simpleGroupList/SimpleGroupListEvent.kt | 3 + .../nip61Nutzaps/info/NutzapInfoEvent.kt | 8 + .../list/TrustProviderListEvent.kt | 5 + .../offer/Bolt12OfferListEvent.kt | 3 + .../quartz/nip01Core/diff/EventDiffTest.kt | 183 ++++++++++++++ 18 files changed, 778 insertions(+), 442 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index e11ce3c8c6..536c1261ec 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -16,27 +16,38 @@ only surviving copy of the user's data is gone. 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. **Only question lossy external rewrites.** `AccountSettings.acceptIntoBackup` guards - every `update*` backup method. A newer version that was not signed here is diffed by - `ReplaceableBackupDiff.diff` against the backup into a `BackupDiff`: the - `BackupEventType` (profile, follow list, mute list, each relay list, wallet…) plus - removed / added / changed `BackupEntry`s, each typed (`BackupEntryType`: person, relay, - hashtag, word, thread, profile field, mint, trust provider…) so the UI can label them. - It only counts as a loss when something was removed: - - tags matched by name + value (a new relay hint or petname is an edit, not a loss; - `alt`/`client`/`d`/`expiration` are ignored); - - kind 0: any filled profile field that is now missing or blank; - - kind 3: content ignored (deprecated relay map); - - everything else: content that went from non-blank to blank (private items wiped). +2. **Events diff themselves.** `Event.diffFrom(older)` (quartz, `nip01Core/diff/`) + compares two versions of the same event and returns an `EventDiff`: removed / added / + changed `DiffEntry`s plus a `ContentChange` for content not already expressed as + entries (the NIP-44 private items of lists). `DiffEntry` is a sealed vocabulary + (`Person`, `Relay` with read/write, `Hashtag`, `Word`, `Geohash`, `EventRef`, + `AddressRef`, `ProfileField`, `RelayGroup`, `ChatRoom`, `TrustProvider`, `Mint`, + `NutzapKey`, `PaymentTarget`, `Bolt12Offer`, `OtherTag`), each with a stable identity + `key`: same key in both versions but unequal entries is a change (new relay marker, + petname, edited bio), not a removal plus an addition. + + Each event owns its mapping through two hooks: `diffEntry(tag)` (default handles + `p`/`t`/`word`/`g`/`r`/`relay`/`e`/`a`, skips `alt`/`client`/`d`/`expiration`) and + `diffContent(older)`. Overrides: `MetadataEvent` (JSON fields → `ProfileField`, no + content change), `ContactListEvent` (ignores the deprecated relay-map content), + `SimpleGroupListEvent`, `EphemeralChatListEvent`, `TrustProviderListEvent`, + `NutzapInfoEvent`, `PaymentTargetsEvent`, `Bolt12OfferListEvent`. +3. **Only question lossy external rewrites.** `AccountSettings.acceptIntoBackup` guards + every `update*` backup method. A newer version that was not signed here goes through + `ReplaceableBackupDiff.detectLoss` (commons), which keeps the `EventDiff` only when + `removesData()`: an entry was removed or the private content was cleared. If nothing was dropped, the other app evidently built on the previous version, and the backup is updated silently as before. -3. **Freeze and ask.** On a loss, the backup keeps the saved version and a +4. **Freeze and ask.** On a loss, the backup keeps the saved version and a `ReplaceableBackupConflict` is published on `AccountSettings.backupConflicts`. `BackupConflictDialog` (shown from `LoggedInPage`) is specific to the event: it names it ("Your mute list changed in another app"), says what that event is for, and lists what was removed, added and changed, grouped by entry type (people by display name, relays - with their read/write marker, profile fields old → new…). It offers: + with their read/write marker, profile fields old → new…). Rendering is an exhaustive + `when` over `DiffEntry`, so a new entry type can't be forgotten by the UI; the group + label also depends on the event (an `e` tag is a muted thread in a mute list and a + joined chat in a public chat list). It 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)` and publishes it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt index e50cd4e447..abb9fa263c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt @@ -43,10 +43,6 @@ 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.BackupDiff -import com.vitorpamplona.amethyst.commons.model.backups.BackupEntry -import com.vitorpamplona.amethyst.commons.model.backups.BackupEntryChange -import com.vitorpamplona.amethyst.commons.model.backups.BackupEntryType import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -56,6 +52,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes 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.DiffChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff private const val MAX_ITEMS_PER_GROUP = 6 private const val MAX_VALUE_LENGTH = 80 @@ -99,21 +99,22 @@ private fun BackupConflictDialog( ) { val context = LocalContext.current val diff = conflict.diff + val eventType = conflict.eventType AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(diff.eventType)))) }, + title = { Text(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(eventType)))) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { Text( - text = stringRes(eventTypeExplainer(diff.eventType)), + text = stringRes(eventTypeExplainer(eventType)), style = MaterialTheme.typography.bodySmall, fontStyle = FontStyle.Italic, ) Spacer(Modifier.height(12.dp)) Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.incoming.createdAt, context))) - DiffDetails(diff) + DiffDetails(diff, eventType) Spacer(Modifier.height(12.dp)) Text( @@ -135,38 +136,49 @@ private fun BackupConflictDialog( } @Composable -private fun DiffDetails(diff: BackupDiff) { - if (diff.removed.isNotEmpty() || diff.privateItemsCleared) { +private fun DiffDetails( + diff: EventDiff, + eventType: BackupEventType, +) { + if (diff.removed.isNotEmpty() || diff.content == ContentChange.CLEARED) { SectionHeader( stringRes(R.string.backup_conflict_removed, diff.removed.size.toString()), MaterialTheme.colorScheme.error, ) - if (diff.privateItemsCleared) { - Text("• " + stringRes(R.string.backup_conflict_private_cleared)) + if (diff.content == ContentChange.CLEARED) { + ContentNote(if (diff.contentEncrypted) R.string.backup_conflict_private_cleared else R.string.backup_conflict_content_cleared) } - EntryGroups(diff.removed) + EntryGroups(diff.removed, eventType) } - if (diff.added.isNotEmpty()) { + if (diff.added.isNotEmpty() || diff.content == ContentChange.ADDED) { SectionHeader( stringRes(R.string.backup_conflict_added, diff.added.size.toString()), MaterialTheme.colorScheme.primary, ) - EntryGroups(diff.added) + if (diff.content == ContentChange.ADDED) { + ContentNote(if (diff.contentEncrypted) R.string.backup_conflict_private_added else R.string.backup_conflict_content_added) + } + EntryGroups(diff.added, eventType) } - if (diff.changed.isNotEmpty() || diff.privateItemsChanged) { + if (diff.changed.isNotEmpty() || diff.content == ContentChange.CHANGED) { SectionHeader( stringRes(R.string.backup_conflict_changed, diff.changed.size.toString()), MaterialTheme.colorScheme.tertiary, ) - if (diff.privateItemsChanged) { - Text("• " + stringRes(R.string.backup_conflict_private_changed)) + if (diff.content == ContentChange.CHANGED) { + ContentNote(if (diff.contentEncrypted) R.string.backup_conflict_private_changed else R.string.backup_conflict_content_changed) } - ChangeGroups(diff.changed) + ChangeGroups(diff.changed, eventType) } } +@Composable +private fun ContentNote(textRes: Int) { + Text("• " + stringRes(textRes), modifier = Modifier.padding(start = 8.dp)) +} + @Composable private fun SectionHeader( text: String, @@ -177,9 +189,9 @@ private fun SectionHeader( } @Composable -private fun GroupLabel(type: BackupEntryType) { +private fun GroupLabel(labelRes: Int) { Text( - text = stringRes(entryTypeName(type)), + text = stringRes(labelRes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp), ) @@ -207,61 +219,105 @@ private fun MoreItems(count: Int) { } @Composable -private fun EntryGroups(entries: List) { - entries.groupBy { it.type }.forEach { (type, group) -> - GroupLabel(type) +private fun EntryGroups( + entries: List, + eventType: BackupEventType, +) { + entries.groupBy { groupLabel(it, eventType) }.forEach { (label, group) -> + GroupLabel(label) group.take(MAX_ITEMS_PER_GROUP).forEach { Item(describe(it)) } MoreItems(group.size - MAX_ITEMS_PER_GROUP) } } @Composable -private fun ChangeGroups(changes: List) { - changes.groupBy { it.before.type }.forEach { (type, group) -> - GroupLabel(type) +private fun ChangeGroups( + changes: List, + eventType: BackupEventType, +) { + changes.groupBy { groupLabel(it.before, eventType) }.forEach { (label, group) -> + GroupLabel(label) group.take(MAX_ITEMS_PER_GROUP).forEach { Item(describe(it)) } MoreItems(group.size - MAX_ITEMS_PER_GROUP) } } +/** One line per changed entry: what it is, then its details before → after. */ @Composable -private fun describe(change: BackupEntryChange): String { +private fun describe(change: DiffChange): String { val before = change.before val after = change.after - return when (before.type) { - BackupEntryType.PROFILE_FIELD -> - profileFieldName(before.value) + ": " + clip(before.detail) + " → " + clip(after.detail) - BackupEntryType.RELAY -> - before.value + ": " + relayMarker(before.detail) + " → " + relayMarker(after.detail) - else -> entryName(before) + ": " + clip(before.detail) + " → " + clip(after.detail) + return when { + before is DiffEntry.ProfileField && after is DiffEntry.ProfileField -> + profileFieldName(before.name) + ": " + clip(before.value) + " → " + clip(after.value) + before is DiffEntry.Relay && after is DiffEntry.Relay -> + before.url + ": " + relayMarker(before) + " → " + relayMarker(after) + before is DiffEntry.Person && after is DiffEntry.Person -> + personName(before.pubKey) + ": " + listOfNotNull(before.petName, before.relayHint).joinToString() + + " → " + listOfNotNull(after.petName, after.relayHint).joinToString() + before is DiffEntry.Mint && after is DiffEntry.Mint -> + before.url + ": " + before.units.joinToString() + " → " + after.units.joinToString() + before is DiffEntry.RelayGroup && after is DiffEntry.RelayGroup -> + (before.name ?: before.groupId) + " → " + (after.name ?: after.groupId) + else -> describe(before) + " → " + describe(after) } } +/** One line per entry, resolving people and chats to their names when this device knows them. */ @Composable -private fun describe(entry: BackupEntry): String = - when (entry.type) { - BackupEntryType.PROFILE_FIELD -> profileFieldName(entry.value) + ": " + clip(entry.detail) - BackupEntryType.RELAY -> entry.value + " (" + relayMarker(entry.detail) + ")" - BackupEntryType.TRUST_PROVIDER -> (entry.detail ?: "") + ": " + personName(entry.value) - BackupEntryType.PAYMENT_TARGET, BackupEntryType.OTHER -> - if (entry.detail != null) entry.value + ": " + clip(entry.detail) else clip(entry.value) - BackupEntryType.CHAT_ROOM -> - if (entry.detail != null) entry.value + " @ " + entry.detail else entry.value - else -> entryName(entry) +private fun describe(entry: DiffEntry): String = + when (entry) { + is DiffEntry.ProfileField -> profileFieldName(entry.name) + ": " + clip(entry.value) + is DiffEntry.Person -> personName(entry.pubKey) + (entry.petName?.let { " ($it)" } ?: "") + is DiffEntry.Hashtag -> "#" + entry.hashtag + is DiffEntry.Word -> "\"" + entry.word + "\"" + is DiffEntry.Geohash -> entry.geohash + is DiffEntry.Relay -> entry.url + " (" + relayMarker(entry) + ")" + is DiffEntry.EventRef -> + LocalCache.getPublicChatChannelIfExists(entry.eventId)?.toBestDisplayName() ?: entry.eventId.toShortDisplay() + is DiffEntry.AddressRef -> Address.parse(entry.address)?.dTag?.ifBlank { null } ?: entry.address.toShortDisplay() + is DiffEntry.RelayGroup -> entry.name ?: entry.groupId + is DiffEntry.ChatRoom -> entry.roomId + " @ " + entry.relay + is DiffEntry.TrustProvider -> entry.service.substringAfter(':') + ": " + personName(entry.pubKey) + is DiffEntry.Mint -> entry.url + is DiffEntry.NutzapKey -> entry.pubKey.toShortDisplay() + is DiffEntry.PaymentTarget -> entry.type + ": " + clip(entry.authority) + is DiffEntry.Bolt12Offer -> entry.offer.toShortDisplay() + is DiffEntry.OtherTag -> entry.name + ": " + clip(entry.value) } -private fun entryName(entry: BackupEntry): String = - when (entry.type) { - BackupEntryType.PERSON, BackupEntryType.NUTZAP_KEY -> personName(entry.value) - BackupEntryType.HASHTAG -> "#" + entry.value - BackupEntryType.WORD -> "\"" + entry.value + "\"" - BackupEntryType.PUBLIC_CHAT -> - LocalCache.getPublicChatChannelIfExists(entry.value)?.toBestDisplayName() ?: entry.value.toShortDisplay() - BackupEntryType.COMMUNITY, BackupEntryType.ALGO_FEED, BackupEntryType.ADDRESS -> - Address.parse(entry.value)?.dTag?.ifBlank { null } ?: entry.value.toShortDisplay() - BackupEntryType.RELAY_GROUP -> entry.detail ?: entry.value - BackupEntryType.THREAD, BackupEntryType.EVENT, BackupEntryType.OFFER -> entry.value.toShortDisplay() - else -> clip(entry.value) +/** Which group an entry is listed under. The same tag means different things in different lists. */ +private fun groupLabel( + entry: DiffEntry, + eventType: BackupEventType, +): Int = + when (entry) { + is DiffEntry.ProfileField -> R.string.backup_entry_profile_field + is DiffEntry.Person -> R.string.backup_entry_person + is DiffEntry.Hashtag -> R.string.backup_entry_hashtag + is DiffEntry.Word -> R.string.backup_entry_word + is DiffEntry.Geohash -> R.string.backup_entry_location + is DiffEntry.Relay -> R.string.backup_entry_relay + is DiffEntry.EventRef -> + when (eventType) { + BackupEventType.MUTE_LIST -> R.string.backup_entry_thread + BackupEventType.PUBLIC_CHATS -> R.string.backup_entry_public_chat + else -> R.string.backup_entry_event + } + is DiffEntry.AddressRef -> + when (eventType) { + BackupEventType.COMMUNITIES -> R.string.backup_entry_community + BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_entry_algo_feed + else -> R.string.backup_entry_address + } + is DiffEntry.RelayGroup -> R.string.backup_entry_relay_group + is DiffEntry.ChatRoom -> R.string.backup_entry_chat_room + is DiffEntry.TrustProvider -> R.string.backup_entry_trust_provider + is DiffEntry.Mint -> R.string.backup_entry_mint + is DiffEntry.NutzapKey -> R.string.backup_entry_nutzap_key + is DiffEntry.PaymentTarget -> R.string.backup_entry_payment_target + is DiffEntry.Bolt12Offer -> R.string.backup_entry_offer + is DiffEntry.OtherTag -> R.string.backup_entry_other } private fun personName(pubkey: String): String = LocalCache.getUserIfExists(pubkey)?.toBestDisplayName() ?: pubkey.toShortDisplay() @@ -273,10 +329,10 @@ private fun clip(text: String?): String { } @Composable -private fun relayMarker(marker: String?): String = - when (marker) { - "read" -> stringRes(R.string.backup_conflict_relay_read_only) - "write" -> stringRes(R.string.backup_conflict_relay_write_only) +private fun relayMarker(relay: DiffEntry.Relay): String = + when { + relay.read && !relay.write -> stringRes(R.string.backup_conflict_relay_read_only) + relay.write && !relay.read -> stringRes(R.string.backup_conflict_relay_write_only) else -> stringRes(R.string.backup_conflict_relay_read_write) } @@ -298,30 +354,6 @@ private fun profileFieldName(field: String): String = else -> field } -private fun entryTypeName(type: BackupEntryType): Int = - when (type) { - BackupEntryType.PROFILE_FIELD -> R.string.backup_entry_profile_field - BackupEntryType.PERSON -> R.string.backup_entry_person - BackupEntryType.HASHTAG -> R.string.backup_entry_hashtag - BackupEntryType.WORD -> R.string.backup_entry_word - BackupEntryType.THREAD -> R.string.backup_entry_thread - BackupEntryType.PUBLIC_CHAT -> R.string.backup_entry_public_chat - BackupEntryType.COMMUNITY -> R.string.backup_entry_community - BackupEntryType.ALGO_FEED -> R.string.backup_entry_algo_feed - BackupEntryType.LOCATION -> R.string.backup_entry_location - BackupEntryType.RELAY -> R.string.backup_entry_relay - BackupEntryType.RELAY_GROUP -> R.string.backup_entry_relay_group - BackupEntryType.CHAT_ROOM -> R.string.backup_entry_chat_room - BackupEntryType.TRUST_PROVIDER -> R.string.backup_entry_trust_provider - BackupEntryType.MINT -> R.string.backup_entry_mint - BackupEntryType.NUTZAP_KEY -> R.string.backup_entry_nutzap_key - BackupEntryType.PAYMENT_TARGET -> R.string.backup_entry_payment_target - BackupEntryType.OFFER -> R.string.backup_entry_offer - BackupEntryType.EVENT -> R.string.backup_entry_event - BackupEntryType.ADDRESS -> R.string.backup_entry_address - BackupEntryType.OTHER -> R.string.backup_entry_other - } - private fun eventTypeName(type: BackupEventType): Int = when (type) { BackupEventType.PROFILE -> R.string.backup_type_profile diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 841b68db73..d0c23432ca 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2836,6 +2836,10 @@ Changed (%1$s) All private (encrypted) items were removed. Private (encrypted) items also changed. They can\'t be compared here. + Private (encrypted) items were added. + The content was cleared. + Content was added. + The content changed. …and %1$s more Restoring brings back what was removed and drops what the other app added or changed. Restore saved version 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 index a1b5296fc0..7ea1b850f1 100644 --- 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 @@ -26,6 +26,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.Event +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -48,10 +49,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject /** Which of the account's backed-up events a conflict is about. Drives the dialog's wording. */ enum class BackupEventType { @@ -118,72 +115,6 @@ enum class BackupEventType { } } -/** What a single entry of a backed-up event represents, so the UI can label and render it. */ -enum class BackupEntryType { - PROFILE_FIELD, - PERSON, - HASHTAG, - WORD, - THREAD, - PUBLIC_CHAT, - COMMUNITY, - ALGO_FEED, - LOCATION, - RELAY, - RELAY_GROUP, - CHAT_ROOM, - TRUST_PROVIDER, - MINT, - NUTZAP_KEY, - PAYMENT_TARGET, - OFFER, - EVENT, - ADDRESS, - OTHER, -} - -/** - * One entry of a backed-up event. - * - * @property value the identity of the entry: a pubkey, relay url, hashtag, profile field name… - * @property detail extra context: the relay marker (read/write), a group's name, a profile - * field's value, the service a trust provider serves… - */ -@Immutable -class BackupEntry( - val type: BackupEntryType, - val value: String, - val detail: String?, -) - -/** An entry present in both versions whose details differ (a new relay marker, an edited bio). */ -@Immutable -class BackupEntryChange( - val before: BackupEntry, - val after: BackupEntry, -) - -/** - * Everything that differs between the version of an event saved on this device and a newer - * version from another client. - * - * @property privateItemsCleared the saved version had encrypted private items and the new one has none. - * @property privateItemsChanged the encrypted private items differ but were not cleared; they can't - * be compared without decrypting. - */ -@Immutable -class BackupDiff( - val eventType: BackupEventType, - val removed: List, - val added: List, - val changed: List, - val privateItemsCleared: Boolean, - val privateItemsChanged: Boolean, -) { - /** The new version dropped something the saved one had: the only case worth asking about. */ - fun losesData() = removed.isNotEmpty() || privateItemsCleared -} - /** * 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 @@ -193,12 +124,14 @@ class BackupDiff( class ReplaceableBackupConflict( val saved: Event, val incoming: Event, - val diff: BackupDiff, + val diff: EventDiff, private val acceptIncoming: () -> Unit, ) { /** 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) + fun keepIncoming() = acceptIncoming() } @@ -206,160 +139,17 @@ fun backupSlot(event: Event): String = event.kind.toString() + ":" + (event.tags object ReplaceableBackupDiff { /** - * Tag names that clients rewrite for their own bookkeeping. Dropping them loses nothing - * the user entered, so they are left out of the diff. - */ - private val IGNORED_TAG_NAMES = setOf("alt", "client", "d", "expiration") - - private val json = Json { ignoreUnknownKeys = true } - - /** - * Compares the [saved] backup with an [incoming] replacement of the same slot and reports - * what it removed, added and changed, or null when it removed nothing (it only added or - * edited entries, which means the other app did look at the previous version). + * What a newer [incoming] version of a backed-up event removed from the [saved] one, as + * computed by the event itself ([Event.diffFrom]), or null when it removed nothing: it + * only added or edited entries, which means the other app did read the previous version. */ fun detectLoss( saved: Event, incoming: Event, - ): BackupDiff? = diff(saved, incoming)?.takeIf { it.losesData() } - - /** - * Full diff between [saved] and a newer [incoming] version of the same slot. Null when - * they aren't comparable (different kind/author) or [incoming] isn't newer. - * - * Tags are matched by name + value, so a changed relay hint, marker or petname on the same - * entry is a change, not a removal plus an addition. - */ - fun diff( - saved: Event, - incoming: Event, - ): BackupDiff? { + ): EventDiff? { if (saved.id == incoming.id) return null - if (saved.kind != incoming.kind || saved.pubKey != incoming.pubKey) return null // Older or same-age versions never replace the backup in LocalCache anyway. if (incoming.createdAt <= saved.createdAt) return null - - val eventType = BackupEventType.of(saved.kind) - - val savedTags = keyedTags(saved) - val incomingTags = keyedTags(incoming) - - val removed = mutableListOf() - val added = mutableListOf() - val changed = mutableListOf() - - savedTags.forEach { (key, tag) -> - val other = incomingTags[key] - if (other == null) { - removed.add(entryOf(eventType, tag)) - } else if (!other.contentEquals(tag)) { - val before = entryOf(eventType, tag) - val after = entryOf(eventType, other) - if (before.detail != after.detail) changed.add(BackupEntryChange(before, after)) - } - } - incomingTags.forEach { (key, tag) -> - if (key !in savedTags) added.add(entryOf(eventType, tag)) - } - - var privateItemsCleared = false - var privateItemsChanged = false - - when (eventType) { - BackupEventType.PROFILE -> diffProfile(saved.content, incoming.content, removed, added, changed) - // kind:3 content is a deprecated relay map many clients drop on purpose. - BackupEventType.FOLLOW_LIST -> {} - else -> { - privateItemsCleared = saved.content.isNotBlank() && incoming.content.isBlank() - privateItemsChanged = !privateItemsCleared && saved.content != incoming.content && saved.content.isNotBlank() - } - } - - return BackupDiff(eventType, removed, added, changed, privateItemsCleared, privateItemsChanged) - } - - private fun keyedTags(event: Event): Map> { - val result = LinkedHashMap>(event.tags.size) - event.tags.forEach { tag -> - if (tag.isNotEmpty() && tag[0] !in IGNORED_TAG_NAMES) { - val key = if (tag.size > 1) tag[0] + "\u0000" + tag[1] else tag[0] - result.putIfAbsent(key, tag) - } - } - return result - } - - private fun entryOf( - eventType: BackupEventType, - tag: Array, - ): BackupEntry { - val name = tag[0] - val value = tag.getOrNull(1) ?: "" - return when { - // NIP-85 provider entries are named ":". - name.contains(':') -> BackupEntry(BackupEntryType.TRUST_PROVIDER, value, name.substringAfter(':')) - name == "p" -> BackupEntry(BackupEntryType.PERSON, value, null) - name == "t" -> BackupEntry(BackupEntryType.HASHTAG, value, null) - name == "word" -> BackupEntry(BackupEntryType.WORD, value, null) - name == "g" -> BackupEntry(BackupEntryType.LOCATION, value, null) - // NIP-65 marks read-only / write-only relays; no marker means both. - name == "r" || name == "relay" -> BackupEntry(BackupEntryType.RELAY, value, tag.getOrNull(2)?.ifBlank { null }) - name == "mint" -> BackupEntry(BackupEntryType.MINT, value, null) - name == "pubkey" -> BackupEntry(BackupEntryType.NUTZAP_KEY, value, null) - name == "payto" -> BackupEntry(BackupEntryType.PAYMENT_TARGET, value, tag.getOrNull(2)) - name == "offer" -> BackupEntry(BackupEntryType.OFFER, value, tag.getOrNull(2)) - name == "group" && eventType == BackupEventType.EPHEMERAL_CHATS -> - BackupEntry(BackupEntryType.CHAT_ROOM, value, tag.getOrNull(2)) - // NIP-51 ["group", id, relay, name?] - name == "group" -> BackupEntry(BackupEntryType.RELAY_GROUP, value, tag.getOrNull(3) ?: tag.getOrNull(2)) - name == "e" -> - when (eventType) { - BackupEventType.MUTE_LIST -> BackupEntry(BackupEntryType.THREAD, value, null) - BackupEventType.PUBLIC_CHATS -> BackupEntry(BackupEntryType.PUBLIC_CHAT, value, tag.getOrNull(2)) - else -> BackupEntry(BackupEntryType.EVENT, value, null) - } - name == "a" -> - when (eventType) { - BackupEventType.COMMUNITIES -> BackupEntry(BackupEntryType.COMMUNITY, value, tag.getOrNull(2)) - BackupEventType.FAVORITE_ALGO_FEEDS -> BackupEntry(BackupEntryType.ALGO_FEED, value, tag.getOrNull(2)) - else -> BackupEntry(BackupEntryType.ADDRESS, value, null) - } - else -> BackupEntry(BackupEntryType.OTHER, value, name) - } - } - - private fun parseObject(content: String): JsonObject? = runCatching { json.parseToJsonElement(content).jsonObject }.getOrNull() - - private fun JsonObject.filledFields(): Map { - val result = LinkedHashMap() - forEach { (key, value) -> - val text = if (value is JsonPrimitive) value.content else value.toString() - if (text.isNotBlank() && text != "null") result[key] = text - } - return result - } - - private fun diffProfile( - savedContent: String, - incomingContent: String, - removed: MutableList, - added: MutableList, - changed: MutableList, - ) { - val saved = parseObject(savedContent)?.filledFields() ?: return - val incoming = parseObject(incomingContent)?.filledFields() ?: emptyMap() - - saved.keys.sorted().forEach { key -> - val before = BackupEntry(BackupEntryType.PROFILE_FIELD, key, saved[key]) - val newValue = incoming[key] - if (newValue == null) { - removed.add(before) - } else if (newValue != saved[key]) { - changed.add(BackupEntryChange(before, BackupEntry(BackupEntryType.PROFILE_FIELD, key, newValue))) - } - } - incoming.keys.sorted().forEach { key -> - if (key !in saved) added.add(BackupEntry(BackupEntryType.PROFILE_FIELD, key, incoming[key])) - } + return incoming.diffFrom(saved)?.takeIf { it.removesData() } } } 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 index 0a01ca03e1..a21990e25b 100644 --- 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 @@ -22,6 +22,7 @@ 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.diff.DiffEntry import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import kotlin.test.Test import kotlin.test.assertEquals @@ -30,6 +31,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +/** The backup guard's use of [Event.diffFrom]; the diff itself is tested in quartz. */ class ReplaceableBackupDiffTest { private val signer = NostrSignerSync(KeyPair()) @@ -42,74 +44,28 @@ class ReplaceableBackupDiffTest { private val alice = "a".repeat(64) private val bob = "b".repeat(64) - private val carol = "c".repeat(64) @Test - fun followListThatOnlyAddsIsNotALoss() { + fun lossyRewriteIsDetected() { val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) - val incoming = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob), arrayOf("p", carol))) + val incoming = sign(3, 200, arrayOf(arrayOf("p", bob))) + val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(listOf(DiffEntry.Person(alice)), diff.removed) + assertEquals(BackupEventType.FOLLOW_LIST, BackupEventType.of(diff.kind)) + } + + @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 followListRewrittenFromScratchIsALoss() { - val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) - val incoming = sign(3, 200, arrayOf(arrayOf("p", carol))) - val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertEquals(BackupEventType.FOLLOW_LIST, diff.eventType) - assertEquals(listOf(alice, bob), diff.removed.map { it.value }) - assertTrue(diff.removed.all { it.type == BackupEntryType.PERSON }) - assertEquals(listOf(carol), diff.added.map { it.value }) - } - - @Test - fun changedRelayHintOrPetnameIsAnEditNotALoss() { - val saved = sign(3, 100, arrayOf(arrayOf("p", alice, "wss://a.com", "al"))) - val incoming = sign(3, 200, arrayOf(arrayOf("p", alice, "wss://b.com"))) - assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - } - - @Test - fun relayMarkerChangeIsReportedAsChanged() { - val saved = - sign( - 10002, - 100, - arrayOf(arrayOf("r", "wss://a.com"), arrayOf("r", "wss://b.com", "read"), arrayOf("r", "wss://c.com")), - ) - val incoming = sign(10002, 200, arrayOf(arrayOf("r", "wss://a.com", "write"), arrayOf("r", "wss://b.com", "read"))) - val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertEquals(BackupEventType.OUTBOX_INBOX_RELAYS, diff.eventType) - assertEquals(listOf("wss://c.com"), diff.removed.map { it.value }) - assertEquals(BackupEntryType.RELAY, diff.removed.single().type) - val change = diff.changed.single() - assertEquals("wss://a.com", change.before.value) - assertNull(change.before.detail) - assertEquals("write", change.after.detail) - } - - @Test - fun muteListEntriesAreTyped() { - val saved = - sign( - 10000, - 100, - arrayOf(arrayOf("p", alice), arrayOf("t", "spam"), arrayOf("word", "crypto"), arrayOf("e", bob)), - ) - val incoming = sign(10000, 200, arrayOf()) - val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertEquals(BackupEventType.MUTE_LIST, diff.eventType) - assertEquals( - listOf(BackupEntryType.PERSON, BackupEntryType.HASHTAG, BackupEntryType.WORD, BackupEntryType.THREAD), - diff.removed.map { it.type }, - ) - } - - @Test - fun bookkeepingTagsDoNotCount() { - val saved = sign(10002, 100, arrayOf(arrayOf("r", "wss://a.com"), arrayOf("client", "Amethyst"), arrayOf("alt", "x"))) - val incoming = sign(10002, 200, arrayOf(arrayOf("r", "wss://a.com"))) - assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + fun clearedPrivateItemsAreALoss() { + val saved = sign(10000, 100, arrayOf(), "encrypted") + val incoming = sign(10000, 200, arrayOf(), "") + assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) } @Test @@ -119,71 +75,6 @@ class ReplaceableBackupDiffTest { assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) } - @Test - fun clearedPrivateItemsAreALoss() { - val saved = sign(10000, 100, arrayOf(arrayOf("p", alice)), "encrypted-private-items") - val incoming = sign(10000, 200, arrayOf(arrayOf("p", alice)), "") - val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertTrue(diff.privateItemsCleared) - assertTrue(diff.removed.isEmpty()) - } - - @Test - fun contactListContentIsIgnored() { - val saved = sign(3, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}") - val incoming = sign(3, 200, arrayOf(arrayOf("p", alice)), "") - assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - } - - @Test - fun profileMissingFieldsIsALoss() { - val saved = sign(0, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":""}""") - val incoming = sign(0, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""") - val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertEquals(BackupEventType.PROFILE, diff.eventType) - assertEquals(listOf("lud16"), diff.removed.map { it.value }) - assertEquals("v@x.com", diff.removed.single().detail) - assertEquals(listOf("banner"), diff.added.map { it.value }) - val nameChange = diff.changed.single() - assertEquals("vitor", nameChange.before.detail) - assertEquals("vitor2", nameChange.after.detail) - assertFalse(diff.privateItemsCleared) - } - - @Test - fun privateItemsRewrittenButNotClearedAreFlaggedAsChanged() { - val saved = sign(10000, 100, arrayOf(arrayOf("p", alice)), "cipher-a") - val incoming = sign(10000, 200, arrayOf(), "cipher-b") - val diff = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertTrue(diff.privateItemsChanged) - assertFalse(diff.privateItemsCleared) - } - - @Test - fun trustProviderEntriesCarryTheService() { - val saved = sign(10040, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com"))) - val incoming = sign(10040, 200, arrayOf()) - val entry = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)).removed.single() - assertEquals(BackupEntryType.TRUST_PROVIDER, entry.type) - assertEquals(alice, entry.value) - assertEquals("rank", entry.detail) - } - - @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 profileEditThatKeepsFieldsIsNotALoss() { - val saved = sign(0, 100, arrayOf(), """{"name":"vitor","about":"hi"}""") - val incoming = sign(0, 200, arrayOf(), """{"name":"vitor","about":"hello","website":"https://v"}""") - assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - } - @Test fun locallySignedEventsTrackOnlyReplaceables() { val list = sign(10000, 100, arrayOf()) @@ -193,4 +84,12 @@ class ReplaceableBackupDiffTest { 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)) + } } 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..4b8afcd31a 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address 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.DiffEntry import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -48,6 +49,8 @@ class EphemeralChatListEvent( fun publicRoomSet() = tags.roomSet() + override fun diffEntry(tag: Array): DiffEntry? = RoomIdTag.parse(tag)?.let { DiffEntry.ChatRoom(it.id, it.relayUrl.url) } ?: super.diffEntry(tag) + companion object { const val KIND = 10023 const val FIXED_D_TAG = "" 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..0e56790cc4 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 @@ -23,6 +23,7 @@ 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.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -36,6 +37,8 @@ class PaymentTargetsEvent( ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun paymentTargets(): List = tags.mapNotNull { PaymentTargetTag.parse(it) } + override fun diffEntry(tag: Array): DiffEntry? = PaymentTargetTag.parse(tag)?.let { DiffEntry.PaymentTarget(it.type, it.authority) } ?: super.diffEntry(tag) + companion object { const val KIND = 10133 const val FIXED_D_TAG = "" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt index 9067738525..86957a9eb3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntries +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -64,6 +68,37 @@ open class Event( */ open fun notifies(userHex: HexKey): Boolean = PTag.isNotifying(tags, userHex) + /** + * Turns one of this event's tags into a [DiffEntry] for [diffFrom], or null to leave the + * tag out of diffs. Subclasses override it for tags whose meaning is specific to their + * kind and fall back to `super` for the common ones. + */ + open fun diffEntry(tag: Array): DiffEntry? = DiffEntries.fromTag(tag) + + /** + * Everything this event holds, as [DiffEntry]s. Defaults to its tags; subclasses whose + * content is structured (kind:0 profiles) add the content's fields here too. + */ + open fun diffEntries(): List = tags.mapNotNull { diffEntry(it) } + + /** + * How the content changed since [older], beyond what [diffEntries] already covers. + * Subclasses whose content is already expressed as entries, or is meaningless, return + * [ContentChange.NONE]. + */ + open fun diffContent(older: Event): ContentChange = ContentChange.between(older.content, content) + + /** + * Compares this event with an [older] version of itself (same kind and author) and + * reports what was removed, added and changed, in the typed [DiffEntry] vocabulary so + * it can be shown to the user. Null when [older] isn't a version of the same event kind + * and author. Callers comparing addressables must pass versions of the same address. + */ + fun diffFrom(older: Event): EventDiff? { + if (older.kind != kind || older.pubKey != pubKey) return null + return EventDiff.compute(kind, older.diffEntries(), diffEntries(), diffContent(older), isContentEncoded()) + } + fun toJson(): String = OptimizedJsonMapper.toJson(this) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt new file mode 100644 index 0000000000..99f47158c1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt @@ -0,0 +1,50 @@ +/* + * 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 + +/** The default tag → [DiffEntry] mapping every event starts from. */ +object DiffEntries { + /** + * Tag names clients rewrite for their own bookkeeping. They carry nothing the user + * entered, so they never show up in a diff. + */ + val BOOKKEEPING_TAGS = setOf("alt", "client", "d", "expiration") + + fun fromTag(tag: Array): DiffEntry? { + if (tag.isEmpty() || tag[0] in BOOKKEEPING_TAGS) return null + val value = tag.getOrNull(1) ?: return DiffEntry.OtherTag(tag.toList()) + return when (tag[0]) { + "p" -> DiffEntry.Person(value, tag.getOrNull(2)?.ifBlank { null }, tag.getOrNull(3)?.ifBlank { null }) + "t" -> DiffEntry.Hashtag(value) + "word" -> DiffEntry.Word(value) + "g" -> DiffEntry.Geohash(value) + "r", "relay" -> + when (tag.getOrNull(2)) { + "read" -> DiffEntry.Relay(value, read = true, write = false) + "write" -> DiffEntry.Relay(value, read = false, write = true) + else -> DiffEntry.Relay(value) + } + "e" -> DiffEntry.EventRef(value, tag.getOrNull(2)?.ifBlank { null }) + "a" -> DiffEntry.AddressRef(value, tag.getOrNull(2)?.ifBlank { null }) + else -> DiffEntry.OtherTag(tag.toList()) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt new file mode 100644 index 0000000000..38b7b08a3e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt @@ -0,0 +1,174 @@ +/* + * 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.HexKey + +/** + * One meaningful item of an event, as seen when comparing two versions of it: a followed + * person, a relay, a muted word, a profile field… + * + * Every entry has a [key], its identity inside the event. Two versions of an event are + * compared by key: a key only in the older one was removed, a key only in the newer one was + * added, and a key in both whose entries are not equal was changed (a new relay marker, an + * edited bio, a different relay hint). + * + * Events decide how their own tags and content become entries by overriding + * [com.vitorpamplona.quartz.nip01Core.core.Event.diffEntry] and + * [com.vitorpamplona.quartz.nip01Core.core.Event.diffEntries]. + */ +@Immutable +sealed interface DiffEntry { + val key: String + + /** A kind:0 profile field, e.g. `name`, `about`, `lud16`. */ + data class ProfileField( + val name: String, + val value: String, + ) : DiffEntry { + override val key get() = "field:$name" + } + + /** A `p` tag: a followed, muted or listed person. */ + data class Person( + val pubKey: HexKey, + val relayHint: String? = null, + val petName: String? = null, + ) : DiffEntry { + override val key get() = "p:$pubKey" + } + + /** A `t` tag. */ + data class Hashtag( + val hashtag: String, + ) : DiffEntry { + override val key get() = "t:$hashtag" + } + + /** A `word` tag (NIP-51 mute lists). */ + data class Word( + val word: String, + ) : DiffEntry { + override val key get() = "word:$word" + } + + /** A `g` tag. */ + data class Geohash( + val geohash: String, + ) : DiffEntry { + override val key get() = "g:$geohash" + } + + /** + * An `r` or `relay` tag. NIP-65 relays can be limited to [read] or [write]; a relay + * without a marker is both. + */ + data class Relay( + val url: String, + val read: Boolean = true, + val write: Boolean = true, + ) : DiffEntry { + override val key get() = "relay:$url" + } + + /** An `e` tag: a muted thread, a joined public chat, a bookmarked note… */ + data class EventRef( + val eventId: HexKey, + val relayHint: String? = null, + ) : DiffEntry { + override val key get() = "e:$eventId" + } + + /** An `a` tag: a community, a feed, an addressable item. */ + data class AddressRef( + val address: String, + val relayHint: String? = null, + ) : DiffEntry { + override val key get() = "a:$address" + } + + /** A NIP-29 relay-based group the user joined. */ + data class RelayGroup( + val groupId: String, + val relay: String, + val name: String? = null, + ) : DiffEntry { + override val key get() = "group:$groupId@$relay" + } + + /** An ephemeral chat room the user joined. */ + data class ChatRoom( + val roomId: String, + val relay: String, + ) : DiffEntry { + override val key get() = "room:$roomId@$relay" + } + + /** A NIP-85 trusted service provider: who computes [service] (e.g. `30382:rank`) for the user. */ + data class TrustProvider( + val service: String, + val pubKey: HexKey, + val relay: String, + ) : DiffEntry { + override val key get() = "provider:$service:$pubKey" + } + + /** A NIP-61 mint the user accepts nutzaps from. */ + data class Mint( + val url: String, + val units: List = emptyList(), + ) : DiffEntry { + override val key get() = "mint:$url" + } + + /** The NIP-61 key nutzaps are locked to. */ + data class NutzapKey( + val pubKey: HexKey, + ) : DiffEntry { + override val key get() = "nutzap-key:$pubKey" + } + + /** A NIP-A3 payment target, e.g. `bitcoin` + an address. */ + data class PaymentTarget( + val type: String, + val authority: String, + ) : DiffEntry { + override val key get() = "payto:$type:$authority" + } + + /** A BOLT12 offer. */ + data class Bolt12Offer( + val offer: String, + ) : DiffEntry { + override val key get() = "offer:$offer" + } + + /** Any other tag, kept as-is and identified by its name and first value. */ + data class OtherTag( + val values: List, + ) : DiffEntry { + val name get() = values.firstOrNull() ?: "" + val value get() = values.getOrNull(1) ?: "" + + override val key get() = "tag:$name:$value" + } +} 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..d2f6e3da4d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.diff + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Kind + +/** An entry that exists in both versions, but with different details. */ +@Immutable +data class DiffChange( + val before: DiffEntry, + val after: DiffEntry, +) + +/** + * What happened to the part of the content that is not already represented by [DiffEntry]s. + * For NIP-51 lists that is the NIP-44 encrypted private items, which can only be compared + * as a whole without decrypting. + */ +enum class ContentChange { + NONE, + ADDED, + CHANGED, + CLEARED, + ; + + companion object { + fun between( + older: String, + newer: String, + ): ContentChange = + when { + older == newer -> NONE + older.isBlank() -> ADDED + newer.isBlank() -> CLEARED + else -> CHANGED + } + } +} + +/** + * Everything that differs between two versions of the same event, computed by + * [com.vitorpamplona.quartz.nip01Core.core.Event.diffFrom]. + * + * @property contentEncrypted whether [content] refers to encrypted content (private list + * items) rather than plain text. + */ +@Immutable +class EventDiff( + val kind: Kind, + val removed: List, + val added: List, + val changed: List, + val content: ContentChange, + val contentEncrypted: Boolean, +) { + fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty() && content == ContentChange.NONE + + /** The newer version dropped something the older one had. */ + fun removesData() = removed.isNotEmpty() || content == ContentChange.CLEARED + + companion object { + fun compute( + kind: Kind, + older: List, + newer: List, + content: ContentChange, + contentEncrypted: Boolean, + ): EventDiff { + val olderByKey = LinkedHashMap(older.size) + older.forEach { olderByKey.getOrPut(it.key) { it } } + val newerByKey = LinkedHashMap(newer.size) + newer.forEach { newerByKey.getOrPut(it.key) { it } } + + val removed = mutableListOf() + val changed = mutableListOf() + olderByKey.forEach { (key, before) -> + val after = newerByKey[key] + when { + after == null -> removed.add(before) + after != before -> changed.add(DiffChange(before, after)) + } + } + val added = newerByKey.filterKeys { it !in olderByKey }.values.toList() + + return EventDiff(kind, removed, added, changed, content, contentEncrypted) + } + } +} 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..a74055c502 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,13 @@ 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.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag @@ -104,6 +107,24 @@ class MetadataEvent( override fun indexableSeparator() = " " + /** + * Profile fields live in the JSON content, so they become [DiffEntry.ProfileField]s. + * Tags that only mirror a content field are left out to not count them twice. + */ + override fun diffEntries(): List { + val fields = + contactMetadataJson()?.mapNotNull { (name, value) -> + val text = if (value is JsonPrimitive) value.content else value.toString() + if (text.isBlank() || text == "null") null else DiffEntry.ProfileField(name, text) + } ?: emptyList() + val fieldNames = fields.mapTo(HashSet()) { it.name } + val tagEntries = super.diffEntries().filterNot { it is DiffEntry.OtherTag && it.name in fieldNames } + return fields + tagEntries + } + + /** The content is already fully represented by [diffEntries]. */ + override fun diffContent(older: Event) = ContentChange.NONE + fun contactMetadataJson() = if (content.isBlank()) { // An empty kind-0 is a valid, empty profile — not a parse failure. 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..b1c73bdd2b 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,10 @@ 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.ContentChange import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -49,6 +51,9 @@ class ContactListEvent( override fun linkedPubKeys() = tags.mapNotNull(ContactTag::parseKey) + /** The content is a deprecated relay map that many clients drop on purpose. */ + override fun diffContent(older: Event) = ContentChange.NONE + /** * Returns a list of p-tags that are verified as hex keys. */ 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..24c4b20666 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent @@ -42,6 +43,8 @@ class SimpleGroupListEvent( ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun publicGroups() = tags.mapNotNull(GroupTag::parse) + override fun diffEntry(tag: Array): DiffEntry? = GroupTag.parse(tag)?.let { DiffEntry.RelayGroup(it.groupId, it.relayUrl, it.name) } ?: super.diffEntry(tag) + suspend fun privateGroups(signer: NostrSigner) = privateTags(signer)?.mapNotNull(GroupTag::parse) companion object { 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..7982b1dc87 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 @@ -25,9 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag +import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapPubkeyTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -45,6 +47,12 @@ class NutzapInfoEvent( fun p2pkPubkey() = tags.p2pkPubkey() + override fun diffEntry(tag: Array): DiffEntry? { + NutzapMintTag.parse(tag)?.let { return DiffEntry.Mint(it.mintUrl, it.units) } + NutzapPubkeyTag.parse(tag)?.let { return DiffEntry.NutzapKey(it) } + return super.diffEntry(tag) + } + companion object { const val KIND = 10019 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..62d898b7d4 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 @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address 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.DiffEntry import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -45,6 +46,10 @@ class TrustProviderListEvent( ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun serviceProviders() = tags.serviceProviders() + override fun diffEntry(tag: Array): DiffEntry? = + ServiceProviderTag.parse(tag)?.let { DiffEntry.TrustProvider(it.service.toValue(), it.pubkey, it.relayUrl.url) } + ?: super.diffEntry(tag) + companion object { const val KIND = 10040 const val FIXED_D_TAG = "" 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..3d90ab02f6 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 @@ -24,6 +24,7 @@ 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.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -57,6 +58,8 @@ class Bolt12OfferListEvent( /** The first valid offer, or null when the list carries none. */ fun firstOffer(): String? = tags.firstNotNullOfOrNull(OfferTag::parse) + override fun diffEntry(tag: Array): DiffEntry? = OfferTag.parse(tag)?.let { DiffEntry.Bolt12Offer(it) } ?: super.diffEntry(tag) + companion object { const val KIND = 10058 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..15c02b65c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -0,0 +1,183 @@ +/* + * 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.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +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 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) + + @Test + fun followListReportsRemovedAndAddedPeople() { + val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) + val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice), arrayOf("p", carol))) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf(DiffEntry.Person(bob)), diff.removed) + assertEquals(listOf(DiffEntry.Person(carol)), diff.added) + assertTrue(diff.removesData()) + } + + @Test + fun onlyAddingIsNotARemoval() { + val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice))) + val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun newRelayHintOrPetnameIsAChange() { + val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice, "wss://a.com", "al"))) + val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice, "wss://b.com"))) + val diff = assertNotNull(newer.diffFrom(older)) + assertTrue(diff.removed.isEmpty()) + assertEquals(DiffChange(DiffEntry.Person(alice, "wss://a.com", "al"), DiffEntry.Person(alice, "wss://b.com")), diff.changed.single()) + } + + @Test + fun contactListContentIsIgnored() { + val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}") + val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice)), "") + assertTrue(assertNotNull(newer.diffFrom(older)).isEmpty()) + } + + @Test + fun bookkeepingTagsAreIgnored() { + val older = sign(AdvertisedRelayListEvent.KIND, 100, arrayOf(arrayOf("r", "wss://a.com"), arrayOf("client", "Amethyst"), arrayOf("alt", "x"))) + val newer = sign(AdvertisedRelayListEvent.KIND, 200, arrayOf(arrayOf("r", "wss://a.com"))) + assertTrue(assertNotNull(newer.diffFrom(older)).isEmpty()) + } + + @Test + fun relayMarkersAreTyped() { + 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"))) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf(DiffEntry.Relay("wss://c.com")), diff.removed) + assertEquals(DiffChange(DiffEntry.Relay("wss://a.com"), DiffEntry.Relay("wss://a.com", read = false, write = true)), diff.changed.single()) + } + + @Test + fun muteListEntriesAndPrivateItems() { + 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(), "") + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals( + listOf(DiffEntry.Person(alice), DiffEntry.Hashtag("spam"), DiffEntry.Word("crypto"), DiffEntry.EventRef(bob)), + diff.removed, + ) + assertEquals(ContentChange.CLEARED, diff.content) + assertTrue(diff.contentEncrypted) + } + + @Test + fun privateItemsRewrittenAreChangedNotCleared() { + 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.content) + assertFalse(diff.removesData()) + } + + @Test + fun profileFieldsComeFromTheContent() { + val older = sign(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":""}""") + val newer = sign(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""") + assertIs(newer) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf(DiffEntry.ProfileField("lud16", "v@x.com")), diff.removed) + assertEquals(listOf(DiffEntry.ProfileField("banner", "https://b")), diff.added) + assertEquals(DiffChange(DiffEntry.ProfileField("name", "vitor"), DiffEntry.ProfileField("name", "vitor2")), diff.changed.single()) + assertEquals(ContentChange.NONE, diff.content) + } + + @Test + fun groupsAreTyped() { + val older = sign(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("group", "abc", "wss://groups.com", "Friends"))) + val newer = sign(SimpleGroupListEvent.KIND, 200, arrayOf()) + assertEquals(listOf(DiffEntry.RelayGroup("abc", "wss://groups.com", "Friends")), assertNotNull(newer.diffFrom(older)).removed) + } + + @Test + fun trustProvidersAreTyped() { + val older = sign(TrustProviderListEvent.KIND, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com"))) + val newer = sign(TrustProviderListEvent.KIND, 200, arrayOf()) + val entry = assertNotNull(newer.diffFrom(older)).removed.single() + assertIs(entry) + assertEquals("30382:rank", entry.service) + assertEquals(alice, entry.pubKey) + } + + @Test + fun nutzapMintsAndKeyAreTyped() { + val older = sign(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice))) + val newer = sign(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("pubkey", bob))) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf(DiffEntry.Mint("https://mint.com", listOf("sat")), DiffEntry.NutzapKey(alice)), diff.removed) + assertEquals(listOf(DiffEntry.NutzapKey(bob)), diff.added) + } + + @Test + fun differentKindsOrAuthorsAreNotComparable() { + val a = sign(ContactListEvent.KIND, 100, arrayOf()) + val b = sign(MuteListEvent.KIND, 200, arrayOf()) + val c = NostrSignerSync(KeyPair()).sign(300, ContactListEvent.KIND, arrayOf(), "") + assertNull(b.diffFrom(a)) + assertNull(c.diffFrom(a)) + } +} From 93fce2d2e8bb5cc78e0787fc848c0c4e0a6d5e2d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 19:53:09 +0000 Subject: [PATCH 05/31] refactor: each event defines its own typed diff class Replace the generic DiffEntry vocabulary: every backed-up event implements DiffableEvent and returns its own diff built from its own parsed objects (ContactListDiff of ContactTag, MuteListDiff of MuteTag, AdvertisedRelayListDiff of AdvertisedRelayInfo, NutzapInfoDiff of NutzapMintTag + P2PK key, MetadataDiff with one ValueChange per UserMetadata field, SimpleGroupListDiff of GroupTag, TrustProviderListDiff of ServiceProviderTag, ...). nip01Core/diff now only holds the ListDiff/ValueChange/ContentChange containers. The conflict dialog maps each diff class to its display groups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 40 +- .../loggedIn/backups/BackupConflictDialog.kt | 427 ++++++++++-------- amethyst/src/main/res/values/strings.xml | 7 +- .../backups/ReplaceableBackupConflict.kt | 9 +- .../backups/ReplaceableBackupDiffTest.kt | 10 +- .../ConcordCommunityListDiff.kt | 35 ++ .../ConcordCommunityListEvent.kt | 11 +- .../ephemChat/list/EphemeralChatListDiff.kt | 38 ++ .../ephemChat/list/EphemeralChatListEvent.kt | 17 +- .../experimental/nipA3/PaymentTargetsDiff.kt | 35 ++ .../experimental/nipA3/PaymentTargetsEvent.kt | 14 +- .../KeyPackageRelayListEvent.kt | 13 +- .../quartz/nip01Core/core/Event.kt | 35 -- .../quartz/nip01Core/diff/DiffEntries.kt | 50 -- .../quartz/nip01Core/diff/DiffEntry.kt | 174 ------- .../quartz/nip01Core/diff/EventDiff.kt | 160 ++++--- .../quartz/nip01Core/metadata/MetadataDiff.kt | 62 +++ .../nip01Core/metadata/MetadataEvent.kt | 70 ++- .../quartz/nip02FollowList/ContactListDiff.kt | 36 ++ .../nip02FollowList/ContactListEvent.kt | 14 +- .../settings/ChatMessageRelayListEvent.kt | 13 +- .../nip28PublicChat/list/ChannelListDiff.kt | 38 ++ .../nip28PublicChat/list/ChannelListEvent.kt | 12 + .../PrivateOutboxRelayListEvent.kt | 12 +- .../nip50Search/SearchRelayListEvent.kt | 12 +- .../quartz/nip51Lists/PrivateTagArrayEvent.kt | 7 + .../FavoriteAlgoFeedsListDiff.kt | 38 ++ .../FavoriteAlgoFeedsListEvent.kt | 14 +- .../nip51Lists/geohashList/GeohashListDiff.kt | 37 ++ .../geohashList/GeohashListEvent.kt | 14 +- .../nip51Lists/hashtagList/HashtagListDiff.kt | 37 ++ .../hashtagList/HashtagListEvent.kt | 14 +- .../nip51Lists/muteList/MuteListDiff.kt | 38 ++ .../nip51Lists/muteList/MuteListEvent.kt | 12 + .../relayLists/BlockedRelayListEvent.kt | 11 +- .../relayLists/IndexerRelayListEvent.kt | 11 +- .../relayLists/RelayFeedsListEvent.kt | 11 +- .../nip51Lists/relayLists/RelayListDiff.kt | 38 ++ .../relayLists/TrustedRelayListEvent.kt | 11 +- .../simpleGroupList/SimpleGroupListDiff.kt | 37 ++ .../simpleGroupList/SimpleGroupListEvent.kt | 17 +- .../nip60Cashu/wallet/CashuWalletDiff.kt | 35 ++ .../nip60Cashu/wallet/CashuWalletEvent.kt | 11 +- .../nip61Nutzaps/info/NutzapInfoDiff.kt | 41 ++ .../nip61Nutzaps/info/NutzapInfoEvent.kt | 24 +- .../nip65RelayList/AdvertisedRelayListDiff.kt | 36 ++ .../AdvertisedRelayListEvent.kt | 11 +- .../follow/CommunityListDiff.kt | 38 ++ .../follow/CommunityListEvent.kt | 12 + .../nip78AppData/AppSpecificDataDiff.kt | 35 ++ .../nip78AppData/AppSpecificDataEvent.kt | 11 +- .../list/TrustProviderListDiff.kt | 38 ++ .../list/TrustProviderListEvent.kt | 19 +- .../offer/Bolt12OfferListDiff.kt | 35 ++ .../offer/Bolt12OfferListEvent.kt | 14 +- .../quartz/nip01Core/diff/EventDiffTest.kt | 220 +++++---- 56 files changed, 1574 insertions(+), 697 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 536c1261ec..06f747d4bb 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -16,26 +16,25 @@ only surviving copy of the user's data is gone. 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.** `Event.diffFrom(older)` (quartz, `nip01Core/diff/`) - compares two versions of the same event and returns an `EventDiff`: removed / added / - changed `DiffEntry`s plus a `ContentChange` for content not already expressed as - entries (the NIP-44 private items of lists). `DiffEntry` is a sealed vocabulary - (`Person`, `Relay` with read/write, `Hashtag`, `Word`, `Geohash`, `EventRef`, - `AddressRef`, `ProfileField`, `RelayGroup`, `ChatRoom`, `TrustProvider`, `Mint`, - `NutzapKey`, `PaymentTarget`, `Bolt12Offer`, `OtherTag`), each with a stable identity - `key`: same key in both versions but unequal entries is a change (new relay marker, - petname, edited bio), not a removal plus an addition. +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. - Each event owns its mapping through two hooks: `diffEntry(tag)` (default handles - `p`/`t`/`word`/`g`/`r`/`relay`/`e`/`a`, skips `alt`/`client`/`d`/`expiration`) and - `diffContent(older)`. Overrides: `MetadataEvent` (JSON fields → `ProfileField`, no - content change), `ContactListEvent` (ignores the deprecated relay-map content), - `SimpleGroupListEvent`, `EphemeralChatListEvent`, `TrustProviderListEvent`, - `NutzapInfoEvent`, `PaymentTargetsEvent`, `Bolt12OfferListEvent`. + `nip01Core/diff/` only holds the containers: `EventDiff` (`removesData()`, `isEmpty()`), + `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.** `AccountSettings.acceptIntoBackup` guards every `update*` backup method. A newer version that was not signed here goes through - `ReplaceableBackupDiff.detectLoss` (commons), which keeps the `EventDiff` only when - `removesData()`: an entry was removed or the private content was cleared. + `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. @@ -44,10 +43,9 @@ only surviving copy of the user's data is gone. `BackupConflictDialog` (shown from `LoggedInPage`) is specific to the event: it names it ("Your mute list changed in another app"), says what that event is for, and lists what was removed, added and changed, grouped by entry type (people by display name, relays - with their read/write marker, profile fields old → new…). Rendering is an exhaustive - `when` over `DiffEntry`, so a new entry type can't be forgotten by the UI; the group - label also depends on the event (an `e` tag is a muted thread in a mute list and a - joined chat in a public chat list). It offers: + with their read/write marker, profile fields old → new…). The dialog maps each diff + class to labelled groups from its typed objects (`when (diff)`), resolving pubkeys and + channel ids to names from `LocalCache`. It 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)` and publishes it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt index abb9fa263c..3b461f548f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt @@ -50,12 +50,38 @@ import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.ui.note.timeAbsolute import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.Address +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.core.HexKey import com.vitorpamplona.quartz.nip01Core.diff.ContentChange -import com.vitorpamplona.quartz.nip01Core.diff.DiffChange -import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry 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.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_ITEMS_PER_GROUP = 6 private const val MAX_VALUE_LENGTH = 80 @@ -98,7 +124,6 @@ private fun BackupConflictDialog( onDismiss: () -> Unit, ) { val context = LocalContext.current - val diff = conflict.diff val eventType = conflict.eventType AlertDialog( @@ -114,7 +139,7 @@ private fun BackupConflictDialog( Spacer(Modifier.height(12.dp)) Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.incoming.createdAt, context))) - DiffDetails(diff, eventType) + DiffDetails(conflict.diff) Spacer(Modifier.height(12.dp)) Text( @@ -135,42 +160,209 @@ private fun BackupConflictDialog( ) } +/** + * One labelled group of differences (people, relays, profile fields…), already turned into + * display lines. Built per event type from that event's own diff class. + */ +private class DiffGroup( + val label: Int, + val removed: List, + val added: List, + val changed: List, +) + +/** A group from a [ListDiff] of an event's parsed items. */ +private fun listGroup( + label: Int, + diff: ListDiff, + describe: (T) -> String, + describeChange: (ItemChange) -> String = { describe(it.before) + " → " + describe(it.after) }, +) = DiffGroup(label, diff.removed.map(describe), diff.added.map(describe), diff.changed.map(describeChange)) + +/** 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(name + ": " + clip(before)) + before == null && after != null -> added.add(name + ": " + clip(after)) + else -> changed.add(name + ": " + clip(before) + " → " + clip(after)) + } + } + return DiffGroup(label, removed, added, changed) +} + +/** Turns each event's own diff into display groups, plus the change to its encrypted content. */ @Composable -private fun DiffDetails( - diff: EventDiff, - eventType: BackupEventType, -) { - if (diff.removed.isNotEmpty() || diff.content == ContentChange.CLEARED) { - SectionHeader( - stringRes(R.string.backup_conflict_removed, diff.removed.size.toString()), - MaterialTheme.colorScheme.error, - ) - if (diff.content == ContentChange.CLEARED) { - ContentNote(if (diff.contentEncrypted) R.string.backup_conflict_private_cleared else R.string.backup_conflict_content_cleared) +private fun presentation(diff: EventDiff): Pair, ContentChange> = + 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( + stringRes(R.string.backup_profile_field_name) to diff.name, + stringRes(R.string.backup_profile_field_display_name) to diff.displayName, + stringRes(R.string.backup_profile_field_about) to diff.about, + stringRes(R.string.backup_profile_field_picture) to diff.picture, + stringRes(R.string.backup_profile_field_banner) to diff.banner, + stringRes(R.string.backup_profile_field_website) to diff.website, + stringRes(R.string.backup_profile_field_nip05) to diff.nip05, + stringRes(R.string.backup_profile_field_lud16) to diff.lud16, + stringRes(R.string.backup_profile_field_lud06) to diff.lud06, + stringRes(R.string.backup_profile_field_clink_offer) to diff.clinkOffer, + stringRes(R.string.backup_profile_field_pronouns) to diff.pronouns, + stringRes(R.string.backup_profile_field_birthday) to birthday, + stringRes(R.string.backup_profile_field_bot) to bot, + ), + ), + listGroup(R.string.backup_entry_identity_claim, diff.identityClaims, { it.platformIdentity() }), + listGroup(R.string.backup_entry_other, diff.otherFields, { it.first + ": " + clip(it.second) }), + ) to ContentChange.NONE } - EntryGroups(diff.removed, eventType) + is ContactListDiff -> + listOf( + listGroup( + R.string.backup_entry_person, + diff.follows, + { personName(it.pubKey) + (it.petname?.let { name -> " ($name)" } ?: "") }, + { + personName(it.before.pubKey) + ": " + + listOfNotNull(it.before.petname, it.before.relayUri?.url).joinToString() + " → " + + listOfNotNull(it.after.petname, it.after.relayUri?.url).joinToString() + }, + ), + ) to ContentChange.NONE + is MuteListDiff -> + listOf( + listGroup(R.string.backup_entry_person, diff.publicMutes.only(), { personName(it.pubKey) }), + listGroup(R.string.backup_entry_word, diff.publicMutes.only(), { "\"" + it.word + "\"" }), + listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only(), { "#" + it.hashtag }), + listGroup(R.string.backup_entry_thread, diff.publicMutes.only(), { it.eventId.toShortDisplay() }), + ) to diff.privateItems + is AdvertisedRelayListDiff -> { + val types = + mapOf( + AdvertisedRelayType.BOTH to stringRes(R.string.backup_conflict_relay_read_write), + AdvertisedRelayType.READ to stringRes(R.string.backup_conflict_relay_read_only), + AdvertisedRelayType.WRITE to stringRes(R.string.backup_conflict_relay_write_only), + ) + listOf( + listGroup( + R.string.backup_entry_relay, + diff.relays, + { it.relayUrl.url + " (" + types[it.type] + ")" }, + { it.before.relayUrl.url + ": " + types[it.before.type] + " → " + types[it.after.type] }, + ), + ) to ContentChange.NONE + } + is RelayListDiff -> listOf(listGroup(R.string.backup_entry_relay, diff.relays, { it.url })) to diff.privateRelays + is ChannelListDiff -> + listOf( + listGroup(R.string.backup_entry_public_chat, diff.channels, { channelName(it.eventId) }), + ) to diff.privateItems + is CommunityListDiff -> + listOf( + listGroup(R.string.backup_entry_community, diff.communities, { it.address.dTag.ifBlank { it.address.toValue() } }), + ) to diff.privateItems + is HashtagListDiff -> listOf(listGroup(R.string.backup_entry_hashtag, diff.hashtags, { "#$it" })) to diff.privateItems + is GeohashListDiff -> listOf(listGroup(R.string.backup_entry_location, diff.geohashes, { it })) to diff.privateItems + is FavoriteAlgoFeedsListDiff -> + listOf( + listGroup(R.string.backup_entry_algo_feed, diff.feeds, { it.address.dTag.ifBlank { it.address.toValue() } }), + ) to diff.privateItems + is EphemeralChatListDiff -> + listOf( + listGroup(R.string.backup_entry_chat_room, diff.rooms, { it.id + " @ " + it.relayUrl.url }), + ) to diff.privateItems + is SimpleGroupListDiff -> + listOf( + listGroup( + R.string.backup_entry_relay_group, + diff.groups, + { (it.name ?: it.groupId) + " @ " + it.relayUrl }, + { (it.before.name ?: it.before.groupId) + " → " + (it.after.name ?: it.after.groupId) }, + ), + ) to diff.privateItems + is TrustProviderListDiff -> + listOf( + listGroup( + R.string.backup_entry_trust_provider, + diff.providers, + { it.service.type + ": " + personName(it.pubkey) }, + { it.before.service.type + ": " + it.before.relayUrl.url + " → " + it.after.relayUrl.url }, + ), + ) to diff.privateItems + is NutzapInfoDiff -> + listOf( + listGroup( + R.string.backup_entry_mint, + diff.mints, + { it.mintUrl + (if (it.units.isEmpty()) "" else " (" + it.units.joinToString() + ")") }, + { it.before.mintUrl + ": " + it.before.units.joinToString() + " → " + it.after.units.joinToString() }, + ), + listGroup(R.string.backup_entry_relay, diff.relays, { it.url }), + fieldGroup( + R.string.backup_entry_nutzap_key, + listOf( + stringRes(R.string.backup_entry_nutzap_key) to + diff.p2pkPubkey?.let { ValueChange(it.before?.toShortDisplay(), it.after?.toShortDisplay()) }, + ), + ), + ) to ContentChange.NONE + is PaymentTargetsDiff -> + listOf( + listGroup(R.string.backup_entry_payment_target, diff.targets, { it.type + ": " + clip(it.authority) }), + ) to ContentChange.NONE + is Bolt12OfferListDiff -> listOf(listGroup(R.string.backup_entry_offer, diff.offers, { it.toShortDisplay() })) 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 } - if (diff.added.isNotEmpty() || diff.content == ContentChange.ADDED) { - SectionHeader( - stringRes(R.string.backup_conflict_added, diff.added.size.toString()), - MaterialTheme.colorScheme.primary, - ) - if (diff.content == ContentChange.ADDED) { - ContentNote(if (diff.contentEncrypted) R.string.backup_conflict_private_added else R.string.backup_conflict_content_added) - } - EntryGroups(diff.added, eventType) +@Composable +private fun DiffDetails(diff: EventDiff) { + val (allGroups, content) = presentation(diff) + val groups = allGroups.filter { it.removed.isNotEmpty() || it.added.isNotEmpty() || it.changed.isNotEmpty() } + + val removed = groups.sumOf { it.removed.size } + if (removed > 0 || content == ContentChange.CLEARED) { + SectionHeader(stringRes(R.string.backup_conflict_removed, removed.toString()), MaterialTheme.colorScheme.error) + if (content == ContentChange.CLEARED) ContentNote(R.string.backup_conflict_private_cleared) + groups.forEach { ItemGroup(it.label, it.removed) } } - if (diff.changed.isNotEmpty() || diff.content == ContentChange.CHANGED) { - SectionHeader( - stringRes(R.string.backup_conflict_changed, diff.changed.size.toString()), - MaterialTheme.colorScheme.tertiary, - ) - if (diff.content == ContentChange.CHANGED) { - ContentNote(if (diff.contentEncrypted) R.string.backup_conflict_private_changed else R.string.backup_conflict_content_changed) - } - ChangeGroups(diff.changed, eventType) + val added = groups.sumOf { it.added.size } + if (added > 0 || content == ContentChange.ADDED) { + SectionHeader(stringRes(R.string.backup_conflict_added, added.toString()), MaterialTheme.colorScheme.primary) + if (content == ContentChange.ADDED) ContentNote(R.string.backup_conflict_private_added) + groups.forEach { ItemGroup(it.label, it.added) } + } + + val changed = groups.sumOf { it.changed.size } + if (changed > 0 || content == ContentChange.CHANGED) { + SectionHeader(stringRes(R.string.backup_conflict_changed, changed.toString()), MaterialTheme.colorScheme.tertiary) + if (content == ContentChange.CHANGED) ContentNote(R.string.backup_conflict_private_changed) + groups.forEach { ItemGroup(it.label, it.changed) } } } @@ -189,171 +381,46 @@ private fun SectionHeader( } @Composable -private fun GroupLabel(labelRes: Int) { +private fun ItemGroup( + labelRes: Int, + items: List, +) { + if (items.isEmpty()) return Text( text = stringRes(labelRes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp), ) -} - -@Composable -private fun Item(text: String) { - Text( - text = "• $text", - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 8.dp), - ) -} - -@Composable -private fun MoreItems(count: Int) { - if (count > 0) { + items.take(MAX_ITEMS_PER_GROUP).forEach { Text( - text = stringRes(R.string.backup_conflict_more_items, count.toString()), + text = "• $it", + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp), + ) + } + val more = items.size - MAX_ITEMS_PER_GROUP + if (more > 0) { + Text( + text = stringRes(R.string.backup_conflict_more_items, more.toString()), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 8.dp), ) } } -@Composable -private fun EntryGroups( - entries: List, - eventType: BackupEventType, -) { - entries.groupBy { groupLabel(it, eventType) }.forEach { (label, group) -> - GroupLabel(label) - group.take(MAX_ITEMS_PER_GROUP).forEach { Item(describe(it)) } - MoreItems(group.size - MAX_ITEMS_PER_GROUP) - } -} - -@Composable -private fun ChangeGroups( - changes: List, - eventType: BackupEventType, -) { - changes.groupBy { groupLabel(it.before, eventType) }.forEach { (label, group) -> - GroupLabel(label) - group.take(MAX_ITEMS_PER_GROUP).forEach { Item(describe(it)) } - MoreItems(group.size - MAX_ITEMS_PER_GROUP) - } -} - -/** One line per changed entry: what it is, then its details before → after. */ -@Composable -private fun describe(change: DiffChange): String { - val before = change.before - val after = change.after - return when { - before is DiffEntry.ProfileField && after is DiffEntry.ProfileField -> - profileFieldName(before.name) + ": " + clip(before.value) + " → " + clip(after.value) - before is DiffEntry.Relay && after is DiffEntry.Relay -> - before.url + ": " + relayMarker(before) + " → " + relayMarker(after) - before is DiffEntry.Person && after is DiffEntry.Person -> - personName(before.pubKey) + ": " + listOfNotNull(before.petName, before.relayHint).joinToString() + - " → " + listOfNotNull(after.petName, after.relayHint).joinToString() - before is DiffEntry.Mint && after is DiffEntry.Mint -> - before.url + ": " + before.units.joinToString() + " → " + after.units.joinToString() - before is DiffEntry.RelayGroup && after is DiffEntry.RelayGroup -> - (before.name ?: before.groupId) + " → " + (after.name ?: after.groupId) - else -> describe(before) + " → " + describe(after) - } -} - -/** One line per entry, resolving people and chats to their names when this device knows them. */ -@Composable -private fun describe(entry: DiffEntry): String = - when (entry) { - is DiffEntry.ProfileField -> profileFieldName(entry.name) + ": " + clip(entry.value) - is DiffEntry.Person -> personName(entry.pubKey) + (entry.petName?.let { " ($it)" } ?: "") - is DiffEntry.Hashtag -> "#" + entry.hashtag - is DiffEntry.Word -> "\"" + entry.word + "\"" - is DiffEntry.Geohash -> entry.geohash - is DiffEntry.Relay -> entry.url + " (" + relayMarker(entry) + ")" - is DiffEntry.EventRef -> - LocalCache.getPublicChatChannelIfExists(entry.eventId)?.toBestDisplayName() ?: entry.eventId.toShortDisplay() - is DiffEntry.AddressRef -> Address.parse(entry.address)?.dTag?.ifBlank { null } ?: entry.address.toShortDisplay() - is DiffEntry.RelayGroup -> entry.name ?: entry.groupId - is DiffEntry.ChatRoom -> entry.roomId + " @ " + entry.relay - is DiffEntry.TrustProvider -> entry.service.substringAfter(':') + ": " + personName(entry.pubKey) - is DiffEntry.Mint -> entry.url - is DiffEntry.NutzapKey -> entry.pubKey.toShortDisplay() - is DiffEntry.PaymentTarget -> entry.type + ": " + clip(entry.authority) - is DiffEntry.Bolt12Offer -> entry.offer.toShortDisplay() - is DiffEntry.OtherTag -> entry.name + ": " + clip(entry.value) - } - -/** Which group an entry is listed under. The same tag means different things in different lists. */ -private fun groupLabel( - entry: DiffEntry, - eventType: BackupEventType, -): Int = - when (entry) { - is DiffEntry.ProfileField -> R.string.backup_entry_profile_field - is DiffEntry.Person -> R.string.backup_entry_person - is DiffEntry.Hashtag -> R.string.backup_entry_hashtag - is DiffEntry.Word -> R.string.backup_entry_word - is DiffEntry.Geohash -> R.string.backup_entry_location - is DiffEntry.Relay -> R.string.backup_entry_relay - is DiffEntry.EventRef -> - when (eventType) { - BackupEventType.MUTE_LIST -> R.string.backup_entry_thread - BackupEventType.PUBLIC_CHATS -> R.string.backup_entry_public_chat - else -> R.string.backup_entry_event - } - is DiffEntry.AddressRef -> - when (eventType) { - BackupEventType.COMMUNITIES -> R.string.backup_entry_community - BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_entry_algo_feed - else -> R.string.backup_entry_address - } - is DiffEntry.RelayGroup -> R.string.backup_entry_relay_group - is DiffEntry.ChatRoom -> R.string.backup_entry_chat_room - is DiffEntry.TrustProvider -> R.string.backup_entry_trust_provider - is DiffEntry.Mint -> R.string.backup_entry_mint - is DiffEntry.NutzapKey -> R.string.backup_entry_nutzap_key - is DiffEntry.PaymentTarget -> R.string.backup_entry_payment_target - is DiffEntry.Bolt12Offer -> R.string.backup_entry_offer - is DiffEntry.OtherTag -> R.string.backup_entry_other - } - private fun personName(pubkey: String): String = LocalCache.getUserIfExists(pubkey)?.toBestDisplayName() ?: pubkey.toShortDisplay() +private fun channelName(eventId: String): String = LocalCache.getPublicChatChannelIfExists(eventId)?.toBestDisplayName() ?: eventId.toShortDisplay() + +private fun birthdayText(birthday: Birthday): String = listOfNotNull(birthday.year, birthday.month, birthday.day).joinToString("-") + private 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 } -@Composable -private fun relayMarker(relay: DiffEntry.Relay): String = - when { - relay.read && !relay.write -> stringRes(R.string.backup_conflict_relay_read_only) - relay.write && !relay.read -> stringRes(R.string.backup_conflict_relay_write_only) - else -> stringRes(R.string.backup_conflict_relay_read_write) - } - -@Composable -private fun profileFieldName(field: String): String = - when (field) { - "name" -> stringRes(R.string.backup_profile_field_name) - "display_name", "displayName" -> stringRes(R.string.backup_profile_field_display_name) - "about" -> stringRes(R.string.backup_profile_field_about) - "picture", "image" -> stringRes(R.string.backup_profile_field_picture) - "banner" -> stringRes(R.string.backup_profile_field_banner) - "website" -> stringRes(R.string.backup_profile_field_website) - "nip05" -> stringRes(R.string.backup_profile_field_nip05) - "lud16" -> stringRes(R.string.backup_profile_field_lud16) - "lud06" -> stringRes(R.string.backup_profile_field_lud06) - "pronouns" -> stringRes(R.string.backup_profile_field_pronouns) - "birthday" -> stringRes(R.string.backup_profile_field_birthday) - "bot" -> stringRes(R.string.backup_profile_field_bot) - else -> field - } - private fun eventTypeName(type: BackupEventType): Int = when (type) { BackupEventType.PROFILE -> R.string.backup_type_profile diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d0c23432ca..b6a25ef8f3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2837,9 +2837,6 @@ All private (encrypted) items were removed. Private (encrypted) items also changed. They can\'t be compared here. Private (encrypted) items were added. - The content was cleared. - Content was added. - The content changed. …and %1$s more Restoring brings back what was removed and drops what the other app added or changed. Restore saved version @@ -2919,8 +2916,7 @@ Nutzap keys Payment addresses Offers - Events - Items + Linked identities Other entries Username Display name @@ -2933,5 +2929,6 @@ LNURL Pronouns Birthday + CLINK offer Bot flag 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 index 7ea1b850f1..0da6dd7cf6 100644 --- 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 @@ -26,6 +26,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.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.nip02FollowList.ContactListEvent @@ -140,8 +141,9 @@ fun backupSlot(event: Event): String = event.kind.toString() + ":" + (event.tags object ReplaceableBackupDiff { /** * What a newer [incoming] version of a backed-up event removed from the [saved] one, as - * computed by the event itself ([Event.diffFrom]), or null when it removed nothing: it - * only added or edited entries, which means the other app did read the previous version. + * 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, @@ -150,6 +152,7 @@ object ReplaceableBackupDiff { 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 - return incoming.diffFrom(saved)?.takeIf { it.removesData() } + val diffable = incoming as? DiffableEvent<*> ?: return null + return diffable.diffFrom(saved)?.takeIf { it.removesData() } } } 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 index a21990e25b..778cbfb679 100644 --- 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 @@ -22,16 +22,17 @@ 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.diff.DiffEntry 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 [Event.diffFrom]; the diff itself is tested in quartz. */ +/** The backup guard's use of the events' own diffs; the diffs themselves are tested in quartz. */ class ReplaceableBackupDiffTest { private val signer = NostrSignerSync(KeyPair()) @@ -49,9 +50,8 @@ class ReplaceableBackupDiffTest { 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 = assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) - assertEquals(listOf(DiffEntry.Person(alice)), diff.removed) - assertEquals(BackupEventType.FOLLOW_LIST, BackupEventType.of(diff.kind)) + val diff = assertIs(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(listOf(alice), diff.follows.removed.map { it.pubKey }) } @Test 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..9874e29920 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.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.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() + + override fun isEmpty() = communities == ContentChange.NONE +} 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..42809e9372 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.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.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() = rooms.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = rooms.isEmpty() && privateItems == ContentChange.NONE +} 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 4b8afcd31a..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,10 +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.DiffEntry +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 @@ -44,13 +46,20 @@ 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() - override fun diffEntry(tag: Array): DiffEntry? = RoomIdTag.parse(tag)?.let { DiffEntry.ChatRoom(it.id, it.relayUrl.url) } ?: super.diffEntry(tag) - companion object { const val KIND = 10023 const val FIXED_D_TAG = "" 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..746534c24a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.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.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() + + override fun isEmpty() = targets.isEmpty() +} 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 0e56790cc4..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,8 +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.DiffEntry +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 @@ -34,10 +36,14 @@ class PaymentTargetsEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun paymentTargets(): List = tags.mapNotNull { PaymentTargetTag.parse(it) } +) : 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 })) + } - override fun diffEntry(tag: Array): DiffEntry? = PaymentTargetTag.parse(tag)?.let { DiffEntry.PaymentTarget(it.type, it.authority) } ?: super.diffEntry(tag) + fun paymentTargets(): List = tags.mapNotNull { PaymentTargetTag.parse(it) } companion object { const val KIND = 10133 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/core/Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt index 86957a9eb3..9067738525 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -21,10 +21,6 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.diff.ContentChange -import com.vitorpamplona.quartz.nip01Core.diff.DiffEntries -import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry -import com.vitorpamplona.quartz.nip01Core.diff.EventDiff import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -68,37 +64,6 @@ open class Event( */ open fun notifies(userHex: HexKey): Boolean = PTag.isNotifying(tags, userHex) - /** - * Turns one of this event's tags into a [DiffEntry] for [diffFrom], or null to leave the - * tag out of diffs. Subclasses override it for tags whose meaning is specific to their - * kind and fall back to `super` for the common ones. - */ - open fun diffEntry(tag: Array): DiffEntry? = DiffEntries.fromTag(tag) - - /** - * Everything this event holds, as [DiffEntry]s. Defaults to its tags; subclasses whose - * content is structured (kind:0 profiles) add the content's fields here too. - */ - open fun diffEntries(): List = tags.mapNotNull { diffEntry(it) } - - /** - * How the content changed since [older], beyond what [diffEntries] already covers. - * Subclasses whose content is already expressed as entries, or is meaningless, return - * [ContentChange.NONE]. - */ - open fun diffContent(older: Event): ContentChange = ContentChange.between(older.content, content) - - /** - * Compares this event with an [older] version of itself (same kind and author) and - * reports what was removed, added and changed, in the typed [DiffEntry] vocabulary so - * it can be shown to the user. Null when [older] isn't a version of the same event kind - * and author. Callers comparing addressables must pass versions of the same address. - */ - fun diffFrom(older: Event): EventDiff? { - if (older.kind != kind || older.pubKey != pubKey) return null - return EventDiff.compute(kind, older.diffEntries(), diffEntries(), diffContent(older), isContentEncoded()) - } - fun toJson(): String = OptimizedJsonMapper.toJson(this) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt deleted file mode 100644 index 99f47158c1..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntries.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core.diff - -/** The default tag → [DiffEntry] mapping every event starts from. */ -object DiffEntries { - /** - * Tag names clients rewrite for their own bookkeeping. They carry nothing the user - * entered, so they never show up in a diff. - */ - val BOOKKEEPING_TAGS = setOf("alt", "client", "d", "expiration") - - fun fromTag(tag: Array): DiffEntry? { - if (tag.isEmpty() || tag[0] in BOOKKEEPING_TAGS) return null - val value = tag.getOrNull(1) ?: return DiffEntry.OtherTag(tag.toList()) - return when (tag[0]) { - "p" -> DiffEntry.Person(value, tag.getOrNull(2)?.ifBlank { null }, tag.getOrNull(3)?.ifBlank { null }) - "t" -> DiffEntry.Hashtag(value) - "word" -> DiffEntry.Word(value) - "g" -> DiffEntry.Geohash(value) - "r", "relay" -> - when (tag.getOrNull(2)) { - "read" -> DiffEntry.Relay(value, read = true, write = false) - "write" -> DiffEntry.Relay(value, read = false, write = true) - else -> DiffEntry.Relay(value) - } - "e" -> DiffEntry.EventRef(value, tag.getOrNull(2)?.ifBlank { null }) - "a" -> DiffEntry.AddressRef(value, tag.getOrNull(2)?.ifBlank { null }) - else -> DiffEntry.OtherTag(tag.toList()) - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt deleted file mode 100644 index 38b7b08a3e..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/DiffEntry.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core.diff - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey - -/** - * One meaningful item of an event, as seen when comparing two versions of it: a followed - * person, a relay, a muted word, a profile field… - * - * Every entry has a [key], its identity inside the event. Two versions of an event are - * compared by key: a key only in the older one was removed, a key only in the newer one was - * added, and a key in both whose entries are not equal was changed (a new relay marker, an - * edited bio, a different relay hint). - * - * Events decide how their own tags and content become entries by overriding - * [com.vitorpamplona.quartz.nip01Core.core.Event.diffEntry] and - * [com.vitorpamplona.quartz.nip01Core.core.Event.diffEntries]. - */ -@Immutable -sealed interface DiffEntry { - val key: String - - /** A kind:0 profile field, e.g. `name`, `about`, `lud16`. */ - data class ProfileField( - val name: String, - val value: String, - ) : DiffEntry { - override val key get() = "field:$name" - } - - /** A `p` tag: a followed, muted or listed person. */ - data class Person( - val pubKey: HexKey, - val relayHint: String? = null, - val petName: String? = null, - ) : DiffEntry { - override val key get() = "p:$pubKey" - } - - /** A `t` tag. */ - data class Hashtag( - val hashtag: String, - ) : DiffEntry { - override val key get() = "t:$hashtag" - } - - /** A `word` tag (NIP-51 mute lists). */ - data class Word( - val word: String, - ) : DiffEntry { - override val key get() = "word:$word" - } - - /** A `g` tag. */ - data class Geohash( - val geohash: String, - ) : DiffEntry { - override val key get() = "g:$geohash" - } - - /** - * An `r` or `relay` tag. NIP-65 relays can be limited to [read] or [write]; a relay - * without a marker is both. - */ - data class Relay( - val url: String, - val read: Boolean = true, - val write: Boolean = true, - ) : DiffEntry { - override val key get() = "relay:$url" - } - - /** An `e` tag: a muted thread, a joined public chat, a bookmarked note… */ - data class EventRef( - val eventId: HexKey, - val relayHint: String? = null, - ) : DiffEntry { - override val key get() = "e:$eventId" - } - - /** An `a` tag: a community, a feed, an addressable item. */ - data class AddressRef( - val address: String, - val relayHint: String? = null, - ) : DiffEntry { - override val key get() = "a:$address" - } - - /** A NIP-29 relay-based group the user joined. */ - data class RelayGroup( - val groupId: String, - val relay: String, - val name: String? = null, - ) : DiffEntry { - override val key get() = "group:$groupId@$relay" - } - - /** An ephemeral chat room the user joined. */ - data class ChatRoom( - val roomId: String, - val relay: String, - ) : DiffEntry { - override val key get() = "room:$roomId@$relay" - } - - /** A NIP-85 trusted service provider: who computes [service] (e.g. `30382:rank`) for the user. */ - data class TrustProvider( - val service: String, - val pubKey: HexKey, - val relay: String, - ) : DiffEntry { - override val key get() = "provider:$service:$pubKey" - } - - /** A NIP-61 mint the user accepts nutzaps from. */ - data class Mint( - val url: String, - val units: List = emptyList(), - ) : DiffEntry { - override val key get() = "mint:$url" - } - - /** The NIP-61 key nutzaps are locked to. */ - data class NutzapKey( - val pubKey: HexKey, - ) : DiffEntry { - override val key get() = "nutzap-key:$pubKey" - } - - /** A NIP-A3 payment target, e.g. `bitcoin` + an address. */ - data class PaymentTarget( - val type: String, - val authority: String, - ) : DiffEntry { - override val key get() = "payto:$type:$authority" - } - - /** A BOLT12 offer. */ - data class Bolt12Offer( - val offer: String, - ) : DiffEntry { - override val key get() = "offer:$offer" - } - - /** Any other tag, kept as-is and identified by its name and first value. */ - data class OtherTag( - val values: List, - ) : DiffEntry { - val name get() = values.firstOrNull() ?: "" - val value get() = values.getOrNull(1) ?: "" - - override val key get() = "tag:$name:$value" - } -} 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 index d2f6e3da4d..f281a31b2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt @@ -21,19 +21,111 @@ package com.vitorpamplona.quartz.nip01Core.diff import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Event -/** An entry that exists in both versions, but with different details. */ +/** + * 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 + + fun isEmpty(): 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 -data class DiffChange( - val before: DiffEntry, - val after: DiffEntry, +class ItemChange( + val before: T, + val after: T, ) /** - * What happened to the part of the content that is not already represented by [DiffEntry]s. - * For NIP-51 lists that is the NIP-44 encrypted private items, which can only be compared - * as a whole without decrypting. + * 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, @@ -42,6 +134,8 @@ enum class ContentChange { CLEARED, ; + fun isRemoval() = this == CLEARED + companion object { fun between( older: String, @@ -55,53 +149,3 @@ enum class ContentChange { } } } - -/** - * Everything that differs between two versions of the same event, computed by - * [com.vitorpamplona.quartz.nip01Core.core.Event.diffFrom]. - * - * @property contentEncrypted whether [content] refers to encrypted content (private list - * items) rather than plain text. - */ -@Immutable -class EventDiff( - val kind: Kind, - val removed: List, - val added: List, - val changed: List, - val content: ContentChange, - val contentEncrypted: Boolean, -) { - fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty() && content == ContentChange.NONE - - /** The newer version dropped something the older one had. */ - fun removesData() = removed.isNotEmpty() || content == ContentChange.CLEARED - - companion object { - fun compute( - kind: Kind, - older: List, - newer: List, - content: ContentChange, - contentEncrypted: Boolean, - ): EventDiff { - val olderByKey = LinkedHashMap(older.size) - older.forEach { olderByKey.getOrPut(it.key) { it } } - val newerByKey = LinkedHashMap(newer.size) - newer.forEach { newerByKey.getOrPut(it.key) { it } } - - val removed = mutableListOf() - val changed = mutableListOf() - olderByKey.forEach { (key, before) -> - val after = newerByKey[key] - when { - after == null -> removed.add(before) - after != before -> changed.add(DiffChange(before, after)) - } - } - val added = newerByKey.filterKeys { it !in olderByKey }.values.toList() - - return EventDiff(kind, removed, added, changed, content, contentEncrypted) - } - } -} 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..37447f7789 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt @@ -0,0 +1,62 @@ +/* + * 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) + + override fun removesData() = fieldChanges().any { it.isRemoval() } || otherFields.hasRemovals() || identityClaims.hasRemovals() + + override fun isEmpty() = fieldChanges().isEmpty() && otherFields.isEmpty() && identityClaims.isEmpty() + + companion object { + /** JSON keys [UserMetadata] parses into typed fields above. */ + 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 a74055c502..87213b0472 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 @@ -27,8 +27,9 @@ 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.ContentChange -import com.vitorpamplona.quartz.nip01Core.diff.DiffEntry +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 @@ -69,7 +70,54 @@ 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 = ValueChange.of(before.bot, after.bot), + 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 @@ -107,24 +155,6 @@ class MetadataEvent( override fun indexableSeparator() = " " - /** - * Profile fields live in the JSON content, so they become [DiffEntry.ProfileField]s. - * Tags that only mirror a content field are left out to not count them twice. - */ - override fun diffEntries(): List { - val fields = - contactMetadataJson()?.mapNotNull { (name, value) -> - val text = if (value is JsonPrimitive) value.content else value.toString() - if (text.isBlank() || text == "null") null else DiffEntry.ProfileField(name, text) - } ?: emptyList() - val fieldNames = fields.mapTo(HashSet()) { it.name } - val tagEntries = super.diffEntries().filterNot { it is DiffEntry.OtherTag && it.name in fieldNames } - return fields + tagEntries - } - - /** The content is already fully represented by [diffEntries]. */ - override fun diffContent(older: Event) = ContentChange.NONE - fun contactMetadataJson() = if (content.isBlank()) { // An empty kind-0 is a valid, empty profile — not a parse failure. 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..a7d1d74390 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.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.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() + + override fun isEmpty() = follows.isEmpty() +} 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 b1c73bdd2b..ceb0d9c66b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -26,7 +26,8 @@ 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.ContentChange +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 @@ -46,14 +47,19 @@ 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) - /** The content is a deprecated relay map that many clients drop on purpose. */ - override fun diffContent(older: Event) = ContentChange.NONE - /** * Returns a list of p-tags that are verified as hex keys. */ 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..d4f92032b9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.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.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() = channels.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = channels.isEmpty() && privateItems == ContentChange.NONE +} 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..b4448a14d4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.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.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() = feeds.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = feeds.isEmpty() && privateItems == ContentChange.NONE +} 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..9ce5c2a2b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt @@ -0,0 +1,37 @@ +/* + * 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() = geohashes.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = geohashes.isEmpty() && privateItems == ContentChange.NONE +} 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..ed1d1aa565 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 }), + 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..0b090773ba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt @@ -0,0 +1,37 @@ +/* + * 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() = hashtags.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = hashtags.isEmpty() && privateItems == ContentChange.NONE +} 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..a5070d8914 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.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.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() = publicMutes.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = publicMutes.isEmpty() && privateItems == ContentChange.NONE +} 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..439e4f559b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.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.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() = relays.hasRemovals() || privateRelays.isRemoval() + + override fun isEmpty() = relays.isEmpty() && privateRelays == ContentChange.NONE +} 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..a05222004d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt @@ -0,0 +1,37 @@ +/* + * 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() = groups.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = groups.isEmpty() && privateItems == ContentChange.NONE +} 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 24c4b20666..7c2c059f54 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,9 +22,11 @@ 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.DiffEntry +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.nip51Lists.PrivateTagArrayEvent @@ -40,10 +42,17 @@ class SimpleGroupListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun publicGroups() = tags.mapNotNull(GroupTag::parse) +) : 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 + "@" + it.relayUrl }, { a, b -> a.name == b.name }), + privateItemsChangeFrom(older), + ) + } - override fun diffEntry(tag: Array): DiffEntry? = GroupTag.parse(tag)?.let { DiffEntry.RelayGroup(it.groupId, it.relayUrl, it.name) } ?: super.diffEntry(tag) + 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..0eed297479 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.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.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() + + override fun isEmpty() = wallet == ContentChange.NONE +} 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..027a54b3f9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt @@ -0,0 +1,41 @@ +/* + * 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 { + override fun removesData() = mints.hasRemovals() || relays.hasRemovals() || p2pkPubkey?.isRemoval() == true + + override fun isEmpty() = mints.isEmpty() && relays.isEmpty() && p2pkPubkey == null +} 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 7982b1dc87..4d0437e5d7 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,13 +23,15 @@ 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.DiffEntry +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 -import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapPubkeyTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -40,19 +42,23 @@ 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 }, { a, b -> a.units == b.units }), + ListDiff.of(older.relays(), relays(), { it }), + ValueChange.of(older.p2pkPubkey(), p2pkPubkey()), + ) + } + fun mints() = tags.mints() fun relays() = tags.relays() fun p2pkPubkey() = tags.p2pkPubkey() - override fun diffEntry(tag: Array): DiffEntry? { - NutzapMintTag.parse(tag)?.let { return DiffEntry.Mint(it.mintUrl, it.units) } - NutzapPubkeyTag.parse(tag)?.let { return DiffEntry.NutzapKey(it) } - return super.diffEntry(tag) - } - companion object { const val KIND = 10019 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..c5f8aba130 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.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.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 { + override fun removesData() = relays.hasRemovals() + + override fun isEmpty() = relays.isEmpty() +} 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..89dab81d3e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -23,7 +23,10 @@ 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.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -38,7 +41,13 @@ 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.relays(), relays(), { it.relayUrl }, { a, b -> a.type == b.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..07b76b3954 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.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.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() = communities.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = communities.isEmpty() && privateItems == ContentChange.NONE +} 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..c18aad45de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.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.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() + + override fun isEmpty() = data == ContentChange.NONE +} 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..8e516df696 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.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.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() = providers.hasRemovals() || privateItems.isRemoval() + + override fun isEmpty() = providers.isEmpty() && privateItems == ContentChange.NONE +} 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 62d898b7d4..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,10 +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.DiffEntry +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,12 +45,17 @@ class TrustProviderListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun serviceProviders() = tags.serviceProviders() +) : 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), + ) + } - override fun diffEntry(tag: Array): DiffEntry? = - ServiceProviderTag.parse(tag)?.let { DiffEntry.TrustProvider(it.service.toValue(), it.pubkey, it.relayUrl.url) } - ?: super.diffEntry(tag) + fun serviceProviders() = tags.serviceProviders() companion object { const val KIND = 10040 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..f188b15c4c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.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.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() + + override fun isEmpty() = offers.isEmpty() +} 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 3d90ab02f6..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,8 +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.DiffEntry +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 @@ -51,15 +53,19 @@ 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) /** The first valid offer, or null when the list carries none. */ fun firstOffer(): String? = tags.firstNotNullOfOrNull(OfferTag::parse) - override fun diffEntry(tag: Array): DiffEntry? = OfferTag.parse(tag)?.let { DiffEntry.Bolt12Offer(it) } ?: super.diffEntry(tag) - companion object { const val KIND = 10058 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 index 15c02b65c6..cae610c9d3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -23,12 +23,19 @@ 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.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 @@ -41,143 +48,164 @@ import kotlin.test.assertTrue class EventDiffTest { private val signer = NostrSignerSync(KeyPair()) - private fun sign( + private inline fun sign( kind: Int, createdAt: Long, tags: Array>, content: String = "", - ): Event = signer.sign(createdAt, kind, tags, content) + ): 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 followListReportsRemovedAndAddedPeople() { - val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) - val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice), arrayOf("p", carol))) + 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(DiffEntry.Person(bob)), diff.removed) - assertEquals(listOf(DiffEntry.Person(carol)), diff.added) + 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 onlyAddingIsNotARemoval() { - val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice))) - val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) - assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) - } - - @Test - fun newRelayHintOrPetnameIsAChange() { - val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice, "wss://a.com", "al"))) - val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice, "wss://b.com"))) + 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)) - assertTrue(diff.removed.isEmpty()) - assertEquals(DiffChange(DiffEntry.Person(alice, "wss://a.com", "al"), DiffEntry.Person(alice, "wss://b.com")), diff.changed.single()) + assertFalse(diff.removesData()) } @Test - fun contactListContentIsIgnored() { - val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}") - val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice)), "") - assertTrue(assertNotNull(newer.diffFrom(older)).isEmpty()) - } - - @Test - fun bookkeepingTagsAreIgnored() { - val older = sign(AdvertisedRelayListEvent.KIND, 100, arrayOf(arrayOf("r", "wss://a.com"), arrayOf("client", "Amethyst"), arrayOf("alt", "x"))) - val newer = sign(AdvertisedRelayListEvent.KIND, 200, arrayOf(arrayOf("r", "wss://a.com"))) - assertTrue(assertNotNull(newer.diffFrom(older)).isEmpty()) - } - - @Test - fun relayMarkersAreTyped() { + fun muteListDiffsMuteTagsAndPrivateItems() { 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"))) - val diff = assertNotNull(newer.diffFrom(older)) - assertEquals(listOf(DiffEntry.Relay("wss://c.com")), diff.removed) - assertEquals(DiffChange(DiffEntry.Relay("wss://a.com"), DiffEntry.Relay("wss://a.com", read = false, write = true)), diff.changed.single()) - } - - @Test - fun muteListEntriesAndPrivateItems() { - val older = - sign( + 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(), "") + val newer = sign(MuteListEvent.KIND, 200, arrayOf(arrayOf("word", "crypto")), "") val diff = assertNotNull(newer.diffFrom(older)) - assertEquals( - listOf(DiffEntry.Person(alice), DiffEntry.Hashtag("spam"), DiffEntry.Word("crypto"), DiffEntry.EventRef(bob)), - diff.removed, - ) - assertEquals(ContentChange.CLEARED, diff.content) - assertTrue(diff.contentEncrypted) + 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 privateItemsRewrittenAreChangedNotCleared() { - val older = sign(MuteListEvent.KIND, 100, arrayOf(), "cipher-a") - val newer = sign(MuteListEvent.KIND, 200, arrayOf(), "cipher-b") + 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.content) + assertEquals(ContentChange.CHANGED, diff.privateItems) assertFalse(diff.removesData()) } @Test - fun profileFieldsComeFromTheContent() { - val older = sign(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":""}""") - val newer = sign(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""") - assertIs(newer) + 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(DiffEntry.ProfileField("lud16", "v@x.com")), diff.removed) - assertEquals(listOf(DiffEntry.ProfileField("banner", "https://b")), diff.added) - assertEquals(DiffChange(DiffEntry.ProfileField("name", "vitor"), DiffEntry.ProfileField("name", "vitor2")), diff.changed.single()) - assertEquals(ContentChange.NONE, diff.content) + 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 groupsAreTyped() { - val older = sign(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("group", "abc", "wss://groups.com", "Friends"))) - val newer = sign(SimpleGroupListEvent.KIND, 200, arrayOf()) - assertEquals(listOf(DiffEntry.RelayGroup("abc", "wss://groups.com", "Friends")), assertNotNull(newer.diffFrom(older)).removed) - } - - @Test - fun trustProvidersAreTyped() { - val older = sign(TrustProviderListEvent.KIND, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com"))) - val newer = sign(TrustProviderListEvent.KIND, 200, arrayOf()) - val entry = assertNotNull(newer.diffFrom(older)).removed.single() - assertIs(entry) - assertEquals("30382:rank", entry.service) - assertEquals(alice, entry.pubKey) - } - - @Test - fun nutzapMintsAndKeyAreTyped() { - val older = sign(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice))) - val newer = sign(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("pubkey", bob))) + 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(DiffEntry.Mint("https://mint.com", listOf("sat")), DiffEntry.NutzapKey(alice)), diff.removed) - assertEquals(listOf(DiffEntry.NutzapKey(bob)), diff.added) + assertEquals(listOf(NormalizedRelayUrl("wss://spam.com/")), diff.relays.removed) } @Test - fun differentKindsOrAuthorsAreNotComparable() { - val a = sign(ContactListEvent.KIND, 100, arrayOf()) - val b = sign(MuteListEvent.KIND, 200, arrayOf()) - val c = NostrSignerSync(KeyPair()).sign(300, ContactListEvent.KIND, arrayOf(), "") - assertNull(b.diffFrom(a)) - assertNull(c.diffFrom(a)) + 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 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)) + } + + @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) } } From 42172f0dd552a14c30df876793375bb6ac4c400d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:34:22 +0000 Subject: [PATCH 06/31] fix: backup conflict audit findings - Restore publishes relay lists (NIP-65, DM, key package) and the Cashu wallet everywhere, plus to every relay the restored list names, instead of only the outbox set derived from the lossy version. - Keep-new and restore claim the conflict first and do nothing when it is stale, so accepting an old dialog can't drop a newer conflict and a double tap can't publish twice. A failed restore reopens the conflict. - Deleting the wallet or nutzap info drops their open conflict so Restore can't resurrect them. - Local edits during an open conflict keep the external version as its cause and keep it snoozed; the snooze lives in AccountSettings, per slot, so it survives rotation and resets on the next external change. - Public items removed while the private section appears count as made private, not lost. - Skip re-diffing when the cache re-emits the same incoming version. - Drop unused EventDiff.isEmpty() and reuse dTag() for backup slots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 21 +++- .../vitorpamplona/amethyst/model/Account.kt | 28 +++-- .../amethyst/model/AccountSettings.kt | 109 +++++++++++++++--- .../amethyst/model/EventBroadcaster.kt | 27 +++++ .../loggedIn/backups/BackupConflictDialog.kt | 23 ++-- .../backups/ReplaceableBackupConflict.kt | 19 ++- .../ConcordCommunityListDiff.kt | 2 - .../ephemChat/list/EphemeralChatListDiff.kt | 4 +- .../experimental/nipA3/PaymentTargetsDiff.kt | 2 - .../quartz/nip01Core/diff/EventDiff.kt | 10 +- .../quartz/nip01Core/metadata/MetadataDiff.kt | 2 - .../quartz/nip02FollowList/ContactListDiff.kt | 2 - .../nip28PublicChat/list/ChannelListDiff.kt | 4 +- .../FavoriteAlgoFeedsListDiff.kt | 4 +- .../nip51Lists/geohashList/GeohashListDiff.kt | 4 +- .../nip51Lists/hashtagList/HashtagListDiff.kt | 4 +- .../nip51Lists/muteList/MuteListDiff.kt | 4 +- .../nip51Lists/relayLists/RelayListDiff.kt | 4 +- .../simpleGroupList/SimpleGroupListDiff.kt | 4 +- .../nip60Cashu/wallet/CashuWalletDiff.kt | 2 - .../nip61Nutzaps/info/NutzapInfoDiff.kt | 2 - .../nip65RelayList/AdvertisedRelayListDiff.kt | 2 - .../follow/CommunityListDiff.kt | 4 +- .../nip78AppData/AppSpecificDataDiff.kt | 2 - .../list/TrustProviderListDiff.kt | 4 +- .../offer/Bolt12OfferListDiff.kt | 2 - .../quartz/nip01Core/diff/EventDiffTest.kt | 17 +++ 27 files changed, 218 insertions(+), 94 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 06f747d4bb..1c5d528da7 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -48,14 +48,29 @@ only surviving copy of the user's data is gone. channel ids to names from `LocalCache`. It 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)` and publishes it. + `created_at = max(now, incoming + 1)` 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) also to every relay the restored + list names, since the lossy version may have dropped them from the outbox set. - **Use new version** — `Account.acceptExternalVersion` whitelists that id and re-runs the original update, so the backup moves forward. - - **Decide later** — hides it for this session only; the backup stays frozen. + - **Decide later** — snoozes the slot in `AccountSettings.snoozedBackupConflicts` while + the account is loaded (survives rotation) and until another app changes it again; the + backup stays frozen. + + Both resolutions first *claim* the conflict: they act only if it is still the open + conflict of its slot, so a stale dialog, 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. + silently clear the conflict. They move the conflict's `incoming` forward but keep the + external version as its `cause` (the dialog shows that date) and keep it snoozed. Conflicts are not persisted: the frozen backup is reloaded on start, the newer relay version replaces it in `LocalCache`, and the same conflict is detected again. 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 7674daf321..6d3bc69fb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1893,21 +1893,27 @@ class Account( /** * 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) with a - * timestamp newer than the external one, and publishes it so it replaces that version. + * 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 saved = conflict.saved - val createdAt = maxOf(TimeUtils.now(), conflict.incoming.createdAt + 1) - val resigned = signer.sign(createdAt, saved.kind, saved.tags, saved.content) - settings.dismissBackupConflict(conflict) - sendMyPublicAndPrivateOutbox(resigned) + val token = settings.startRestoringSavedVersion(conflict) ?: return + var restored = false + try { + val saved = conflict.saved + val createdAt = maxOf(TimeUtils.now(), conflict.incoming.createdAt + 1) + val resigned = signer.sign(createdAt, saved.kind, saved.tags, saved.content) + broadcaster.sendRestoredVersion(resigned) + restored = true + } finally { + settings.finishRestoringSavedVersion(conflict, token, restored) + } } - /** Resolves a [ReplaceableBackupConflict] in favor of the version from the other client. */ - fun acceptExternalVersion(conflict: ReplaceableBackupConflict) { - settings.dismissBackupConflict(conflict) - conflict.keepIncoming() - } + /** Resolves a [ReplaceableBackupConflict] in favor of the current version. No-op when stale. */ + fun acceptExternalVersion(conflict: ReplaceableBackupConflict) = settings.keepIncomingVersion(conflict) suspend fun signAndSendPrivately( template: EventTemplate, 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 3ccd7f007c..6c8c885b3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupDiff import com.vitorpamplona.amethyst.commons.model.backups.backupSlot +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 @@ -96,6 +97,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable +import java.util.concurrent.ConcurrentHashMap val DefaultSignerPermissions = listOf( @@ -1172,6 +1174,20 @@ class AccountSettings( */ val backupConflicts = MutableStateFlow>(emptyMap()) + /** + * Slots whose conflict the user chose to decide later. Lives as long as the account is + * loaded, so it survives activity recreation, and is cleared when another app changes + * the slot again (a new external change deserves a new question). + */ + val snoozedBackupConflicts = MutableStateFlow>(emptySet()) + + // Re-runs the update that raised each open conflict, once the user keeps the new version. + private val backupConflictRetries = ConcurrentHashMap Unit>() + + // Versions whose conflict is being resolved (accepted or restored). They must not raise + // the same conflict again while the resolution is in flight. + private val resolvingBackupVersions = ConcurrentSet() + // Externally signed versions the user explicitly chose to keep over the backup. private val acceptedExternalVersions = ConcurrentSet() @@ -1179,7 +1195,7 @@ class AccountSettings( * 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 accepts it. + * update once the user keeps it. */ private fun acceptIntoBackup( saved: T?, @@ -1187,37 +1203,101 @@ class AccountSettings( retry: () -> Unit, ): Boolean { if (saved?.id == incoming.id) return false + if (resolvingBackupVersions.contains(incoming.id)) return false val slot = backupSlot(incoming) val pending = backupConflicts.value[slot] + // The same version re-emitted by the cache: the open conflict already describes it. + if (pending != null && pending.incoming.id == incoming.id) return false + + val isLocal = LocallySignedEvents.contains(incoming.id) + // 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 { LocallySignedEvents.contains(incoming.id) } + val reference = pending?.saved ?: saved?.takeUnless { isLocal } if (reference != null && !acceptedExternalVersions.contains(incoming.id)) { val diff = ReplaceableBackupDiff.detectLoss(reference, incoming) if (diff != null) { - val conflict = - ReplaceableBackupConflict(reference, incoming, diff) { - acceptedExternalVersions.add(incoming.id) - retry() - } + // A local edit keeps the external change as the conflict's cause, and keeps + // it snoozed; a new external change is a new question. + val cause = if (isLocal && pending != null) pending.cause else incoming + val conflict = ReplaceableBackupConflict(reference, incoming, cause, diff) + backupConflictRetries[slot] = retry backupConflicts.update { it + (slot to conflict) } + if (!isLocal) snoozedBackupConflicts.update { it - slot } return false } } - if (backupConflicts.value.containsKey(slot)) { - backupConflicts.update { it - slot } - } + dropBackupConflict(slot) return true } - fun dismissBackupConflict(conflict: ReplaceableBackupConflict) { - backupConflicts.update { current -> - if (current[conflict.slot] === conflict) current - conflict.slot else current + private fun dropBackupConflict(slot: String) { + if (backupConflicts.value.containsKey(slot)) { + backupConflicts.update { it - slot } } + backupConflictRetries.remove(slot) + snoozedBackupConflicts.update { it - slot } + } + + /** + * Removes [conflict] if it is still the open conflict of its slot. Returns false when it + * was already resolved or replaced by a newer one, so callers never act on a stale conflict. + */ + private fun claimBackupConflict(conflict: ReplaceableBackupConflict): (() -> Unit)? { + var claimed = false + backupConflicts.update { current -> + claimed = current[conflict.slot] === conflict + if (claimed) current - conflict.slot else current + } + if (!claimed) return null + snoozedBackupConflicts.update { it - conflict.slot } + return backupConflictRetries.remove(conflict.slot) ?: {} + } + + /** Keeps the new version: the backup moves forward to [ReplaceableBackupConflict.incoming]. */ + fun keepIncomingVersion(conflict: ReplaceableBackupConflict) { + val retry = claimBackupConflict(conflict) ?: return + acceptedExternalVersions.add(conflict.incoming.id) + 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 + * [finishRestoringSavedVersion]. + */ + fun startRestoringSavedVersion(conflict: ReplaceableBackupConflict): (() -> Unit)? { + val retry = claimBackupConflict(conflict) ?: return null + resolvingBackupVersions.add(conflict.incoming.id) + return 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 finishRestoringSavedVersion( + conflict: ReplaceableBackupConflict, + token: () -> Unit, + restored: Boolean, + ) { + resolvingBackupVersions.remove(conflict.incoming.id) + if (restored) return + var reopened = false + backupConflicts.update { current -> + reopened = !current.containsKey(conflict.slot) + if (reopened) current + (conflict.slot to conflict) else current + } + if (reopened) backupConflictRetries[conflict.slot] = token + } + + fun snoozeBackupConflict(conflict: ReplaceableBackupConflict) { + snoozedBackupConflicts.update { it + conflict.slot } } fun updateLocalRelayServers(servers: Set) { @@ -1312,6 +1392,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. + dropBackupConflict(backupSlotOf(CashuWalletEvent.KIND)) if (backupCashuWallet != null) { backupCashuWallet = null saveAccountSettings() @@ -1320,6 +1402,7 @@ class AccountSettings( /** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */ fun clearNutzapInfo() { + dropBackupConflict(backupSlotOf(NutzapInfoEvent.KIND)) if (backupNutzapInfo != null) { backupNutzapInfo = null saveAccountSettings() 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..d6c04749b1 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,7 @@ 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.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -369,6 +372,30 @@ 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. + */ + 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) + 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/ui/screen/loggedIn/backups/BackupConflictDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt index 3b461f548f..c26632fd59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt @@ -33,8 +33,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateSetOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -53,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.diff.EventDiff import com.vitorpamplona.quartz.nip01Core.diff.ItemChange @@ -95,24 +92,24 @@ private const val MAX_VALUE_LENGTH = 80 * lists what was removed, added and changed, grouped by what each entry is (people, relays, * hashtags, profile fields…). * - * One conflict at a time; "decide later" only hides the question for this session: the - * conflict stays open, so the backup stays frozen on the saved version and the question - * comes back on the next launch. + * One conflict at a time; "decide later" snoozes it while the account stays loaded (so it + * survives rotation) and until another app changes that event again: the conflict stays + * open, so the backup stays frozen on the saved version and the question comes back on the + * next launch. */ @Composable fun BackupConflictDialog(accountViewModel: AccountViewModel) { - val conflicts by accountViewModel.account.settings.backupConflicts - .collectAsStateWithLifecycle() + val settings = accountViewModel.account.settings + val conflicts by settings.backupConflicts.collectAsStateWithLifecycle() + val snoozed by settings.snoozedBackupConflicts.collectAsStateWithLifecycle() - val snoozed = remember { mutableStateSetOf() } - - val conflict = conflicts.values.firstOrNull { it.incoming.id !in snoozed } ?: return + val conflict = conflicts.values.firstOrNull { it.slot !in snoozed } ?: return BackupConflictDialog( conflict = conflict, onRestore = { accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }, onKeepNew = { accountViewModel.account.acceptExternalVersion(conflict) }, - onDismiss = { snoozed.add(conflict.incoming.id) }, + onDismiss = { settings.snoozeBackupConflict(conflict) }, ) } @@ -137,7 +134,7 @@ private fun BackupConflictDialog( fontStyle = FontStyle.Italic, ) Spacer(Modifier.height(12.dp)) - Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.incoming.createdAt, context))) + Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.cause.createdAt, context))) DiffDetails(conflict.diff) 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 index 0da6dd7cf6..07bdf94b37 100644 --- 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 @@ -29,6 +29,7 @@ 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.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent @@ -120,23 +121,33 @@ enum class BackupEventType { * 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, - private val acceptIncoming: () -> Unit, ) { /** 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) - - fun keepIncoming() = acceptIncoming() } -fun backupSlot(event: Event): String = event.kind.toString() + ":" + (event.tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "") +fun backupSlot(event: Event): String = backupSlotOf(event.kind, event.dTag()) + +fun backupSlotOf( + kind: Int, + dTag: String = "", +): String = "$kind:$dTag" object ReplaceableBackupDiff { /** 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 index 9874e29920..6bc2df62f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt @@ -30,6 +30,4 @@ class ConcordCommunityListDiff( val communities: ContentChange, ) : EventDiff { override fun removesData() = communities.isRemoval() - - override fun isEmpty() = communities == ContentChange.NONE } 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 index 42809e9372..c3e149e94a 100644 --- 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 @@ -32,7 +32,5 @@ class EphemeralChatListDiff( val rooms: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = rooms.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = rooms.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(rooms.hasRemovals()) || privateItems.isRemoval() } 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 index 746534c24a..29ddcc0f85 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt @@ -30,6 +30,4 @@ class PaymentTargetsDiff( val targets: ListDiff, ) : EventDiff { override fun removesData() = targets.hasRemovals() - - override fun isEmpty() = targets.isEmpty() } 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 index f281a31b2f..1d5b596544 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt @@ -32,8 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event interface EventDiff { /** The newer version dropped something the older one had. */ fun removesData(): Boolean - - fun isEmpty(): Boolean } /** @@ -136,6 +134,14 @@ enum class ContentChange { 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, 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 index 37447f7789..429b3f48bf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt @@ -53,8 +53,6 @@ class MetadataDiff( override fun removesData() = fieldChanges().any { it.isRemoval() } || otherFields.hasRemovals() || identityClaims.hasRemovals() - override fun isEmpty() = fieldChanges().isEmpty() && otherFields.isEmpty() && identityClaims.isEmpty() - companion object { /** JSON keys [UserMetadata] parses into typed fields above. */ 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/nip02FollowList/ContactListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt index a7d1d74390..248e6551da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt @@ -31,6 +31,4 @@ class ContactListDiff( val follows: ListDiff, ) : EventDiff { override fun removesData() = follows.hasRemovals() - - override fun isEmpty() = follows.isEmpty() } 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 index d4f92032b9..69ab74f29c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt @@ -32,7 +32,5 @@ class ChannelListDiff( val channels: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = channels.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = channels.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(channels.hasRemovals()) || privateItems.isRemoval() } 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 index b4448a14d4..d46cf70ff7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt @@ -32,7 +32,5 @@ class FavoriteAlgoFeedsListDiff( val feeds: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = feeds.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = feeds.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(feeds.hasRemovals()) || privateItems.isRemoval() } 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 index 9ce5c2a2b1..4e0c5ec47e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt @@ -31,7 +31,5 @@ class GeohashListDiff( val geohashes: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = geohashes.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = geohashes.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(geohashes.hasRemovals()) || privateItems.isRemoval() } 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 index 0b090773ba..21faacdee8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt @@ -31,7 +31,5 @@ class HashtagListDiff( val hashtags: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = hashtags.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = hashtags.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(hashtags.hasRemovals()) || privateItems.isRemoval() } 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 index a5070d8914..079e89f180 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt @@ -32,7 +32,5 @@ class MuteListDiff( val publicMutes: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = publicMutes.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = publicMutes.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(publicMutes.hasRemovals()) || privateItems.isRemoval() } 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 index 439e4f559b..a4b04e7192 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt @@ -32,7 +32,5 @@ class RelayListDiff( val relays: ListDiff, val privateRelays: ContentChange, ) : EventDiff { - override fun removesData() = relays.hasRemovals() || privateRelays.isRemoval() - - override fun isEmpty() = relays.isEmpty() && privateRelays == ContentChange.NONE + override fun removesData() = privateRelays.publicRemovalsAreLoss(relays.hasRemovals()) || privateRelays.isRemoval() } 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 index a05222004d..a141de829a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt @@ -31,7 +31,5 @@ class SimpleGroupListDiff( val groups: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = groups.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = groups.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(groups.hasRemovals()) || privateItems.isRemoval() } 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 index 0eed297479..977ec8d256 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt @@ -30,6 +30,4 @@ class CashuWalletDiff( val wallet: ContentChange, ) : EventDiff { override fun removesData() = wallet.isRemoval() - - override fun isEmpty() = wallet == ContentChange.NONE } 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 index 027a54b3f9..c07d7669c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt @@ -36,6 +36,4 @@ class NutzapInfoDiff( val p2pkPubkey: ValueChange?, ) : EventDiff { override fun removesData() = mints.hasRemovals() || relays.hasRemovals() || p2pkPubkey?.isRemoval() == true - - override fun isEmpty() = mints.isEmpty() && relays.isEmpty() && p2pkPubkey == null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt index c5f8aba130..e427df46ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt @@ -31,6 +31,4 @@ class AdvertisedRelayListDiff( val relays: ListDiff, ) : EventDiff { override fun removesData() = relays.hasRemovals() - - override fun isEmpty() = relays.isEmpty() } 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 index 07b76b3954..5f7fb9b95d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt @@ -32,7 +32,5 @@ class CommunityListDiff( val communities: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = communities.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = communities.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(communities.hasRemovals()) || privateItems.isRemoval() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt index c18aad45de..0a810f407e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt @@ -30,6 +30,4 @@ class AppSpecificDataDiff( val data: ContentChange, ) : EventDiff { override fun removesData() = data.isRemoval() - - override fun isEmpty() = data == ContentChange.NONE } 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 index 8e516df696..3eb66fa456 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt @@ -32,7 +32,5 @@ class TrustProviderListDiff( val providers: ListDiff, val privateItems: ContentChange, ) : EventDiff { - override fun removesData() = providers.hasRemovals() || privateItems.isRemoval() - - override fun isEmpty() = providers.isEmpty() && privateItems == ContentChange.NONE + override fun removesData() = privateItems.publicRemovalsAreLoss(providers.hasRemovals()) || privateItems.isRemoval() } 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 index f188b15c4c..523dbcf127 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt @@ -30,6 +30,4 @@ class Bolt12OfferListDiff( val offers: ListDiff, ) : EventDiff { override fun removesData() = offers.hasRemovals() - - override fun isEmpty() = offers.isEmpty() } 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 index cae610c9d3..09db90c560 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -109,6 +109,23 @@ class EventDiffTest { 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 = From 4fedd90073ec2564bfa01bb95e9d0e3b960c9574 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:59:05 +0000 Subject: [PATCH 07/31] fix: ignore client/pow/expiration tags in backup diffs and restores Diffs only read the tags each event parses, so client, nonce and expiration never counted as changes; a test now pins that across contact, mute, NIP-65, relay, nutzap and profile events. Restoring a saved version now drops its old client tag (so the signer adds the current one), its nonce (the proof of work was for the old id) and an expiration already in the past. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 10 +++- .../vitorpamplona/amethyst/model/Account.kt | 11 ++-- .../backups/ReplaceableBackupConflict.kt | 25 ++++++++ .../backups/ReplaceableBackupDiffTest.kt | 26 ++++++++ .../quartz/nip01Core/diff/EventDiffTest.kt | 60 +++++++++++++++++++ 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 1c5d528da7..94730b7195 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -27,7 +27,11 @@ only surviving copy of the user's data is gone. outbox) share `RelayListDiff`; encrypted-only events (Cashu wallet, Concord list, NIP-78 data) diff their content as a whole. - `nip01Core/diff/` only holds the containers: `EventDiff` (`removesData()`, `isEmpty()`), + 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. @@ -48,7 +52,9 @@ only surviving copy of the user's data is gone. channel ids to names from `LocalCache`. It 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)` and publishes it through + `created_at = max(now, incoming + 1)`, 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) also to every relay the restored list names, since the lossy version may have dropped them from the outbox set. 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 6d3bc69fb4..d913123c9e 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,7 @@ 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 @@ -1892,8 +1893,9 @@ class Account( /** * 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) with a - * timestamp newer than the current one, and publishes it so it replaces that version. + * 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. @@ -1903,8 +1905,9 @@ class Account( var restored = false try { val saved = conflict.saved - val createdAt = maxOf(TimeUtils.now(), conflict.incoming.createdAt + 1) - val resigned = signer.sign(createdAt, saved.kind, saved.tags, saved.content) + val now = TimeUtils.now() + val createdAt = maxOf(now, conflict.incoming.createdAt + 1) + val resigned = signer.sign(createdAt, saved.kind, BackupRestore.tagsToResign(saved, now), saved.content) broadcaster.sendRestoredVersion(resigned) restored = true } finally { 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 index 07bdf94b37..ed98d029c6 100644 --- 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 @@ -31,9 +31,11 @@ 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 @@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.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. */ @@ -167,3 +170,25 @@ object ReplaceableBackupDiff { 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/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt index 778cbfb679..ece67f29b7 100644 --- 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 @@ -92,4 +92,30 @@ class ReplaceableBackupDiffTest { 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/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt index 09db90c560..a6b91fd36f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -218,6 +218,66 @@ class EventDiffTest { 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 }) From c69a528877f302d48ba7fdb4e00194b9092c6e82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:35:03 +0000 Subject: [PATCH 08/31] feat: clickable, lazily scrolled review screen for backup conflicts The conflict dialog becomes a short summary with a Review changes button that opens Route.BackupConflictReview. The review screen lists every removed, added and changed entry in a LazyColumn, rendered with the app's relay-backed loaders (users, notes, addressables, public chats) and linking to profiles, threads, communities, feeds, chats and relay info. The dialog moves into AppNavigation to get navigation, and hides while the review screen is open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 18 +- .../amethyst/model/AccountSettings.kt | 3 + .../amethyst/ui/navigation/AppNavigation.kt | 5 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../ui/screen/loggedIn/LoggedInPage.kt | 4 - .../loggedIn/backups/BackupConflictDialog.kt | 423 +++--------------- .../backups/BackupConflictPresentation.kt | 415 +++++++++++++++++ .../backups/BackupConflictReviewScreen.kt | 386 ++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 +- 9 files changed, 878 insertions(+), 382 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 94730b7195..e1b45f12db 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -44,12 +44,18 @@ only surviving copy of the user's data is gone. backup is updated silently as before. 4. **Freeze and ask.** On a loss, the backup keeps the saved version and a `ReplaceableBackupConflict` is published on `AccountSettings.backupConflicts`. - `BackupConflictDialog` (shown from `LoggedInPage`) is specific to the event: it names it - ("Your mute list changed in another app"), says what that event is for, and lists what - was removed, added and changed, grouped by entry type (people by display name, relays - with their read/write marker, profile fields old → new…). The dialog maps each diff - class to labelled groups from its typed objects (`when (diff)`), resolving pubkeys and - channel ids to names from `LocalCache`. It offers: + `BackupConflictDialog` (shown from `AppNavigation`, so it can navigate) is a short + summary specific to the event: it names it ("Your mute list changed in another app"), + says what that event is for, and counts what was removed, added and changed per entry + type. **Review changes** 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). The dialog hides while the + review screen is open (`AccountSettings.reviewingBackupConflict`). Both offer: - **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)`, dropping the old `client` tag (so the signer 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 6c8c885b3f..01c33f2042 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1181,6 +1181,9 @@ class AccountSettings( */ val snoozedBackupConflicts = MutableStateFlow>(emptySet()) + /** The slot whose review screen is open, so the conflict dialog doesn't cover it. */ + val reviewingBackupConflict = MutableStateFlow(null) + // Re-runs the update that raised each open conflict, once the user keeps the new version. private val backupConflictRetries = ConcurrentHashMap Unit>() 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 561b4e0686..1c547bcdbb 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 @@ -91,6 +91,8 @@ 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.BackupConflictDialog +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 @@ -402,6 +404,8 @@ fun AppNavigation( DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) + // Asks before another client's lossy rewrite of a backed-up list/profile becomes permanent. + BackupConflictDialog(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) DisplayResourceUsageAlert(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) @@ -678,6 +682,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/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index c6730afa1d..935683f480 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmDiscoveryPreload import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload @@ -130,9 +129,6 @@ fun LoggedInPage( accountViewModel = accountViewModel, accountSessionManager = accountSessionManager, ) - - // Asks before another client's lossy rewrite of a backed-up list/profile becomes permanent. - BackupConflictDialog(accountViewModel) } @OptIn(ExperimentalPermissionsApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt index c26632fd59..877ea74a39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt @@ -37,60 +37,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle -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.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.timeAbsolute import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff -import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff import com.vitorpamplona.quartz.nip01Core.diff.ContentChange -import com.vitorpamplona.quartz.nip01Core.diff.EventDiff -import com.vitorpamplona.quartz.nip01Core.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.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_ITEMS_PER_GROUP = 6 -private const val MAX_VALUE_LENGTH = 80 /** * Asks the user what to do when another client replaced one of the account's backed-up * replaceable events with a version that lost data: keep the new one, or re-sign the * version saved on this device over it. * - * The dialog is specific to the event that changed: it names it, says what it is for, and - * lists what was removed, added and changed, grouped by what each entry is (people, relays, - * hashtags, profile fields…). + * The dialog is a short summary (what changed, how many of each kind of entry); "Review + * changes" opens [BackupConflictReviewScreen], which lists every entry and links people, + * notes, chats and relays. The dialog stays hidden while that screen is open. * * One conflict at a time; "decide later" snoozes it while the account stays loaded (so it * survives rotation) and until another app changes that event again: the conflict stays @@ -98,17 +63,22 @@ private const val MAX_VALUE_LENGTH = 80 * next launch. */ @Composable -fun BackupConflictDialog(accountViewModel: AccountViewModel) { +fun BackupConflictDialog( + accountViewModel: AccountViewModel, + nav: INav, +) { val settings = accountViewModel.account.settings val conflicts by settings.backupConflicts.collectAsStateWithLifecycle() val snoozed by settings.snoozedBackupConflicts.collectAsStateWithLifecycle() + val reviewing by settings.reviewingBackupConflict.collectAsStateWithLifecycle() - val conflict = conflicts.values.firstOrNull { it.slot !in snoozed } ?: return + val conflict = conflicts.values.firstOrNull { it.slot !in snoozed && it.slot != reviewing } ?: return BackupConflictDialog( conflict = conflict, onRestore = { accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }, onKeepNew = { accountViewModel.account.acceptExternalVersion(conflict) }, + onReview = { nav.nav(Route.BackupConflictReview(conflict.slot)) }, onDismiss = { settings.snoozeBackupConflict(conflict) }, ) } @@ -118,26 +88,26 @@ private fun BackupConflictDialog( conflict: ReplaceableBackupConflict, onRestore: () -> Unit, onKeepNew: () -> Unit, + onReview: () -> Unit, onDismiss: () -> Unit, ) { - val context = LocalContext.current - val eventType = conflict.eventType + val presentation = presentationOf(conflict.diff) AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(eventType)))) }, + title = { Text(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType)))) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { - Text( - text = stringRes(eventTypeExplainer(eventType)), - style = MaterialTheme.typography.bodySmall, - fontStyle = FontStyle.Italic, - ) - Spacer(Modifier.height(12.dp)) - Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.cause.createdAt, context))) - - DiffDetails(conflict.diff) - + BackupConflictIntro(conflict) + SummarySection(R.string.backup_conflict_removed, presentation.removedCount, MaterialTheme.colorScheme.error, presentation.content == ContentChange.CLEARED, R.string.backup_conflict_private_cleared) { + presentation.groups.filter { it.removed.isNotEmpty() }.map { it.label to it.removed.size } + } + SummarySection(R.string.backup_conflict_added, presentation.addedCount, MaterialTheme.colorScheme.primary, presentation.content == ContentChange.ADDED, R.string.backup_conflict_private_added) { + presentation.groups.filter { it.added.isNotEmpty() }.map { it.label to it.added.size } + } + SummarySection(R.string.backup_conflict_changed, presentation.changedCount, MaterialTheme.colorScheme.tertiary, presentation.content == ContentChange.CHANGED, R.string.backup_conflict_private_changed) { + presentation.groups.filter { it.changed.isNotEmpty() }.map { it.label to it.changed.size } + } Spacer(Modifier.height(12.dp)) Text( text = stringRes(R.string.backup_conflict_restore_explainer), @@ -150,6 +120,7 @@ private fun BackupConflictDialog( }, dismissButton = { Column { + TextButton(onClick = onReview) { Text(stringRes(R.string.backup_conflict_review)) } TextButton(onClick = onKeepNew) { Text(stringRes(R.string.backup_conflict_keep_new)) } TextButton(onClick = onDismiss) { Text(stringRes(R.string.backup_conflict_decide_later)) } } @@ -157,325 +128,35 @@ private fun BackupConflictDialog( ) } -/** - * One labelled group of differences (people, relays, profile fields…), already turned into - * display lines. Built per event type from that event's own diff class. - */ -private class DiffGroup( - val label: Int, - val removed: List, - val added: List, - val changed: List, -) - -/** A group from a [ListDiff] of an event's parsed items. */ -private fun listGroup( - label: Int, - diff: ListDiff, - describe: (T) -> String, - describeChange: (ItemChange) -> String = { describe(it.before) + " → " + describe(it.after) }, -) = DiffGroup(label, diff.removed.map(describe), diff.added.map(describe), diff.changed.map(describeChange)) - -/** 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(name + ": " + clip(before)) - before == null && after != null -> added.add(name + ": " + clip(after)) - else -> changed.add(name + ": " + clip(before) + " → " + clip(after)) - } - } - return DiffGroup(label, removed, added, changed) -} - -/** Turns each event's own diff into display groups, plus the change to its encrypted content. */ +/** What the event is for and when the other app changed it; shared by the dialog and the review screen. */ @Composable -private fun presentation(diff: EventDiff): Pair, ContentChange> = - 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( - stringRes(R.string.backup_profile_field_name) to diff.name, - stringRes(R.string.backup_profile_field_display_name) to diff.displayName, - stringRes(R.string.backup_profile_field_about) to diff.about, - stringRes(R.string.backup_profile_field_picture) to diff.picture, - stringRes(R.string.backup_profile_field_banner) to diff.banner, - stringRes(R.string.backup_profile_field_website) to diff.website, - stringRes(R.string.backup_profile_field_nip05) to diff.nip05, - stringRes(R.string.backup_profile_field_lud16) to diff.lud16, - stringRes(R.string.backup_profile_field_lud06) to diff.lud06, - stringRes(R.string.backup_profile_field_clink_offer) to diff.clinkOffer, - stringRes(R.string.backup_profile_field_pronouns) to diff.pronouns, - stringRes(R.string.backup_profile_field_birthday) to birthday, - stringRes(R.string.backup_profile_field_bot) to bot, - ), - ), - listGroup(R.string.backup_entry_identity_claim, diff.identityClaims, { it.platformIdentity() }), - listGroup(R.string.backup_entry_other, diff.otherFields, { it.first + ": " + clip(it.second) }), - ) to ContentChange.NONE - } - is ContactListDiff -> - listOf( - listGroup( - R.string.backup_entry_person, - diff.follows, - { personName(it.pubKey) + (it.petname?.let { name -> " ($name)" } ?: "") }, - { - personName(it.before.pubKey) + ": " + - listOfNotNull(it.before.petname, it.before.relayUri?.url).joinToString() + " → " + - listOfNotNull(it.after.petname, it.after.relayUri?.url).joinToString() - }, - ), - ) to ContentChange.NONE - is MuteListDiff -> - listOf( - listGroup(R.string.backup_entry_person, diff.publicMutes.only(), { personName(it.pubKey) }), - listGroup(R.string.backup_entry_word, diff.publicMutes.only(), { "\"" + it.word + "\"" }), - listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only(), { "#" + it.hashtag }), - listGroup(R.string.backup_entry_thread, diff.publicMutes.only(), { it.eventId.toShortDisplay() }), - ) to diff.privateItems - is AdvertisedRelayListDiff -> { - val types = - mapOf( - AdvertisedRelayType.BOTH to stringRes(R.string.backup_conflict_relay_read_write), - AdvertisedRelayType.READ to stringRes(R.string.backup_conflict_relay_read_only), - AdvertisedRelayType.WRITE to stringRes(R.string.backup_conflict_relay_write_only), - ) - listOf( - listGroup( - R.string.backup_entry_relay, - diff.relays, - { it.relayUrl.url + " (" + types[it.type] + ")" }, - { it.before.relayUrl.url + ": " + types[it.before.type] + " → " + types[it.after.type] }, - ), - ) to ContentChange.NONE - } - is RelayListDiff -> listOf(listGroup(R.string.backup_entry_relay, diff.relays, { it.url })) to diff.privateRelays - is ChannelListDiff -> - listOf( - listGroup(R.string.backup_entry_public_chat, diff.channels, { channelName(it.eventId) }), - ) to diff.privateItems - is CommunityListDiff -> - listOf( - listGroup(R.string.backup_entry_community, diff.communities, { it.address.dTag.ifBlank { it.address.toValue() } }), - ) to diff.privateItems - is HashtagListDiff -> listOf(listGroup(R.string.backup_entry_hashtag, diff.hashtags, { "#$it" })) to diff.privateItems - is GeohashListDiff -> listOf(listGroup(R.string.backup_entry_location, diff.geohashes, { it })) to diff.privateItems - is FavoriteAlgoFeedsListDiff -> - listOf( - listGroup(R.string.backup_entry_algo_feed, diff.feeds, { it.address.dTag.ifBlank { it.address.toValue() } }), - ) to diff.privateItems - is EphemeralChatListDiff -> - listOf( - listGroup(R.string.backup_entry_chat_room, diff.rooms, { it.id + " @ " + it.relayUrl.url }), - ) to diff.privateItems - is SimpleGroupListDiff -> - listOf( - listGroup( - R.string.backup_entry_relay_group, - diff.groups, - { (it.name ?: it.groupId) + " @ " + it.relayUrl }, - { (it.before.name ?: it.before.groupId) + " → " + (it.after.name ?: it.after.groupId) }, - ), - ) to diff.privateItems - is TrustProviderListDiff -> - listOf( - listGroup( - R.string.backup_entry_trust_provider, - diff.providers, - { it.service.type + ": " + personName(it.pubkey) }, - { it.before.service.type + ": " + it.before.relayUrl.url + " → " + it.after.relayUrl.url }, - ), - ) to diff.privateItems - is NutzapInfoDiff -> - listOf( - listGroup( - R.string.backup_entry_mint, - diff.mints, - { it.mintUrl + (if (it.units.isEmpty()) "" else " (" + it.units.joinToString() + ")") }, - { it.before.mintUrl + ": " + it.before.units.joinToString() + " → " + it.after.units.joinToString() }, - ), - listGroup(R.string.backup_entry_relay, diff.relays, { it.url }), - fieldGroup( - R.string.backup_entry_nutzap_key, - listOf( - stringRes(R.string.backup_entry_nutzap_key) to - diff.p2pkPubkey?.let { ValueChange(it.before?.toShortDisplay(), it.after?.toShortDisplay()) }, - ), - ), - ) to ContentChange.NONE - is PaymentTargetsDiff -> - listOf( - listGroup(R.string.backup_entry_payment_target, diff.targets, { it.type + ": " + clip(it.authority) }), - ) to ContentChange.NONE - is Bolt12OfferListDiff -> listOf(listGroup(R.string.backup_entry_offer, diff.offers, { it.toShortDisplay() })) 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 - } - -@Composable -private fun DiffDetails(diff: EventDiff) { - val (allGroups, content) = presentation(diff) - val groups = allGroups.filter { it.removed.isNotEmpty() || it.added.isNotEmpty() || it.changed.isNotEmpty() } - - val removed = groups.sumOf { it.removed.size } - if (removed > 0 || content == ContentChange.CLEARED) { - SectionHeader(stringRes(R.string.backup_conflict_removed, removed.toString()), MaterialTheme.colorScheme.error) - if (content == ContentChange.CLEARED) ContentNote(R.string.backup_conflict_private_cleared) - groups.forEach { ItemGroup(it.label, it.removed) } - } - - val added = groups.sumOf { it.added.size } - if (added > 0 || content == ContentChange.ADDED) { - SectionHeader(stringRes(R.string.backup_conflict_added, added.toString()), MaterialTheme.colorScheme.primary) - if (content == ContentChange.ADDED) ContentNote(R.string.backup_conflict_private_added) - groups.forEach { ItemGroup(it.label, it.added) } - } - - val changed = groups.sumOf { it.changed.size } - if (changed > 0 || content == ContentChange.CHANGED) { - SectionHeader(stringRes(R.string.backup_conflict_changed, changed.toString()), MaterialTheme.colorScheme.tertiary) - if (content == ContentChange.CHANGED) ContentNote(R.string.backup_conflict_private_changed) - groups.forEach { ItemGroup(it.label, it.changed) } - } -} - -@Composable -private fun ContentNote(textRes: Int) { - Text("• " + stringRes(textRes), modifier = Modifier.padding(start = 8.dp)) -} - -@Composable -private fun SectionHeader( - text: String, - color: Color, -) { - Spacer(Modifier.height(12.dp)) - Text(text = text, style = MaterialTheme.typography.titleSmall, color = color) -} - -@Composable -private fun ItemGroup( - labelRes: Int, - items: List, -) { - if (items.isEmpty()) return +fun BackupConflictIntro(conflict: ReplaceableBackupConflict) { + val context = LocalContext.current Text( - text = stringRes(labelRes), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(top = 6.dp), + text = stringRes(eventTypeExplainer(conflict.eventType)), + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, ) - items.take(MAX_ITEMS_PER_GROUP).forEach { - Text( - text = "• $it", - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 8.dp), - ) - } - val more = items.size - MAX_ITEMS_PER_GROUP - if (more > 0) { - Text( - text = stringRes(R.string.backup_conflict_more_items, more.toString()), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 8.dp), - ) - } + Spacer(Modifier.height(12.dp)) + Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.cause.createdAt, context))) } -private fun personName(pubkey: String): String = LocalCache.getUserIfExists(pubkey)?.toBestDisplayName() ?: pubkey.toShortDisplay() - -private fun channelName(eventId: String): String = LocalCache.getPublicChatChannelIfExists(eventId)?.toBestDisplayName() ?: eventId.toShortDisplay() - -private fun birthdayText(birthday: Birthday): String = listOfNotNull(birthday.year, birthday.month, birthday.day).joinToString("-") - -private 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 +@Composable +private fun SummarySection( + titleRes: Int, + count: Int, + color: Color, + showContentNote: Boolean, + contentNoteRes: Int, + groups: () -> List>, +) { + if (count == 0 && !showContentNote) return + Spacer(Modifier.height(12.dp)) + Text(stringRes(titleRes, count.toString()), style = MaterialTheme.typography.titleSmall, color = color) + if (showContentNote) { + Text("• " + stringRes(contentNoteRes), modifier = Modifier.padding(start = 8.dp)) + } + groups().forEach { (label, size) -> + Text("• " + stringRes(label) + ": " + size, modifier = Modifier.padding(start = 8.dp)) + } } - -private 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 - } - -private 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/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt new file mode 100644 index 0000000000..c6b169901b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -0,0 +1,415 @@ +/* + * 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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +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 + +/** + * 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 ?: "∅") + +/** Turns each event's own diff into typed, labelled groups. */ +@Composable +fun presentationOf(diff: EventDiff): 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( + stringRes(R.string.backup_profile_field_name) to diff.name, + stringRes(R.string.backup_profile_field_display_name) to diff.displayName, + stringRes(R.string.backup_profile_field_about) to diff.about, + stringRes(R.string.backup_profile_field_picture) to diff.picture, + stringRes(R.string.backup_profile_field_banner) to diff.banner, + stringRes(R.string.backup_profile_field_website) to diff.website, + stringRes(R.string.backup_profile_field_nip05) to diff.nip05, + stringRes(R.string.backup_profile_field_lud16) to diff.lud16, + stringRes(R.string.backup_profile_field_lud06) to diff.lud06, + stringRes(R.string.backup_profile_field_clink_offer) to diff.clinkOffer, + stringRes(R.string.backup_profile_field_pronouns) to diff.pronouns, + stringRes(R.string.backup_profile_field_birthday) to birthday, + stringRes(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 stringRes(R.string.backup_conflict_relay_read_write), + AdvertisedRelayType.READ to stringRes(R.string.backup_conflict_relay_read_only), + AdvertisedRelayType.WRITE to stringRes(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..f9183b7122 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt @@ -0,0 +1,386 @@ +/* + * 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.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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +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.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.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.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.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.nip01Core.diff.ContentChange + +/** A row of the review list, flattened so hundreds of entries scroll lazily. */ +@Immutable +private 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 +} + +private enum class Tone { REMOVED, ADDED, CHANGED } + +private fun buildRows(presentation: DiffPresentation): 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 -> + 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. Offers the same three choices as [BackupConflictDialog]. + */ +@Composable +fun BackupConflictReviewScreen( + slot: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val settings = accountViewModel.account.settings + val conflicts by settings.backupConflicts.collectAsStateWithLifecycle() + val conflict = conflicts[slot] + + // Keeps the dialog from covering this screen while it is open. + DisposableEffect(slot) { + settings.reviewingBackupConflict.value = slot + onDispose { + if (settings.reviewingBackupConflict.value == slot) settings.reviewingBackupConflict.value = null + } + } + + // Resolved from here or elsewhere: nothing left to review. + if (conflict == null) { + LaunchedEffect(Unit) { nav.popBack() } + return + } + + BackupConflictReview( + conflict = conflict, + accountViewModel = accountViewModel, + nav = nav, + onRestore = { + accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } + nav.popBack() + }, + onKeepNew = { + accountViewModel.account.acceptExternalVersion(conflict) + nav.popBack() + }, + onDecideLater = { + settings.snoozeBackupConflict(conflict) + nav.popBack() + }, + ) +} + +@Composable +private fun BackupConflictReview( + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, + onRestore: () -> Unit, + onKeepNew: () -> Unit, + onDecideLater: () -> Unit, +) { + val presentation = presentationOf(conflict.diff) + val rows = remember(conflict) { buildRows(presentation) } + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))), nav) + }, + bottomBar = { ReviewActions(onRestore, onKeepNew, onDecideLater) }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding() + 16.dp), + ) { + item(key = "intro", contentType = "intro") { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + BackupConflictIntro(conflict) + Text( + text = stringRes(R.string.backup_conflict_restore_explainer), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + 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 -> + Text( + text = stringRes(row.labelRes), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 4.dp), + ) + is ReviewRow.Entry -> ReviewEntry(row.item, accountViewModel, nav) + } + } + } + } +} + +@Composable +private 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( + onRestore: () -> Unit, + onKeepNew: () -> Unit, + onDecideLater: () -> Unit, +) { + Surface(tonalElevation = 3.dp) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Button(onClick = onRestore, modifier = Modifier.fillMaxWidth()) { + Text(stringRes(R.string.backup_conflict_restore_mine)) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onKeepNew, modifier = Modifier.weight(1f)) { + Text(stringRes(R.string.backup_conflict_keep_new)) + } + TextButton(onClick = onDecideLater, modifier = Modifier.weight(1f)) { + Text(stringRes(R.string.backup_conflict_decide_later)) + } + } + } + } +} + +private val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + +@Composable +private 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 +private 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 +private 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b6a25ef8f3..8c1586b66b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2837,9 +2837,9 @@ All private (encrypted) items were removed. Private (encrypted) items also changed. They can\'t be compared here. Private (encrypted) items were added. - …and %1$s more Restoring brings back what was removed and drops what the other app added or changed. Restore saved version + Review changes Use new version Decide later read only From 43f603b0fdb1600b2b458ce8b7f2e452d6eff809 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:37:19 +0000 Subject: [PATCH 09/31] feat: per-event layouts in the backup conflict review Profiles compare each changed field saved vs new side by side, with picture and banner rendered as images and removed fields highlighted. NIP-65 relay lists render as a relay table with saved and new read/write access and a count of unchanged relays. Follow and mute lists get an on-this-device vs new-version count header above the linked entries. Other events keep the generic sections. All layouts are lazy list items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 9 +- .../backups/BackupConflictEventViews.kt | 319 ++++++++++++++++++ .../backups/BackupConflictReviewScreen.kt | 80 +++-- amethyst/src/main/res/values/strings.xml | 9 + 4 files changed, 384 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index e1b45f12db..a201f19e19 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -55,7 +55,14 @@ only surviving copy of the user's data is gone. profile), muted threads (`LoadNote` + `NoteCompose`), communities and feeds (`LoadAddressableNote` + `NoteCompose`), public chats (`observeChannel`, tap → chat), ephemeral rooms (tap → room) and relays (tap → relay info). The dialog hides while the - review screen is open (`AccountSettings.reviewingBackupConflict`). Both offer: + review screen is open (`AccountSettings.reviewingBackupConflict`). + + The list is laid out per event (`BackupConflictEventViews.kt`, `eventDiffItems`): a + profile compares each changed field saved vs new side by side, with picture and banner + shown as images and removed fields highlighted; a NIP-65 list is a relay table with the + saved and new read/write access and a count of unchanged relays; follow and mute lists + get a count header (on this device vs new version) above the linked people. Other + events use the generic removed / added / changed sections. Both offer: - **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)`, dropping the old `client` tag (so the signer 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..a39c3b1668 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt @@ -0,0 +1,319 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType + +/** + * The review list of a conflict, laid out for the kind of event that changed: a profile is + * compared field by field with its images side by side, a NIP-65 list as a relay table with + * read/write columns, follow and mute lists get a count header. Everything else uses the + * generic removed / added / changed sections. All layouts contribute lazy items, so lists + * with hundreds of entries scroll smoothly. + */ +internal fun LazyListScope.eventDiffItems( + conflict: ReplaceableBackupConflict, + presentation: DiffPresentation, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (val diff = conflict.diff) { + is MetadataDiff -> profileDiffItems(diff, presentation, accountViewModel, nav) + is AdvertisedRelayListDiff -> nip65DiffItems(diff, conflict, nav) + is ContactListDiff -> { + countHeader( + (conflict.saved as? ContactListEvent)?.followCount(), + (conflict.incoming as? ContactListEvent)?.followCount(), + R.string.backup_review_follow_counts, + ) + genericDiffItems(buildRows(presentation), accountViewModel, nav) + } + is MuteListDiff -> { + countHeader( + (conflict.saved as? MuteListEvent)?.countMutes(), + (conflict.incoming as? MuteListEvent)?.countMutes(), + R.string.backup_review_public_mute_counts, + ) + genericDiffItems(buildRows(presentation), accountViewModel, nav) + } + else -> genericDiffItems(buildRows(presentation), accountViewModel, nav) + } +} + +private fun LazyListScope.countHeader( + saved: Int?, + incoming: Int?, + textRes: Int, +) { + if (saved == null || incoming == null) return + item(key = "counts", contentType = "counts") { + Text( + text = stringRes(textRes, saved.toString(), incoming.toString()), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } +} + +// --------------------------------------------------------------------------------------- +// Profile: each changed field, saved vs new, images shown as images. +// --------------------------------------------------------------------------------------- + +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.profileDiffItems( + diff: MetadataDiff, + presentation: DiffPresentation, + accountViewModel: AccountViewModel, + nav: INav, +) { + val fields = profileFields(diff) + if (fields.isNotEmpty()) { + item(key = "profile-fields", contentType = "group") { GroupLabel(R.string.backup_entry_profile_field) } + items(fields, key = { "field-" + it.labelRes }, contentType = { if (it.isImage) "image-field" else "text-field" }) { + FieldComparison(it, accountViewModel) + } + } + // Identity claims 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 FieldComparison( + field: ProfileField, + accountViewModel: AccountViewModel, +) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(stringRes(field.labelRes), style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FieldSide(R.string.backup_review_saved, field.change.before, field.isImage, removed = field.change.after == null, Modifier.weight(1f), accountViewModel) + FieldSide(R.string.backup_review_new, field.change.after, field.isImage, removed = false, Modifier.weight(1f), accountViewModel) + } + } +} + +@Composable +private fun FieldSide( + captionRes: Int, + value: String?, + isImage: Boolean, + removed: Boolean, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + val background = if (removed) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant + Column( + modifier + .clip(RoundedCornerShape(8.dp)) + .background(background) + .padding(8.dp), + ) { + Text(stringRes(captionRes), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText) + when { + value == null -> + Text( + stringRes(R.string.backup_review_not_set), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + isImage -> + MyAsyncImage( + imageUrl = value, + contentDescription = null, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(6.dp)), + loadedImageModifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(6.dp)), + accountViewModel = accountViewModel, + onLoadingBackground = null, + onError = { Text(clip(value), style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) }, + ) + else -> Text(value, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) + } + } +} + +// --------------------------------------------------------------------------------------- +// NIP-65: a relay table with the saved and new read/write access side by side. +// --------------------------------------------------------------------------------------- + +private class RelayRowData( + val url: String, + val saved: AdvertisedRelayType?, + val new: AdvertisedRelayType?, +) + +private fun LazyListScope.nip65DiffItems( + diff: AdvertisedRelayListDiff, + conflict: ReplaceableBackupConflict, + nav: INav, +) { + val rows = + diff.relays.removed.map { RelayRowData(it.relayUrl.url, it.type, null) } + + diff.relays.changed.map { RelayRowData(it.before.relayUrl.url, it.before.type, it.after.type) } + + diff.relays.added.map { RelayRowData(it.relayUrl.url, null, it.type) } + + val savedCount = (conflict.saved as? AdvertisedRelayListEvent)?.relays()?.size ?: 0 + val unchanged = savedCount - diff.relays.removed.size - diff.relays.changed.size + + item(key = "relay-header", contentType = "relay-header") { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(stringRes(R.string.backup_entry_relay), Modifier.weight(2f), style = MaterialTheme.typography.labelLarge) + Text(stringRes(R.string.backup_review_saved), Modifier.weight(1f), style = MaterialTheme.typography.labelLarge) + Text(stringRes(R.string.backup_review_new), Modifier.weight(1f), style = MaterialTheme.typography.labelLarge) + } + } + items(rows, key = { "relay-" + it.url }, contentType = { "relay-row" }) { RelayTableRow(it, nav) } + if (unchanged > 0) { + item(key = "relay-unchanged", contentType = "note") { + DetailText( + stringRes(R.string.backup_review_unchanged_relays, unchanged.toString()), + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } +} + +@Composable +private fun RelayTableRow( + row: RelayRowData, + nav: INav, +) { + val removed = row.new == null + val added = row.saved == null + val urlColor = + when { + removed -> MaterialTheme.colorScheme.error + added -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.tertiary + } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.RelayInfo(row.url)) } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + row.url.removePrefix("wss://").removeSuffix("/"), + Modifier.weight(2f), + color = urlColor, + textDecoration = if (removed) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + AccessCell(row.saved, Modifier.weight(1f)) + AccessCell(row.new, Modifier.weight(1f), highlight = row.saved != row.new) + } +} + +@Composable +private fun AccessCell( + type: AdvertisedRelayType?, + modifier: Modifier, + highlight: Boolean = false, +) { + val text = + when (type) { + null -> "—" + AdvertisedRelayType.BOTH -> stringRes(R.string.backup_review_access_read_write) + AdvertisedRelayType.READ -> stringRes(R.string.backup_review_access_read) + AdvertisedRelayType.WRITE -> stringRes(R.string.backup_review_access_write) + } + Text( + text, + modifier, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (highlight) FontWeight.Bold else null, + color = if (type == null) MaterialTheme.colorScheme.placeholderText else Color.Unspecified, + ) +} 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 index f9183b7122..2fe71eeffc 100644 --- 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 @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider @@ -43,7 +44,6 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -72,7 +72,7 @@ import com.vitorpamplona.quartz.nip01Core.diff.ContentChange /** A row of the review list, flattened so hundreds of entries scroll lazily. */ @Immutable -private sealed interface ReviewRow { +internal sealed interface ReviewRow { class Section( val titleRes: Int, val count: Int, @@ -92,9 +92,12 @@ private sealed interface ReviewRow { ) : ReviewRow } -private enum class Tone { REMOVED, ADDED, CHANGED } +internal enum class Tone { REMOVED, ADDED, CHANGED } -private fun buildRows(presentation: DiffPresentation): List { +internal fun buildRows( + presentation: DiffPresentation, + skipGroups: Set = emptySet(), +): List { val rows = mutableListOf() fun section( @@ -108,6 +111,7 @@ private fun buildRows(presentation: DiffPresentation): List { 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)) @@ -181,7 +185,6 @@ private fun BackupConflictReview( onDecideLater: () -> Unit, ) { val presentation = presentationOf(conflict.diff) - val rows = remember(conflict) { buildRows(presentation) } Scaffold( topBar = { @@ -203,35 +206,48 @@ private fun BackupConflictReview( ) } } - 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 -> - Text( - text = stringRes(row.labelRes), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 4.dp), - ) - is ReviewRow.Entry -> ReviewEntry(row.item, accountViewModel, nav) - } + eventDiffItems(conflict, presentation, 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 -private fun SectionRow(row: ReviewRow.Section) { +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 @@ -273,10 +289,10 @@ private fun ReviewActions( } } -private val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) +internal val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) @Composable -private fun ReviewEntry( +internal fun ReviewEntry( item: ReviewItem, accountViewModel: AccountViewModel, nav: INav, @@ -353,7 +369,7 @@ private fun PublicChatEntry( } @Composable -private fun TextEntry( +internal fun TextEntry( text: String, detail: String?, onClick: (() -> Unit)? = null, @@ -371,7 +387,7 @@ private fun TextEntry( } @Composable -private fun DetailText( +internal fun DetailText( text: String, modifier: Modifier = Modifier, ) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8c1586b66b..7f51e82808 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2840,6 +2840,15 @@ Restoring brings back what was removed and drops what the other app added or changed. Restore saved version Review changes + On this device + New version + Not set + Follows: %1$s on this device · %2$s in the new version + Public mutes: %1$s on this device · %2$s in the new version + %1$s other relays are unchanged + Read & write + Read + Write Use new version Decide later read only From 10fb8e3c3f6cbab610dec5ef5da1dda49ed34b7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:04:37 +0000 Subject: [PATCH 10/31] feat: replace the backup conflict dialog with persistent Home cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each open conflict shows a card at the top of Home ("Your mute list changed in another app", "12 removed · 3 added") that stays until the user decides; more than two fold into one card with a row per conflict. Tapping opens the review screen to inspect the differences, which now offers only Restore saved version and Use new version. The dialog, its snooze and the hide-while-reviewing state are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 24 ++- .../amethyst/model/AccountSettings.kt | 21 +-- .../amethyst/ui/navigation/AppNavigation.kt | 3 - .../loggedIn/backups/BackupConflictCards.kt | 171 ++++++++++++++++++ .../loggedIn/backups/BackupConflictDialog.kt | 162 ----------------- .../backups/BackupConflictReviewScreen.kt | 58 +++--- .../ui/screen/loggedIn/home/HomeScreen.kt | 6 + amethyst/src/main/res/values/strings.xml | 5 +- 8 files changed, 223 insertions(+), 227 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index a201f19e19..d1207b222a 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -44,25 +44,26 @@ only surviving copy of the user's data is gone. backup is updated silently as before. 4. **Freeze and ask.** On a loss, the backup keeps the saved version and a `ReplaceableBackupConflict` is published on `AccountSettings.backupConflicts`. - `BackupConflictDialog` (shown from `AppNavigation`, so it can navigate) is a short - summary specific to the event: it names it ("Your mute list changed in another app"), - says what that event is for, and counts what was removed, added and changed per entry - type. **Review changes** opens `Route.BackupConflictReview(slot)` + Home shows a card per open conflict at the top of the feed (`BackupConflictCards`, + next to the key-backup nudge): "Your mute list changed in another app" with a + "12 removed · 3 added" summary. Cards can't be dismissed; they stay until the user + decides, and with more than two conflicts they fold into one card with a row per + conflict. 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). The dialog hides while the - review screen is open (`AccountSettings.reviewingBackupConflict`). + 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`): a profile compares each changed field saved vs new side by side, with picture and banner shown as images and removed fields highlighted; a NIP-65 list is a relay table with the saved and new read/write access and a count of unchanged relays; follow and mute lists get a count header (on this device vs new version) above the linked people. Other - events use the generic removed / added / changed sections. Both offer: + events use the generic removed / added / changed sections. 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)`, dropping the old `client` tag (so the signer @@ -73,12 +74,9 @@ only surviving copy of the user's data is gone. list names, since the lossy version may have dropped them from the outbox set. - **Use new version** — `Account.acceptExternalVersion` whitelists that id and re-runs the original update, so the backup moves forward. - - **Decide later** — snoozes the slot in `AccountSettings.snoozedBackupConflicts` while - the account is loaded (survives rotation) and until another app changes it again; the - backup stays frozen. Both resolutions first *claim* the conflict: they act only if it is still the open - conflict of its slot, so a stale dialog, a double tap, or a conflict dropped because the + 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. @@ -89,7 +87,7 @@ only surviving copy of the user's data is gone. 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 dialog shows that date) and keep it snoozed. + external version as its `cause` (the screen shows that date). Conflicts are not persisted: the frozen backup is reloaded on start, the newer relay version replaces it in `LocalCache`, and the same conflict is detected again. @@ -104,4 +102,4 @@ version replaces it in `LocalCache`, and the same conflict is detected again. 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 dialog yet. +- Desktop marks its own events too but has no backup store or conflict UI yet. 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 01c33f2042..361da15f95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1171,19 +1171,10 @@ class AccountSettings( * 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. Not persisted: * after a restart the same newer event is detected again against the same frozen backup. + * Home shows a card for each until the user keeps the new version or restores the saved one. */ val backupConflicts = MutableStateFlow>(emptyMap()) - /** - * Slots whose conflict the user chose to decide later. Lives as long as the account is - * loaded, so it survives activity recreation, and is cleared when another app changes - * the slot again (a new external change deserves a new question). - */ - val snoozedBackupConflicts = MutableStateFlow>(emptySet()) - - /** The slot whose review screen is open, so the conflict dialog doesn't cover it. */ - val reviewingBackupConflict = MutableStateFlow(null) - // Re-runs the update that raised each open conflict, once the user keeps the new version. private val backupConflictRetries = ConcurrentHashMap Unit>() @@ -1223,13 +1214,11 @@ class AccountSettings( if (reference != null && !acceptedExternalVersions.contains(incoming.id)) { val diff = ReplaceableBackupDiff.detectLoss(reference, incoming) if (diff != null) { - // A local edit keeps the external change as the conflict's cause, and keeps - // it snoozed; a new external change is a new question. + // A local edit keeps the external change as the conflict's cause. val cause = if (isLocal && pending != null) pending.cause else incoming val conflict = ReplaceableBackupConflict(reference, incoming, cause, diff) backupConflictRetries[slot] = retry backupConflicts.update { it + (slot to conflict) } - if (!isLocal) snoozedBackupConflicts.update { it - slot } return false } } @@ -1243,7 +1232,6 @@ class AccountSettings( backupConflicts.update { it - slot } } backupConflictRetries.remove(slot) - snoozedBackupConflicts.update { it - slot } } /** @@ -1257,7 +1245,6 @@ class AccountSettings( if (claimed) current - conflict.slot else current } if (!claimed) return null - snoozedBackupConflicts.update { it - conflict.slot } return backupConflictRetries.remove(conflict.slot) ?: {} } @@ -1299,10 +1286,6 @@ class AccountSettings( if (reopened) backupConflictRetries[conflict.slot] = token } - fun snoozeBackupConflict(conflict: ReplaceableBackupConflict) { - snoozedBackupConflicts.update { it + conflict.slot } - } - fun updateLocalRelayServers(servers: Set) { if (localRelayServers.value != servers) { localRelayServers.update { servers } 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 1c547bcdbb..f60b12f2a7 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 @@ -91,7 +91,6 @@ 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.BackupConflictDialog 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 @@ -404,8 +403,6 @@ fun AppNavigation( DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) - // Asks before another client's lossy rewrite of a backed-up list/profile becomes permanent. - BackupConflictDialog(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) DisplayResourceUsageAlert(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) 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..1d987880d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -0,0 +1,171 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange + +private const val MAX_INDIVIDUAL_CARDS = 2 + +/** + * Cards at the top of Home, one per open [ReplaceableBackupConflict]: another app replaced + * one of the account's backed-up lists (or the profile) with a version that lost data. They + * can't be dismissed. They stay until the user decides on the review screen they open + * ([BackupConflictReviewScreen]), where the differences can be inspected. With more than + * [MAX_INDIVIDUAL_CARDS] conflicts they fold into one card with a row per conflict, so they + * don't cover the feed. + */ +@Composable +fun BackupConflictCards( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val conflictMap by accountViewModel.account.settings.backupConflicts + .collectAsStateWithLifecycle() + if (conflictMap.isEmpty()) return + + val conflicts = conflictMap.values.sortedBy { it.cause.createdAt } + val open = { conflict: ReplaceableBackupConflict -> nav.nav(Route.BackupConflictReview(conflict.slot)) } + + if (conflicts.size <= MAX_INDIVIDUAL_CARDS) { + conflicts.forEach { conflict -> + ConflictCardSurface(modifier) { + ConflictRow( + title = stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))), + conflict = conflict, + onClick = { open(conflict) }, + ) + } + } + } else { + ConflictCardSurface(modifier) { + Column { + Text( + text = stringRes(R.string.backup_conflict_many_title, conflicts.size.toString()), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 4.dp), + ) + conflicts.forEach { conflict -> + ConflictRow( + title = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() }, + conflict = conflict, + onClick = { open(conflict) }, + ) + } + } + } + } +} + +@Composable +private fun ConflictCardSurface( + modifier: Modifier, + content: @Composable () -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, + content = content, + ) +} + +@Composable +private fun ConflictRow( + title: String, + conflict: ReplaceableBackupConflict, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.SyncProblem, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = StdHorzSpacer) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = conflictSummary(conflict), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = stringRes(R.string.backup_conflict_review), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** "12 removed · 3 added · 1 changed", counting encrypted content as one item. */ +@Composable +private fun conflictSummary(conflict: ReplaceableBackupConflict): String { + val presentation = presentationOf(conflict.diff) + val content = presentation.content + val removed = presentation.removedCount + if (content == ContentChange.CLEARED) 1 else 0 + val added = presentation.addedCount + if (content == ContentChange.ADDED) 1 else 0 + val changed = presentation.changedCount + if (content == ContentChange.CHANGED) 1 else 0 + return listOfNotNull( + if (removed > 0) stringRes(R.string.backup_conflict_count_removed, removed.toString()) else null, + if (added > 0) stringRes(R.string.backup_conflict_count_added, added.toString()) else null, + if (changed > 0) stringRes(R.string.backup_conflict_count_changed, changed.toString()) else null, + ).joinToString(" · ") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt deleted file mode 100644 index 877ea74a39..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictDialog.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R -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.timeAbsolute -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.diff.ContentChange - -/** - * Asks the user what to do when another client replaced one of the account's backed-up - * replaceable events with a version that lost data: keep the new one, or re-sign the - * version saved on this device over it. - * - * The dialog is a short summary (what changed, how many of each kind of entry); "Review - * changes" opens [BackupConflictReviewScreen], which lists every entry and links people, - * notes, chats and relays. The dialog stays hidden while that screen is open. - * - * One conflict at a time; "decide later" snoozes it while the account stays loaded (so it - * survives rotation) and until another app changes that event again: the conflict stays - * open, so the backup stays frozen on the saved version and the question comes back on the - * next launch. - */ -@Composable -fun BackupConflictDialog( - accountViewModel: AccountViewModel, - nav: INav, -) { - val settings = accountViewModel.account.settings - val conflicts by settings.backupConflicts.collectAsStateWithLifecycle() - val snoozed by settings.snoozedBackupConflicts.collectAsStateWithLifecycle() - val reviewing by settings.reviewingBackupConflict.collectAsStateWithLifecycle() - - val conflict = conflicts.values.firstOrNull { it.slot !in snoozed && it.slot != reviewing } ?: return - - BackupConflictDialog( - conflict = conflict, - onRestore = { accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }, - onKeepNew = { accountViewModel.account.acceptExternalVersion(conflict) }, - onReview = { nav.nav(Route.BackupConflictReview(conflict.slot)) }, - onDismiss = { settings.snoozeBackupConflict(conflict) }, - ) -} - -@Composable -private fun BackupConflictDialog( - conflict: ReplaceableBackupConflict, - onRestore: () -> Unit, - onKeepNew: () -> Unit, - onReview: () -> Unit, - onDismiss: () -> Unit, -) { - val presentation = presentationOf(conflict.diff) - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType)))) }, - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - BackupConflictIntro(conflict) - SummarySection(R.string.backup_conflict_removed, presentation.removedCount, MaterialTheme.colorScheme.error, presentation.content == ContentChange.CLEARED, R.string.backup_conflict_private_cleared) { - presentation.groups.filter { it.removed.isNotEmpty() }.map { it.label to it.removed.size } - } - SummarySection(R.string.backup_conflict_added, presentation.addedCount, MaterialTheme.colorScheme.primary, presentation.content == ContentChange.ADDED, R.string.backup_conflict_private_added) { - presentation.groups.filter { it.added.isNotEmpty() }.map { it.label to it.added.size } - } - SummarySection(R.string.backup_conflict_changed, presentation.changedCount, MaterialTheme.colorScheme.tertiary, presentation.content == ContentChange.CHANGED, R.string.backup_conflict_private_changed) { - presentation.groups.filter { it.changed.isNotEmpty() }.map { it.label to it.changed.size } - } - Spacer(Modifier.height(12.dp)) - Text( - text = stringRes(R.string.backup_conflict_restore_explainer), - style = MaterialTheme.typography.bodySmall, - ) - } - }, - confirmButton = { - Button(onClick = onRestore) { Text(stringRes(R.string.backup_conflict_restore_mine)) } - }, - dismissButton = { - Column { - TextButton(onClick = onReview) { Text(stringRes(R.string.backup_conflict_review)) } - TextButton(onClick = onKeepNew) { Text(stringRes(R.string.backup_conflict_keep_new)) } - TextButton(onClick = onDismiss) { Text(stringRes(R.string.backup_conflict_decide_later)) } - } - }, - ) -} - -/** What the event is for and when the other app changed it; shared by the dialog and the review screen. */ -@Composable -fun BackupConflictIntro(conflict: ReplaceableBackupConflict) { - val context = LocalContext.current - Text( - text = stringRes(eventTypeExplainer(conflict.eventType)), - style = MaterialTheme.typography.bodySmall, - fontStyle = FontStyle.Italic, - ) - Spacer(Modifier.height(12.dp)) - Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.cause.createdAt, context))) -} - -@Composable -private fun SummarySection( - titleRes: Int, - count: Int, - color: Color, - showContentNote: Boolean, - contentNoteRes: Int, - groups: () -> List>, -) { - if (count == 0 && !showContentNote) return - Spacer(Modifier.height(12.dp)) - Text(stringRes(titleRes, count.toString()), style = MaterialTheme.typography.titleSmall, color = color) - if (showContentNote) { - Text("• " + stringRes(contentNoteRes), modifier = Modifier.padding(start = 8.dp)) - } - groups().forEach { (label, size) -> - Text("• " + stringRes(label) + ": " + size, modifier = Modifier.padding(start = 8.dp)) - } -} 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 index 2fe71eeffc..8c7fb61eab 100644 --- 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 @@ -25,8 +25,10 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -38,14 +40,15 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +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 @@ -63,6 +66,7 @@ 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.timeAbsolute import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes @@ -130,7 +134,9 @@ internal fun buildRows( /** * 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. Offers the same three choices as [BackupConflictDialog]. + * 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( @@ -142,14 +148,6 @@ fun BackupConflictReviewScreen( val conflicts by settings.backupConflicts.collectAsStateWithLifecycle() val conflict = conflicts[slot] - // Keeps the dialog from covering this screen while it is open. - DisposableEffect(slot) { - settings.reviewingBackupConflict.value = slot - onDispose { - if (settings.reviewingBackupConflict.value == slot) settings.reviewingBackupConflict.value = null - } - } - // Resolved from here or elsewhere: nothing left to review. if (conflict == null) { LaunchedEffect(Unit) { nav.popBack() } @@ -168,10 +166,6 @@ fun BackupConflictReviewScreen( accountViewModel.account.acceptExternalVersion(conflict) nav.popBack() }, - onDecideLater = { - settings.snoozeBackupConflict(conflict) - nav.popBack() - }, ) } @@ -182,7 +176,6 @@ private fun BackupConflictReview( nav: INav, onRestore: () -> Unit, onKeepNew: () -> Unit, - onDecideLater: () -> Unit, ) { val presentation = presentationOf(conflict.diff) @@ -190,7 +183,7 @@ private fun BackupConflictReview( topBar = { TopBarWithBackButton(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))), nav) }, - bottomBar = { ReviewActions(onRestore, onKeepNew, onDecideLater) }, + bottomBar = { ReviewActions(onRestore, onKeepNew) }, ) { padding -> LazyColumn( modifier = Modifier.fillMaxSize(), @@ -267,28 +260,35 @@ internal fun SectionRow(row: ReviewRow.Section) { private fun ReviewActions( onRestore: () -> Unit, onKeepNew: () -> Unit, - onDecideLater: () -> Unit, ) { Surface(tonalElevation = 3.dp) { - Column( + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Button(onClick = onRestore, modifier = Modifier.fillMaxWidth()) { - Text(stringRes(R.string.backup_conflict_restore_mine)) + OutlinedButton(onClick = onKeepNew, modifier = Modifier.weight(1f)) { + Text(stringRes(R.string.backup_conflict_keep_new), textAlign = TextAlign.Center) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onKeepNew, modifier = Modifier.weight(1f)) { - Text(stringRes(R.string.backup_conflict_keep_new)) - } - TextButton(onClick = onDecideLater, modifier = Modifier.weight(1f)) { - Text(stringRes(R.string.backup_conflict_decide_later)) - } + Button(onClick = onRestore, modifier = Modifier.weight(1f)) { + Text(stringRes(R.string.backup_conflict_restore_mine), textAlign = TextAlign.Center) } } } } +/** What the event is for and when the other app changed it. */ +@Composable +private fun BackupConflictIntro(conflict: ReplaceableBackupConflict) { + val context = LocalContext.current + Text( + text = stringRes(eventTypeExplainer(conflict.eventType)), + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + ) + Spacer(Modifier.height(12.dp)) + Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.cause.createdAt, context))) +} + internal val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) @Composable 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 e9ae7e948e..b094fa4348 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 @@ -86,6 +86,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 @@ -300,6 +301,11 @@ private fun HomePages( .align(Alignment.TopCenter) .padding(top = paddingValues.calculateTopPadding()), ) { + BackupConflictCards( + accountViewModel = accountViewModel, + nav = nav, + ) + BackupKeysNudge( accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7f51e82808..c72fe2fe7e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2849,8 +2849,11 @@ Read & write Read Write + %1$s of your lists changed in other apps + %1$s removed + %1$s added + %1$s changed Use new version - Decide later read only write only read and write From 82e78a1c285b7e9e14bca8e33505e11f25c7a9d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:10:05 +0000 Subject: [PATCH 11/31] fix: handle 3-button navigation and IME insets in the backup conflict UI The review screen's action bar pads for the navigation bar and the keyboard (Scaffold only insets its content slot), and its list consumes the scaffold insets, applies imePaddingSafe and keeps the start/end padding so a side 3-button bar in landscape doesn't cover it. Home conflict cards pad for horizontal system bars and display cutouts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../loggedIn/backups/BackupConflictCards.kt | 13 ++++++- .../backups/BackupConflictReviewScreen.kt | 35 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) 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 index 1d987880d1..897efbde2f 100644 --- 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 @@ -24,8 +24,13 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -107,7 +112,13 @@ private fun ConflictCardSurface( content: @Composable () -> Unit, ) { Surface( - modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + // Home's padding only covers the top bar; in landscape a side 3-button navigation bar + // or a display cutout would otherwise sit on top of the card. + modifier = + modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = 12.dp, vertical = 6.dp), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 4.dp, 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 index 8c7fb61eab..5a038cac45 100644 --- 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 @@ -26,9 +26,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.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 @@ -47,6 +51,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -57,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflic 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 @@ -185,9 +191,23 @@ private fun BackupConflictReview( }, bottomBar = { ReviewActions(onRestore, onKeepNew) }, ) { padding -> + val layoutDirection = LocalLayoutDirection.current LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding() + 16.dp), + // 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") { Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { @@ -263,7 +283,16 @@ private fun ReviewActions( ) { Surface(tonalElevation = 3.dp) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + // 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 = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { OutlinedButton(onClick = onKeepNew, modifier = Modifier.weight(1f)) { From 097cd4ce1ddc543123d3dd264676de0f5e73ddea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:25:51 +0000 Subject: [PATCH 12/31] feat: per-event visual redesign of backup conflict cards and review Builds the HTML design in Compose. Home: a lead card with a headline and the change pictured (follow list split bar, or lost/gained counts) plus slim pills for other conflicts. Review: follow list saved-vs-new counts, split bar, Dropped/New/Edited tabs and a searchable lazy grid of faces; mute list shield, per-kind tiles, face pile, struck-through words and a private-items card; profile side-by-side mini cards and inline field diffs with image thumbnails; NIP-65 reach numbers over outbox/inbox lanes; nutzap key swap as color fingerprints and mint rows; group tiles faded when you'd leave them. Colors come from the theme; buttons are worded per event. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 18 +- .../loggedIn/backups/BackupConflictCards.kt | 234 ++-- .../backups/BackupConflictEventViews.kt | 1113 ++++++++++++++--- .../backups/BackupConflictReviewScreen.kt | 99 +- .../loggedIn/backups/BackupConflictVisuals.kt | 253 ++++ amethyst/src/main/res/values/strings.xml | 58 +- 6 files changed, 1481 insertions(+), 294 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictVisuals.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index d1207b222a..739e6a8ebc 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -58,12 +58,18 @@ only surviving copy of the user's data is gone. 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`): a - profile compares each changed field saved vs new side by side, with picture and banner - shown as images and removed fields highlighted; a NIP-65 list is a relay table with the - saved and new read/write access and a count of unchanged relays; follow and mute lists - get a count header (on this device vs new version) above the linked people. Other - events use the generic removed / added / changed sections. The screen offers: + 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 reach numbers ("people + find your posts on 2 of 3 relays") over 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'd leave it. Other events use the generic removed / added / changed sections. + Buttons are worded per event ("Keep 120" / "Restore 523", "Re-mute 37", "Rejoin 3"). + 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)`, dropping the old `client` tag (so the signer 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 index 897efbde2f..77a66795a0 100644 --- 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 @@ -20,17 +20,22 @@ */ 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.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -39,8 +44,12 @@ 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.clip +import androidx.compose.ui.platform.LocalContext +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 androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -48,20 +57,24 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.diff.ContentChange - -private const val MAX_INDIVIDUAL_CARDS = 2 +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff /** * Cards at the top of Home, one per open [ReplaceableBackupConflict]: another app replaced * one of the account's backed-up lists (or the profile) with a version that lost data. They * can't be dismissed. They stay until the user decides on the review screen they open - * ([BackupConflictReviewScreen]), where the differences can be inspected. With more than - * [MAX_INDIVIDUAL_CARDS] conflicts they fold into one card with a row per conflict, so they - * don't cover the feed. + * ([BackupConflictReviewScreen]), where the differences can be inspected. + * + * The most recent conflict gets a lead card with a headline and a picture of the change + * (the follow list's shrink bar, or lost/gained counts); the rest collapse into slim pills + * so they don't cover the feed. */ @Composable fun BackupConflictCards( @@ -73,110 +86,143 @@ fun BackupConflictCards( .collectAsStateWithLifecycle() if (conflictMap.isEmpty()) return - val conflicts = conflictMap.values.sortedBy { it.cause.createdAt } + val conflicts = conflictMap.values.sortedByDescending { it.cause.createdAt } val open = { conflict: ReplaceableBackupConflict -> nav.nav(Route.BackupConflictReview(conflict.slot)) } - if (conflicts.size <= MAX_INDIVIDUAL_CARDS) { - conflicts.forEach { conflict -> - ConflictCardSurface(modifier) { - ConflictRow( - title = stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))), - conflict = conflict, - onClick = { open(conflict) }, - ) - } - } - } else { - ConflictCardSurface(modifier) { - Column { - Text( - text = stringRes(R.string.backup_conflict_many_title, conflicts.size.toString()), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 4.dp), - ) - conflicts.forEach { conflict -> - ConflictRow( - title = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() }, - conflict = conflict, - onClick = { open(conflict) }, - ) - } - } - } + // Home's padding only covers the top bar; in landscape a side 3-button navigation bar + // or a display cutout would otherwise sit on top of the cards. + Column( + modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LeadConflictCard(conflicts.first()) { open(conflicts.first()) } + conflicts.drop(1).forEach { conflict -> ConflictPill(conflict) { open(conflict) } } } } +/** Items lost, gained and changed, counting encrypted content as one item. */ +private class ConflictCounts( + val removed: Int, + val added: Int, + val changed: Int, +) + @Composable -private fun ConflictCardSurface( - modifier: Modifier, - content: @Composable () -> Unit, -) { - Surface( - // Home's padding only covers the top bar; in landscape a side 3-button navigation bar - // or a display cutout would otherwise sit on top of the card. - modifier = - modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) - .padding(horizontal = 12.dp, vertical = 6.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 4.dp, - shadowElevation = 4.dp, - content = content, +private fun countsOf(conflict: ReplaceableBackupConflict): ConflictCounts { + val presentation = presentationOf(conflict.diff) + val content = presentation.content + return ConflictCounts( + removed = presentation.removedCount + if (content == ContentChange.CLEARED) 1 else 0, + added = presentation.addedCount + if (content == ContentChange.ADDED) 1 else 0, + changed = presentation.changedCount + if (content == ContentChange.CHANGED) 1 else 0, ) } +/** A headline that says what happened, in the event's own terms. */ @Composable -private fun ConflictRow( - title: String, +private fun headlineOf( + conflict: ReplaceableBackupConflict, + counts: ConflictCounts, +): String = + when (conflict.diff) { + is ContactListDiff -> stringRes(R.string.backup_card_follows_shrank) + is MuteListDiff -> stringRes(R.string.backup_card_mutes_visible, counts.removed.toString()) + else -> stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) + } + +@Composable +private fun LeadConflictCard( conflict: ReplaceableBackupConflict, onClick: () -> Unit, ) { - Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(12.dp), - verticalAlignment = Alignment.CenterVertically, + val tones = conflictTones() + val counts = countsOf(conflict) + 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, ) { - Icon( - symbol = MaterialSymbols.SyncProblem, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = StdHorzSpacer) - Column(Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = conflictSummary(conflict), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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 = 2, overflow = TextOverflow.Ellipsis) + Text( + stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt, LocalContext.current)), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = stringRes(R.string.backup_conflict_review), tint = MaterialTheme.colorScheme.placeholderText) + } + val diff = conflict.diff + if (diff is ContactListDiff) { + val saved = (conflict.saved as? ContactListEvent)?.followCount() ?: 0 + val new = (conflict.incoming as? ContactListEvent)?.followCount() ?: 0 + 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 = tones.removed) + } + } else { + CountChips(counts) + } } - Icon( - symbol = MaterialSymbols.ChevronRight, - contentDescription = stringRes(R.string.backup_conflict_review), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } -/** "12 removed · 3 added · 1 changed", counting encrypted content as one item. */ @Composable -private fun conflictSummary(conflict: ReplaceableBackupConflict): String { - val presentation = presentationOf(conflict.diff) - val content = presentation.content - val removed = presentation.removedCount + if (content == ContentChange.CLEARED) 1 else 0 - val added = presentation.addedCount + if (content == ContentChange.ADDED) 1 else 0 - val changed = presentation.changedCount + if (content == ContentChange.CHANGED) 1 else 0 - return listOfNotNull( - if (removed > 0) stringRes(R.string.backup_conflict_count_removed, removed.toString()) else null, - if (added > 0) stringRes(R.string.backup_conflict_count_added, added.toString()) else null, - if (changed > 0) stringRes(R.string.backup_conflict_count_changed, changed.toString()) else null, - ).joinToString(" · ") +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, + onClick: () -> Unit, +) { + val tones = conflictTones() + val counts = countsOf(conflict) + 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 = 1, overflow = TextOverflow.Ellipsis) + if (counts.removed > 0) StatusTag("−" + counts.removed, tones.removed) + } + } } 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 index a39c3b1668..95bce07284 100644 --- 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 @@ -21,21 +21,37 @@ 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.aspectRatio +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.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +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 @@ -43,77 +59,490 @@ 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.util.toShortDisplay +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.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.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.nip02FollowList.ContactListDiff import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff -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.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.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: a profile is - * compared field by field with its images side by side, a NIP-65 list as a relay table with - * read/write columns, follow and mute lists get a count header. Everything else uses the - * generic removed / added / changed sections. All layouts contribute lazy items, so lists - * with hundreds of entries scroll smoothly. + * 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 MetadataDiff -> profileDiffItems(diff, presentation, accountViewModel, nav) - is AdvertisedRelayListDiff -> nip65DiffItems(diff, conflict, nav) - is ContactListDiff -> { - countHeader( - (conflict.saved as? ContactListEvent)?.followCount(), - (conflict.incoming as? ContactListEvent)?.followCount(), - R.string.backup_review_follow_counts, - ) - genericDiffItems(buildRows(presentation), accountViewModel, nav) - } - is MuteListDiff -> { - countHeader( - (conflict.saved as? MuteListEvent)?.countMutes(), - (conflict.incoming as? MuteListEvent)?.countMutes(), - R.string.backup_review_public_mute_counts, - ) - genericDiffItems(buildRows(presentation), accountViewModel, nav) - } + 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) else -> genericDiffItems(buildRows(presentation), accountViewModel, nav) } } -private fun LazyListScope.countHeader( - saved: Int?, - incoming: Int?, - textRes: Int, +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, ) { - if (saved == null || incoming == null) return - item(key = "counts", contentType = "counts") { + val saved = (conflict.saved as? ContactListEvent)?.followCount() ?: 0 + val new = (conflict.incoming as? ContactListEvent)?.followCount() ?: 0 + 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 = { ui.tab = it }, + 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, +) { + val names = people.take(2).map { LocalCache.getUserIfExists(it)?.toBestDisplayName() ?: it.toShortDisplay() } + 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 = LocalCache.getUserIfExists(pubKey)?.profilePicture(), + contentDescription = null, + modifier = Modifier.size(42.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + } + } + } Text( - text = stringRes(textRes, saved.toString(), incoming.toString()), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + summary, + modifier = Modifier.padding(start = 12.dp).weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } +@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 +private 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: each changed field, saved vs new, images shown as images. +// Profile: both versions side by side, then field-by-field inline diffs. // --------------------------------------------------------------------------------------- private class ProfileField( @@ -147,173 +576,547 @@ private fun profileFields(diff: MetadataDiff): List = }, ) -private fun LazyListScope.profileDiffItems( +private fun LazyListScope.profileItems( diff: MetadataDiff, + conflict: ReplaceableBackupConflict, presentation: DiffPresentation, accountViewModel: AccountViewModel, nav: INav, ) { - val fields = profileFields(diff) - if (fields.isNotEmpty()) { - item(key = "profile-fields", contentType = "group") { GroupLabel(R.string.backup_entry_profile_field) } - items(fields, key = { "field-" + it.labelRes }, contentType = { if (it.isImage) "image-field" else "text-field" }) { - FieldComparison(it, accountViewModel) + 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)) } } - // Identity claims and fields this app doesn't model use the generic sections. + + 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 FieldComparison( - field: ProfileField, +private fun MiniProfileCard( + meta: UserMetadata, + pubKey: HexKey, + label: String, + borderColor: Color, accountViewModel: AccountViewModel, + modifier: Modifier, ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { - Text(stringRes(field.labelRes), style = MaterialTheme.typography.labelLarge) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - FieldSide(R.string.backup_review_saved, field.change.before, field.isImage, removed = field.change.after == null, Modifier.weight(1f), accountViewModel) - FieldSide(R.string.backup_review_new, field.change.after, field.isImage, removed = false, Modifier.weight(1f), accountViewModel) + 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 FieldSide( - captionRes: Int, - value: String?, - isImage: Boolean, - removed: Boolean, - modifier: Modifier, +private fun FieldDiffRow( + field: ProfileField, accountViewModel: AccountViewModel, ) { - val background = if (removed) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant - Column( - modifier - .clip(RoundedCornerShape(8.dp)) - .background(background) - .padding(8.dp), - ) { - Text(stringRes(captionRes), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText) - when { - value == null -> - Text( - stringRes(R.string.backup_review_not_set), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - isImage -> - MyAsyncImage( - imageUrl = value, - contentDescription = null, - contentScale = ContentScale.Crop, - mainImageModifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(6.dp)), - loadedImageModifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(6.dp)), - accountViewModel = accountViewModel, - onLoadingBackground = null, - onError = { Text(clip(value), style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) }, - ) - else -> Text(value, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) + 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) } } } -// --------------------------------------------------------------------------------------- -// NIP-65: a relay table with the saved and new read/write access side by side. -// --------------------------------------------------------------------------------------- - -private class RelayRowData( - val url: String, - val saved: AdvertisedRelayType?, - val new: AdvertisedRelayType?, -) - -private fun LazyListScope.nip65DiffItems( - diff: AdvertisedRelayListDiff, - conflict: ReplaceableBackupConflict, - nav: INav, +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FlowRowDiff( + before: String?, + after: String?, ) { - val rows = - diff.relays.removed.map { RelayRowData(it.relayUrl.url, it.type, null) } + - diff.relays.changed.map { RelayRowData(it.before.relayUrl.url, it.before.type, it.after.type) } + - diff.relays.added.map { RelayRowData(it.relayUrl.url, null, it.type) } - - val savedCount = (conflict.saved as? AdvertisedRelayListEvent)?.relays()?.size ?: 0 - val unchanged = savedCount - diff.relays.removed.size - diff.relays.changed.size - - item(key = "relay-header", contentType = "relay-header") { - Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { - Text(stringRes(R.string.backup_entry_relay), Modifier.weight(2f), style = MaterialTheme.typography.labelLarge) - Text(stringRes(R.string.backup_review_saved), Modifier.weight(1f), style = MaterialTheme.typography.labelLarge) - Text(stringRes(R.string.backup_review_new), Modifier.weight(1f), style = MaterialTheme.typography.labelLarge) + 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) + } } } - items(rows, key = { "relay-" + it.url }, contentType = { "relay-row" }) { RelayTableRow(it, nav) } - if (unchanged > 0) { - item(key = "relay-unchanged", contentType = "note") { - DetailText( - stringRes(R.string.backup_review_unchanged_relays, unchanged.toString()), - Modifier.padding(horizontal = 16.dp, vertical = 8.dp), +} + +@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) }, ) } } } -@Composable -private fun RelayTableRow( - row: RelayRowData, +// --------------------------------------------------------------------------------------- +// 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 removed = row.new == null - val added = row.saved == null - val urlColor = - when { - removed -> MaterialTheme.colorScheme.error - added -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.tertiary + 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) + + item(key = "relay-reach", contentType = "reach") { + Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ReachTile(stringRes(R.string.backup_review_reach_posts), outbox, Modifier.weight(1f)) + ReachTile(stringRes(R.string.backup_review_reach_replies), inbox, Modifier.weight(1f)) + } + } + 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) +} + +@Composable +private fun ReachTile( + caption: String, + lane: List, + modifier: Modifier, +) { + val tones = conflictTones() + val had = lane.count { it.state != LaneState.ADDED } + val kept = lane.count { it.state == LaneState.KEPT } + val color = if (kept < had) tones.removed else tones.added + Column(modifier.clip(RoundedCornerShape(20.dp)).background(color.copy(alpha = 0.12f)).padding(14.dp)) { + Text(caption, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(kept.toString(), fontSize = 34.sp, lineHeight = 36.sp, fontWeight = FontWeight.Bold, color = color) + Text(stringRes(R.string.backup_review_of_relays, had.toString()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp)) + } + } +} + +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( - modifier = - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.RelayInfo(row.url)) } - .padding(horizontal = 16.dp, vertical = 8.dp), + 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.weight(2f), - color = urlColor, - textDecoration = if (removed) TextDecoration.LineThrough else null, + 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, ) - AccessCell(row.saved, Modifier.weight(1f)) - AccessCell(row.new, Modifier.weight(1f), highlight = row.saved != row.new) + 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) + } + } + } + } + } + + val saved = (conflict.saved as? NutzapInfoEvent)?.mints().orEmpty() + val removedUrls = + diff.mints.removed + .map { it.mintUrl } + .toSet() + val changedUrls = diff.mints.changed.associate { it.after.mintUrl to it } + val mintRows = + saved.map { m -> + when (m.mintUrl) { + in removedUrls -> Triple(m.mintUrl, m.units.joinToString(), LaneState.DROPPED) + in changedUrls -> Triple(m.mintUrl, changedUrls.getValue(m.mintUrl).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 AccessCell( - type: AdvertisedRelayType?, - modifier: Modifier, - highlight: Boolean = false, +private fun MintRow( + url: String, + units: String, + state: LaneState, ) { - val text = - when (type) { - null -> "—" - AdvertisedRelayType.BOTH -> stringRes(R.string.backup_review_access_read_write) - AdvertisedRelayType.READ -> stringRes(R.string.backup_review_access_read) - AdvertisedRelayType.WRITE -> stringRes(R.string.backup_review_access_write) + 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 } - Text( - text, - modifier, - style = MaterialTheme.typography.bodySmall, - fontWeight = if (highlight) FontWeight.Bold else null, - color = if (type == null) MaterialTheme.colorScheme.placeholderText else Color.Unspecified, - ) + 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'd leave 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, +) { + val key = { g: GroupTag -> g.groupId + "@" + g.relayUrl } + val removed = + diff.groups.removed + .map(key) + .toSet() + val changed = diff.groups.changed.associateBy { key(it.after) } + val saved = (conflict.saved as? SimpleGroupListEvent)?.publicGroups().orEmpty() + 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_would_leave), + 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, Modifier.weight(1f)) } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } +} + +@Composable +private fun GroupTileCard( + tile: GroupTile, + modifier: Modifier, +) { + val tones = conflictTones() + val name = tile.group.name ?: tile.group.groupId + val hue = (name.hashCode() and 0xFFFF) / 65535f * 360f + Box(modifier.clip(RoundedCornerShape(20.dp)).background(MaterialTheme.colorScheme.surfaceContainer).padding(14.dp)) { + Column( + Modifier.alpha(if (tile.state == LaneState.DROPPED) 0.45f else 1f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(Color.hsl(hue, 0.6f, 0.72f)), contentAlignment = Alignment.Center) { + Text(name.take(1).uppercase(), color = Color(0xFF17111F), fontWeight = FontWeight.ExtraBold, fontSize = 18.sp) + } + 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/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt index 5a038cac45..a6a678e036 100644 --- 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 @@ -25,7 +25,6 @@ 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.Spacer import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets @@ -48,11 +47,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.text.font.FontStyle +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 @@ -72,13 +72,20 @@ 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.timeAbsolute +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.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +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.nip65RelayList.AdvertisedRelayListDiff /** A row of the review list, flattened so hundreds of entries scroll lazily. */ @Immutable @@ -184,12 +191,14 @@ private fun BackupConflictReview( onKeepNew: () -> Unit, ) { val presentation = presentationOf(conflict.diff) + val ui = remember(conflict.slot) { ReviewUiState() } + val (keepLabel, restoreLabel) = actionLabels(conflict) Scaffold( topBar = { - TopBarWithBackButton(stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))), nav) + TopBarWithBackButton(eventTitle(conflict), nav) }, - bottomBar = { ReviewActions(onRestore, onKeepNew) }, + bottomBar = { ReviewActions(keepLabel, restoreLabel, onRestore, onKeepNew) }, ) { padding -> val layoutDirection = LocalLayoutDirection.current LazyColumn( @@ -210,16 +219,9 @@ private fun BackupConflictReview( ), ) { item(key = "intro", contentType = "intro") { - Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - BackupConflictIntro(conflict) - Text( - text = stringRes(R.string.backup_conflict_restore_explainer), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 8.dp), - ) - } + BackupConflictIntro(conflict, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } - eventDiffItems(conflict, presentation, accountViewModel, nav) + eventDiffItems(conflict, presentation, ui, accountViewModel, nav) } } } @@ -278,6 +280,8 @@ internal fun SectionRow(row: ReviewRow.Section) { @Composable private fun ReviewActions( + keepLabel: String, + restoreLabel: String, onRestore: () -> Unit, onKeepNew: () -> Unit, ) { @@ -292,30 +296,65 @@ private fun ReviewActions( .fillMaxWidth() .navigationBarsPadding() .imePaddingSafe() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - OutlinedButton(onClick = onKeepNew, modifier = Modifier.weight(1f)) { - Text(stringRes(R.string.backup_conflict_keep_new), textAlign = TextAlign.Center) + OutlinedButton(onClick = onKeepNew, modifier = Modifier.weight(1f).height(52.dp)) { + Text(keepLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold) } - Button(onClick = onRestore, modifier = Modifier.weight(1f)) { - Text(stringRes(R.string.backup_conflict_restore_mine), textAlign = TextAlign.Center) + Button(onClick = onRestore, modifier = Modifier.weight(1f).height(52.dp)) { + Text(restoreLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) } } } } -/** What the event is for and when the other app changed it. */ +/** "Follow list", "Mute list"…: the event's name as a title. */ @Composable -private fun BackupConflictIntro(conflict: ReplaceableBackupConflict) { - val context = LocalContext.current - Text( - text = stringRes(eventTypeExplainer(conflict.eventType)), - style = MaterialTheme.typography.bodySmall, - fontStyle = FontStyle.Italic, - ) - Spacer(Modifier.height(12.dp)) - Text(stringRes(R.string.backup_conflict_intro, timeAbsolute(conflict.cause.createdAt, context))) +internal fun eventTitle(conflict: ReplaceableBackupConflict): String = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() } + +/** 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 = (conflict.saved as? ContactListEvent)?.followCount() ?: 0 + val new = (conflict.incoming as? ContactListEvent)?.followCount() ?: 0 + 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 AdvertisedRelayListDiff, is RelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) + else -> stringRes(R.string.backup_conflict_keep_new) to 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, LocalContext.current)), + 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) 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c72fe2fe7e..6cadf20f70 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2830,29 +2830,69 @@ %1$d videos Your %1$s changed in another app - Another app published a new version on %1$s. Compared with the copy saved on this device: Removed (%1$s) Added (%1$s) Changed (%1$s) All private (encrypted) items were removed. Private (encrypted) items also changed. They can\'t be compared here. Private (encrypted) items were added. - Restoring brings back what was removed and drops what the other app added or changed. Restore saved version Review changes On this device New version Not set - Follows: %1$s on this device · %2$s in the new version - Public mutes: %1$s on this device · %2$s in the new version - %1$s other relays are unchanged - Read & write - Read - Write - %1$s of your lists changed in other apps %1$s removed %1$s added %1$s changed + Your follow list shrank + %1$s muted things are visible again + Changed by 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 + Private items were wiped + They\'re encrypted, so only you can see what they were. Restoring brings them all back. + Items were made private + They\'re encrypted now, which may be where the public items above went. + Private items were rewritten + They\'re encrypted, so they can\'t be compared here. + Gone + New + Dropped + Moved + Units + Left + Renamed + People find your posts on + Replies reach you on + of %1$s relays + 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. + you\'d leave + joined + renamed + was “%1$s” Use new version read only write only From 6a7d5917c05e9a4df63f0b9bfdca05d875fbd265 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:38:16 +0000 Subject: [PATCH 13/31] feat: per-event review layouts for the remaining backed-up lists Relay lists are framed by what they do: DM, key-package, search, indexer, relay-feed, private-outbox and trusted relays show an N-of-M reach headline over per-relay rows; blocked relays show what gets unblocked, newly blocked and still blocked. Public chats, communities, favorite feeds and ephemeral rooms render as tiles loaded from relays and faded when you'd leave them; hashtags and places as a pill cloud; trust providers as a per-service before/after table; payment targets and BOLT12 offers as cards; the Cashu wallet and Concord list as an explained encrypted panel. Only NIP-78 app settings keeps the generic layout. Buttons are worded per event. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 10 +- .../backups/BackupConflictEventViews.kt | 4 +- .../backups/BackupConflictListViews.kt | 919 ++++++++++++++++++ .../backups/BackupConflictReviewScreen.kt | 34 +- amethyst/src/main/res/values/strings.xml | 57 ++ 5 files changed, 1020 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 739e6a8ebc..b9dea44972 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -67,7 +67,15 @@ only surviving copy of the user's data is gone. cards, then inline field diffs with images as thumbnails; NIP-65 reach numbers ("people find your posts on 2 of 3 relays") over 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'd leave it. Other events use the generic removed / added / changed sections. + when you'd leave it. The other lists (`BackupConflictListViews.kt`) get their own too: + each relay list framed by what it is for (DM, key-package, search, indexer, relay-feed, + private-outbox and trusted relays as "N of M" reach over per-relay rows; 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'd leave them, hashtags and places as a pill cloud, 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. Buttons are worded per event ("Keep 120" / "Restore 523", "Re-mute 37", "Rejoin 3"). The screen offers: - **Restore saved version** — `Account.restoreBackupOver` re-signs the saved kind, tags 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 index 95bce07284..e42807c0d3 100644 --- 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 @@ -132,7 +132,7 @@ internal fun LazyListScope.eventDiffItems( is AdvertisedRelayListDiff -> nip65Items(conflict, nav) is NutzapInfoDiff -> nutzapItems(diff, conflict, presentation, accountViewModel, nav) is SimpleGroupListDiff -> groupItems(diff, conflict) - else -> genericDiffItems(buildRows(presentation), accountViewModel, nav) + else -> if (!listDiffItems(conflict, accountViewModel, nav)) genericDiffItems(buildRows(presentation), accountViewModel, nav) } } @@ -511,7 +511,7 @@ private fun StruckPill( } @Composable -private fun PrivateItemsCard( +internal fun PrivateItemsCard( change: ContentChange, modifier: Modifier = Modifier, ) { 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..226a3defe9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -0,0 +1,919 @@ +/* + * 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.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.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) + is GeohashListDiff -> topicItems(diff.geohashes, (conflict.saved as? GeohashListEvent)?.publicGeohashes().orEmpty(), diff.privateItems, isPlace = true) + 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 is presented: the reach it gives, or the protection it takes away. */ +private class RelayFraming( + val icon: MaterialSymbol, + val headline: Int, + val explainer: Int, + val keptTag: Int, + val droppedTag: Int = R.string.backup_review_dropped_tag, + val lossIsWarning: Boolean = false, +) + +private fun framingOf(type: BackupEventType): RelayFraming = + when (type) { + BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_reach_dms, R.string.backup_review_reach_dms_explainer, R.string.backup_review_receiving_tag) + BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_reach_key_packages, R.string.backup_review_reach_key_packages_explainer, R.string.backup_review_listed_tag) + BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_reach_search, R.string.backup_review_reach_search_explainer, R.string.backup_review_searching_tag) + BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_reach_indexers, R.string.backup_review_reach_indexers_explainer, R.string.backup_review_listed_tag) + BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_reach_relay_feeds, R.string.backup_review_reach_relay_feeds_explainer, R.string.backup_review_listed_tag) + BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_reach_private_outbox, R.string.backup_review_reach_private_outbox_explainer, R.string.backup_review_storing_tag) + BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_reach_trusted, R.string.backup_review_reach_trusted_explainer, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag, lossIsWarning = true) + else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_reach_generic, R.string.backup_review_reach_generic_explainer, 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 { + val framing = framingOf(conflict.eventType) + val had = rows.count { it.second != ItemFate.ADDED } + val kept = rows.count { it.second == ItemFate.KEPT } + item(key = "relay-hero", contentType = "hero") { + val tones = conflictTones() + val color = if (framing.lossIsWarning) tones.changed else tones.removed + 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 = framing.icon, contentDescription = null, tint = color) + } + Column { + Text(stringRes(framing.headline), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(kept.toString(), fontSize = 34.sp, lineHeight = 36.sp, fontWeight = FontWeight.Bold, color = if (kept < had) color else tones.added) + Text(stringRes(R.string.backup_review_of_relays, had.toString()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp)) + } + Text(stringRes(framing.explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + } + } + } + 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_would_leave, 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_would_leave, 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, +) { + 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) + .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)) + Text( + if (isPlace) topic else "#$topic", + color = color, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null, + ) + } + } + } + } + } + privateItemsCard(privateItems) +} + +// --------------------------------------------------------------------------------------- +// 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, +) { + val before = (conflict.saved as? TrustProviderListEvent)?.serviceProviders().orEmpty().associate { it.service.type to it.pubkey } + val after = (conflict.incoming as? TrustProviderListEvent)?.serviceProviders().orEmpty().associate { it.service.type to it.pubkey } + val rows = (before.keys + after.keys).distinct().map { ServiceRow(it, before[it], after[it]) } + val affected = rows.count { it.before != it.after } + + 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(), rows.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 }, 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, + model = user?.profilePicture(), + 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.string.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.string.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(stringRes(headline, dropped.toString()), 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/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt index a6a678e036..d6dddcc93d 100644 --- 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 @@ -58,6 +58,7 @@ 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 @@ -78,14 +79,22 @@ 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.nip02FollowList.ContactListEvent +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 @@ -333,10 +342,33 @@ private fun actionLabels(conflict: ReplaceableBackupConflict): Pair 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 AdvertisedRelayListDiff, is RelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) + 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, is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics) + 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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6cadf20f70..c2c1308f56 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2893,6 +2893,63 @@ joined renamed was “%1$s” + Block %1$s again + Restore feeds + Restore topics + Restore providers + Restore wallet + kept + People can DM you on + Messages sent to the dropped relays won\'t reach you. Most senders only look at this list. + New group chats can find you on + Others look here for the keys that let them add you to encrypted group chats. + Search runs on + Searches in Amethyst only query these relays. + Profiles and relay lists are found on + Amethyst asks these relays where other people publish. + You browse as feeds + Relays you open directly as a feed. + Drafts and private data are stored on + Dropped relays won\'t get your new drafts, and other devices may not find the old ones. + Amethyst trusts without asking + Amethyst will ask again before connecting to relays you no longer trust. + You use + Relays saved in this list. + Receiving + Listed + Searching + Storing + Trusted + Untrusted + %1$s unblocked + Amethyst would 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$s ways to pay you removed + %1$s offers removed + People paying you only see what\'s in this list. + BOLT12 offer + It was emptied + It was created + It was rewritten + 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. Use new version read only write only From ba8f381f1db73659cfae72db9f29f9f679edac6a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:45:36 +0000 Subject: [PATCH 14/31] feat: event-specific headlines on the Home backup conflict cards Each backed-up event gets its own headline counted from its own diff, with proper plurals: "3 relays were unblocked", "DMs may miss you: 2 inbox relays dropped", "You'd leave 4 communities", "Your nutzap key was replaced", "Your Cashu wallet was emptied"... A conflict that only lost encrypted items falls back to the generic title. NIP-78 app settings keeps the generic one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../loggedIn/backups/BackupConflictCards.kt | 84 +++++++++++++++- amethyst/src/main/res/values/strings.xml | 95 ++++++++++++++++++- 2 files changed, 174 insertions(+), 5 deletions(-) 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 index 77a66795a0..2f4255e696 100644 --- 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 @@ -46,6 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +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 @@ -54,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -61,10 +63,26 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.metadata.MetadataDiff import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +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.nip85TrustedAssertions.list.TrustProviderListDiff +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff /** * Cards at the top of Home, one per open [ReplaceableBackupConflict]: another app replaced @@ -126,13 +144,71 @@ private fun countsOf(conflict: ReplaceableBackupConflict): ConflictCounts { private fun headlineOf( conflict: ReplaceableBackupConflict, counts: ConflictCounts, +): String { + val diff = conflict.diff + val specific: String? = + when (diff) { + is ContactListDiff -> stringRes(R.string.backup_card_follows_shrank) + 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) + else -> null + } + // A headline counting removed items reads wrong when only encrypted items were lost. + return specific?.takeUnless { it.isEmpty() } + ?: stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) +} + +/** 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 (conflict.diff) { - is ContactListDiff -> stringRes(R.string.backup_card_follows_shrank) - is MuteListDiff -> stringRes(R.string.backup_card_mutes_visible, counts.removed.toString()) - else -> stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) + 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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c2c1308f56..17b6d25f29 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2845,7 +2845,6 @@ %1$s added %1$s changed Your follow list shrank - %1$s muted things are visible again Changed by another app · %1$s Keep %1$s Restore %1$s @@ -2950,6 +2949,100 @@ It was rewritten 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. + + %1$d muted thing is visible again + %1$d muted things are visible again + + + Your profile lost %1$d field + Your profile lost %1$d fields + + + Your posts lost %1$d relay + Your posts lost %1$d relays + + + DMs may miss you: %1$d inbox relay dropped + DMs may miss you: %1$d inbox relays dropped + + + %1$d group-chat key relay dropped + %1$d group-chat key relays dropped + + + %1$d search relay dropped + %1$d search relays dropped + + + %1$d indexer relay dropped + %1$d indexer relays dropped + + + %1$d relay feed removed + %1$d relay feeds removed + + + Your drafts lost %1$d storage relay + Your drafts lost %1$d storage relays + + + %1$d relay is no longer trusted + %1$d relays are no longer trusted + + + %1$d relay was unblocked + %1$d relays were unblocked + + + You\'d leave %1$d public chat + You\'d leave %1$d public chats + + + You\'d leave %1$d community + You\'d leave %1$d communities + + + You\'d leave %1$d chat room + You\'d leave %1$d chat rooms + + + You\'d leave %1$d group + You\'d leave %1$d groups + + + %1$d favorite feed removed + %1$d favorite feeds removed + + + %1$d hashtag unfollowed + %1$d hashtags unfollowed + + + %1$d place unfollowed + %1$d places unfollowed + + + %1$d trust service changed provider + %1$d trust services changed provider + + + %1$d nutzap mint removed + %1$d nutzap mints removed + + + %1$d way to pay you was removed + %1$d ways to pay you were removed + + + %1$d BOLT12 offer removed + %1$d BOLT12 offers removed + + Your nutzap key was replaced + Your nutzap key was removed + Your Cashu wallet was emptied + Your Cashu wallet was rewritten + Your Concord list was emptied + Your Concord list was rewritten Use new version read only write only From 5f11f9508f201f7257905844961b7d35bc2a92cd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:49:47 +0000 Subject: [PATCH 15/31] feat: warn that non-Amethyst apps changing Amethyst's synced settings can cause problems The Home card for a NIP-78 app settings conflict now reads "Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems." Card headlines get one more line so it isn't cut off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../ui/screen/loggedIn/backups/BackupConflictCards.kt | 6 ++++-- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) 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 index 2f4255e696..f0c0ea4fd5 100644 --- 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 @@ -81,6 +81,7 @@ 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 @@ -171,6 +172,7 @@ private fun headlineOf( 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. @@ -232,7 +234,7 @@ private fun LeadConflictCard( 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 = 2, overflow = TextOverflow.Ellipsis) + 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, LocalContext.current)), style = MaterialTheme.typography.labelMedium, @@ -297,7 +299,7 @@ private fun ConflictPill( 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 = 1, overflow = TextOverflow.Ellipsis) + Text(headlineOf(conflict, counts), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) if (counts.removed > 0) StatusTag("−" + counts.removed, tones.removed) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 17b6d25f29..e994375284 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3037,6 +3037,7 @@ %1$d BOLT12 offer removed %1$d BOLT12 offers removed + Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems. Your nutzap key was replaced Your nutzap key was removed Your Cashu wallet was emptied From 50df08173b02c765737b1a53c509df924333cd39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:58:16 +0000 Subject: [PATCH 16/31] fix: make every backup conflict view observe LocalCache and relays The mute list face pile and the trust provider avatars read metadata once from LocalCache; they now observe it through observeUserInfo, which also subscribes to the user's metadata on relays. NIP-29 group tiles load and observe the group channel, fetching its relay-signed name and picture even for groups the user hasn't joined, and open the group when tapped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../backups/BackupConflictEventViews.kt | 97 +++++++++++++++++-- .../backups/BackupConflictListViews.kt | 3 +- 2 files changed, 89 insertions(+), 11 deletions(-) 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 index e42807c0d3..9586fef7fa 100644 --- 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 @@ -44,10 +44,12 @@ 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 @@ -65,17 +67,22 @@ 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 @@ -85,8 +92,10 @@ 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.nip02FollowList.ContactListEvent +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 @@ -131,7 +140,7 @@ internal fun LazyListScope.eventDiffItems( 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) + is SimpleGroupListDiff -> groupItems(diff, conflict, accountViewModel, nav) else -> if (!listDiffItems(conflict, accountViewModel, nav)) genericDiffItems(buildRows(presentation), accountViewModel, nav) } } @@ -444,7 +453,8 @@ private fun FacePile( accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - val names = people.take(2).map { LocalCache.getUserIfExists(it)?.toBestDisplayName() ?: it.toShortDisplay() } + // 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()) @@ -471,7 +481,7 @@ private fun FacePile( ) { RobohashFallbackAsyncImage( robot = pubKey, - model = LocalCache.getUserIfExists(pubKey)?.profilePicture(), + model = observedPicture(pubKey, accountViewModel), contentDescription = null, modifier = Modifier.size(42.dp).clip(CircleShape), loadProfilePicture = accountViewModel.settings.showProfilePictures(), @@ -491,6 +501,36 @@ private fun FacePile( } } +/** + * 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, @@ -1032,6 +1072,8 @@ private class GroupTile( private fun LazyListScope.groupItems( diff: SimpleGroupListDiff, conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, ) { val key = { g: GroupTag -> g.groupId + "@" + g.relayUrl } val removed = @@ -1077,7 +1119,7 @@ private fun LazyListScope.groupItems( } 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, Modifier.weight(1f)) } + row.forEach { GroupTileCard(it, accountViewModel, nav, Modifier.weight(1f)) } if (row.size == 1) Spacer(Modifier.weight(1f)) } } @@ -1086,19 +1128,54 @@ private fun LazyListScope.groupItems( @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() - val name = tile.group.name ?: tile.group.groupId - val hue = (name.hashCode() and 0xFFFF) / 65535f * 360f - Box(modifier.clip(RoundedCornerShape(20.dp)).background(MaterialTheme.colorScheme.surfaceContainer).padding(14.dp)) { + 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), ) { - Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(Color.hsl(hue, 0.6f, 0.72f)), contentAlignment = Alignment.Center) { - Text(name.take(1).uppercase(), color = Color(0xFF17111F), fontWeight = FontWeight.ExtraBold, fontSize = 18.sp) - } + 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( 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 index 226a3defe9..63466e4488 100644 --- 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 @@ -778,7 +778,8 @@ private fun ProviderCell( ) { RobohashFallbackAsyncImage( robot = pubKey, - model = user?.profilePicture(), + // 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(), From d582b64da2830751e338e118ce4944d698fefb11 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 23:20:37 +0000 Subject: [PATCH 17/31] fix: backup conflict audit findings, round two - Wiped versions (empty tags, empty app data, mints-less nutzap info) now reach the guard, so a total wipe from another app raises a conflict instead of silently replacing everything; an empty version only becomes the backup when signed here or kept by the user. - The guard and the resolutions share a lock, and a version older than an open conflict's incoming can no longer replace or clear it. - Restored profiles and nutzap info are published everywhere, like their normal saves (nutzap info also to the relays it names). - The restore timestamp is capped at 15 minutes ahead of now. - "bot": false dropped from a profile is no longer counted as a lost field. - Follow counts use unique valid follows, matching the diff. - The review screen pops exactly once after a decision. - Home cards count straight from the diff (remembered) instead of building every display item. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 14 +- .../vitorpamplona/amethyst/model/Account.kt | 7 +- .../amethyst/model/AccountSettings.kt | 209 +++++++++++------- .../amethyst/model/EventBroadcaster.kt | 8 +- .../loggedIn/backups/BackupConflictCards.kt | 27 +-- .../backups/BackupConflictEventViews.kt | 4 +- .../backups/BackupConflictPresentation.kt | 61 +++++ .../backups/BackupConflictReviewScreen.kt | 28 ++- .../nip01Core/metadata/MetadataEvent.kt | 3 +- .../quartz/nip01Core/diff/EventDiffTest.kt | 9 + 10 files changed, 246 insertions(+), 124 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index b9dea44972..7c1fbe27fe 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -42,6 +42,12 @@ only surviving copy of the user's data is gone. 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 `AccountSettings.backupConflicts`. Home shows a card per open conflict at the top of the feed (`BackupConflictCards`, @@ -80,12 +86,14 @@ only surviving copy of the user's data is gone. 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)`, dropping the old `client` tag (so the signer + `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) also to every relay the restored - list names, since the lossy version may have dropped them from the outbox set. + 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. 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 d913123c9e..c511f0ae27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1906,7 +1906,10 @@ class Account( try { val saved = conflict.saved val now = TimeUtils.now() - val createdAt = maxOf(now, conflict.incoming.createdAt + 1) + // 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; the restore then goes out at "now". + val createdAt = (conflict.incoming.createdAt + 1).takeIf { it <= now + maxRestoreFutureSeconds }?.coerceAtLeast(now) ?: now val resigned = signer.sign(createdAt, saved.kind, BackupRestore.tagsToResign(saved, now), saved.content) broadcaster.sendRestoredVersion(resigned) restored = true @@ -1915,6 +1918,8 @@ class Account( } } + private val maxRestoreFutureSeconds = 15 * 60L + /** Resolves a [ReplaceableBackupConflict] in favor of the current version. No-op when stale. */ fun acceptExternalVersion(conflict: ReplaceableBackupConflict) = settings.keepIncomingVersion(conflict) 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 361da15f95..7ef42eef23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1185,47 +1185,65 @@ class AccountSettings( // Externally signed versions the user explicitly chose to keep over the backup. private val acceptedExternalVersions = ConcurrentSet() + // Serializes the conflict bookkeeping: the backup collectors run on IO threads while + // the user resolves conflicts from the UI thread. + private val backupConflictLock = Any() + /** * 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. */ private fun acceptIntoBackup( saved: T?, incoming: T, + isEmpty: Boolean = false, retry: () -> Unit, - ): Boolean { - if (saved?.id == incoming.id) return false - if (resolvingBackupVersions.contains(incoming.id)) return false + ): Boolean = + synchronized(backupConflictLock) { + if (saved?.id == incoming.id) return@synchronized false + if (resolvingBackupVersions.contains(incoming.id)) return@synchronized false - val slot = backupSlot(incoming) - val pending = backupConflicts.value[slot] + val slot = backupSlot(incoming) + val pending = backupConflicts.value[slot] - // The same version re-emitted by the cache: the open conflict already describes it. - if (pending != null && pending.incoming.id == incoming.id) return false - - val isLocal = LocallySignedEvents.contains(incoming.id) - - // 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 && !acceptedExternalVersions.contains(incoming.id)) { - 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 - val conflict = ReplaceableBackupConflict(reference, incoming, cause, diff) - backupConflictRetries[slot] = retry - backupConflicts.update { it + (slot to conflict) } - return false + if (pending != null) { + // The same version re-emitted by the cache: the open conflict already describes it. + if (pending.incoming.id == incoming.id) return@synchronized 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@synchronized false } - } - dropBackupConflict(slot) - return true - } + val isLocal = LocallySignedEvents.contains(incoming.id) + val accepted = acceptedExternalVersions.contains(incoming.id) + + // 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 && !accepted) { + 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 + val conflict = ReplaceableBackupConflict(reference, incoming, cause, diff) + backupConflictRetries[slot] = retry + backupConflicts.update { it + (slot to conflict) } + return@synchronized false + } + } + + dropBackupConflict(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. + !isEmpty || isLocal || accepted + } private fun dropBackupConflict(slot: String) { if (backupConflicts.value.containsKey(slot)) { @@ -1238,7 +1256,12 @@ class AccountSettings( * Removes [conflict] if it is still the open conflict of its slot. Returns false when it * was already resolved or replaced by a newer one, so callers never act on a stale conflict. */ - private fun claimBackupConflict(conflict: ReplaceableBackupConflict): (() -> Unit)? { + private fun claimBackupConflict(conflict: ReplaceableBackupConflict): (() -> Unit)? = + synchronized(backupConflictLock) { + claimBackupConflictLocked(conflict) + } + + private fun claimBackupConflictLocked(conflict: ReplaceableBackupConflict): (() -> Unit)? { var claimed = false backupConflicts.update { current -> claimed = current[conflict.slot] === conflict @@ -1250,8 +1273,13 @@ class AccountSettings( /** Keeps the new version: the backup moves forward to [ReplaceableBackupConflict.incoming]. */ fun keepIncomingVersion(conflict: ReplaceableBackupConflict) { - val retry = claimBackupConflict(conflict) ?: return - acceptedExternalVersions.add(conflict.incoming.id) + val retry = + synchronized(backupConflictLock) { + val claimed = claimBackupConflictLocked(conflict) ?: return + acceptedExternalVersions.add(conflict.incoming.id) + claimed + } + // Outside the lock: the retry re-enters acceptIntoBackup, which takes it again. retry() } @@ -1261,11 +1289,12 @@ class AccountSettings( * is stale (already resolved, replaced or dropped); otherwise a token to hand back to * [finishRestoringSavedVersion]. */ - fun startRestoringSavedVersion(conflict: ReplaceableBackupConflict): (() -> Unit)? { - val retry = claimBackupConflict(conflict) ?: return null - resolvingBackupVersions.add(conflict.incoming.id) - return retry - } + fun startRestoringSavedVersion(conflict: ReplaceableBackupConflict): (() -> Unit)? = + synchronized(backupConflictLock) { + val retry = claimBackupConflictLocked(conflict) ?: return@synchronized null + resolvingBackupVersions.add(conflict.incoming.id) + retry + } /** * Ends a restore. On failure the conflict is reopened so the user can try again, unless @@ -1275,6 +1304,16 @@ class AccountSettings( conflict: ReplaceableBackupConflict, token: () -> Unit, restored: Boolean, + ) { + synchronized(backupConflictLock) { + finishRestoringLocked(conflict, token, restored) + } + } + + private fun finishRestoringLocked( + conflict: ReplaceableBackupConflict, + token: () -> Unit, + restored: Boolean, ) { resolvingBackupVersions.remove(conflict.incoming.id) if (restored) return @@ -1311,36 +1350,36 @@ class AccountSettings( } fun updateContactListTo(newContactList: ContactListEvent?) { - if (newContactList == null || newContactList.tags.isEmpty()) return + if (newContactList == null) return - if (acceptIntoBackup(backupContactList, newContactList) { updateContactListTo(newContactList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupDMRelayList, newDMRelayList) { updateDMRelayList(newDMRelayList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupKeyPackageRelayList, newKeyPackageRelayList) { updateKeyPackageRelayList(newKeyPackageRelayList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupNIP65RelayList, newNIP65RelayList) { updateNIP65RelayList(newNIP65RelayList) }) { + if (acceptIntoBackup(backupNIP65RelayList, newNIP65RelayList, isEmpty = newNIP65RelayList.tags.isEmpty()) { updateNIP65RelayList(newNIP65RelayList) }) { backupNIP65RelayList = newNIP65RelayList saveAccountSettings() } @@ -1356,7 +1395,10 @@ class AccountSettings( } 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 (!acceptIntoBackup(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 @@ -1365,10 +1407,8 @@ class AccountSettings( clearNutzapInfo() return } - if (acceptIntoBackup(backupNutzapInfo, newNutzapInfo) { updateNutzapInfo(newNutzapInfo) }) { - backupNutzapInfo = newNutzapInfo - saveAccountSettings() - } + backupNutzapInfo = newNutzapInfo + saveAccountSettings() } /** @@ -1440,72 +1480,72 @@ class AccountSettings( } fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) { - if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return + if (newNIPA3PaymentTargets == null) return - if (acceptIntoBackup(backupNipA3PaymentTargets, newNIPA3PaymentTargets) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupBolt12Offers, newBolt12Offers) { updateBolt12Offers(newBolt12Offers) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupSearchRelayList, newSearchRelayList) { updateSearchRelayList(newSearchRelayList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupIndexRelayList, newIndexRelayList) { updateIndexRelayList(newIndexRelayList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupRelayFeedsList, newRelayFeedList) { updateRelayFeedList(newRelayFeedList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupBlockedRelayList, newBlockedRelayList) { updateBlockedRelayList(newBlockedRelayList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupTrustedRelayList, newTrustedRelayList) { updateTrustedRelayList(newTrustedRelayList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupPrivateHomeRelayList, newPrivateHomeRelayList) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) { + if (acceptIntoBackup(backupPrivateHomeRelayList, newPrivateHomeRelayList, isEmpty = newPrivateHomeRelayList.tags.isEmpty()) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) { backupPrivateHomeRelayList = newPrivateHomeRelayList saveAccountSettings() } @@ -1514,45 +1554,45 @@ class AccountSettings( override fun channelList() = backupChannelList override fun updateChannelListTo(newChannelList: ChannelListEvent?) { - if (newChannelList == null || newChannelList.tags.isEmpty()) return + if (newChannelList == null) return - if (acceptIntoBackup(backupChannelList, newChannelList) { updateChannelListTo(newChannelList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupGeohashList, newGeohashList) { updateGeohashListTo(newGeohashList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupHashtagList, newHashtagList) { updateHashtagListTo(newHashtagList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupFavoriteAlgoFeedsList, newFavoriteDvmList) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupCommunityList, newCommunityList) { updateCommunityListTo(newCommunityList) }) { + if (acceptIntoBackup(backupCommunityList, newCommunityList, isEmpty = newCommunityList.tags.isEmpty()) { updateCommunityListTo(newCommunityList) }) { backupCommunityList = newCommunityList saveAccountSettings() } @@ -1561,9 +1601,9 @@ class AccountSettings( override fun ephemeralChatList() = backupEphemeralChatList override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) { - if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return + if (newEphemeralChatList == null) return - if (acceptIntoBackup(backupEphemeralChatList, newEphemeralChatList) { updateEphemeralChatListTo(newEphemeralChatList) }) { + if (acceptIntoBackup(backupEphemeralChatList, newEphemeralChatList, isEmpty = newEphemeralChatList.tags.isEmpty()) { updateEphemeralChatListTo(newEphemeralChatList) }) { backupEphemeralChatList = newEphemeralChatList saveAccountSettings() } @@ -1596,18 +1636,18 @@ class AccountSettings( } fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) { - if (trustProviderList == null || trustProviderList.tags.isEmpty()) return + if (trustProviderList == null) return - if (acceptIntoBackup(backupTrustProviderList, trustProviderList) { updateTrustProviderListTo(trustProviderList) }) { + if (acceptIntoBackup(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 - if (acceptIntoBackup(backupMuteList, newMuteList) { updateMuteList(newMuteList) }) { + if (acceptIntoBackup(backupMuteList, newMuteList, isEmpty = newMuteList.tags.isEmpty()) { updateMuteList(newMuteList) }) { backupMuteList = newMuteList saveAccountSettings() } @@ -1617,10 +1657,15 @@ class AccountSettings( appSettings: AppSpecificDataEvent?, newSyncedSettings: AccountSyncedSettingsInternal, ) { - if (appSettings == null || appSettings.content.isEmpty()) return + if (appSettings == null) return - if (acceptIntoBackup(backupAppSpecificData, appSettings) { updateAppSpecificData(appSettings, newSyncedSettings) }) { + if (acceptIntoBackup(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isEmpty()) { updateAppSpecificData(appSettings, newSyncedSettings) }) { backupAppSpecificData = appSettings + // An emptied blob (signed here or accepted by the user) has no settings to merge. + if (appSettings.content.isEmpty()) { + 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 d6c04749b1..298fff1dff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven 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 @@ -376,7 +377,8 @@ 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. + * 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) { @@ -384,6 +386,10 @@ class EventBroadcaster( 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) } } 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 index f0c0ea4fd5..6f5c747ca0 100644 --- 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 @@ -42,6 +42,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -122,24 +123,6 @@ fun BackupConflictCards( } } -/** Items lost, gained and changed, counting encrypted content as one item. */ -private class ConflictCounts( - val removed: Int, - val added: Int, - val changed: Int, -) - -@Composable -private fun countsOf(conflict: ReplaceableBackupConflict): ConflictCounts { - val presentation = presentationOf(conflict.diff) - val content = presentation.content - return ConflictCounts( - removed = presentation.removedCount + if (content == ContentChange.CLEARED) 1 else 0, - added = presentation.addedCount + if (content == ContentChange.ADDED) 1 else 0, - changed = presentation.changedCount + if (content == ContentChange.CHANGED) 1 else 0, - ) -} - /** A headline that says what happened, in the event's own terms. */ @Composable private fun headlineOf( @@ -217,7 +200,7 @@ private fun LeadConflictCard( onClick: () -> Unit, ) { val tones = conflictTones() - val counts = countsOf(conflict) + val counts = remember(conflict) { countsOf(conflict.diff) } Surface( modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).clickable(onClick = onClick), shape = RoundedCornerShape(22.dp), @@ -245,8 +228,8 @@ private fun LeadConflictCard( } val diff = conflict.diff if (diff is ContactListDiff) { - val saved = (conflict.saved as? ContactListEvent)?.followCount() ?: 0 - val new = (conflict.incoming as? ContactListEvent)?.followCount() ?: 0 + val saved = (conflict.saved as? ContactListEvent)?.uniqueFollowCount() ?: 0 + val new = (conflict.incoming as? ContactListEvent)?.uniqueFollowCount() ?: 0 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Text(saved.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold) SplitBar( @@ -283,7 +266,7 @@ private fun ConflictPill( onClick: () -> Unit, ) { val tones = conflictTones() - val counts = countsOf(conflict) + val counts = remember(conflict) { countsOf(conflict.diff) } Surface( modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).clickable(onClick = onClick), shape = RoundedCornerShape(18.dp), 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 index 9586fef7fa..aebd997841 100644 --- 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 @@ -158,8 +158,8 @@ private fun LazyListScope.followListItems( accountViewModel: AccountViewModel, nav: INav, ) { - val saved = (conflict.saved as? ContactListEvent)?.followCount() ?: 0 - val new = (conflict.incoming as? ContactListEvent)?.followCount() ?: 0 + val saved = (conflict.saved as? ContactListEvent)?.uniqueFollowCount() ?: 0 + val new = (conflict.incoming as? ContactListEvent)?.uniqueFollowCount() ?: 0 val dropped = diff.follows.removed.size val gained = diff.follows.added.size val edited = diff.follows.changed.size 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 index c6b169901b..5c4688c293 100644 --- 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 @@ -40,6 +40,7 @@ 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.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff @@ -63,6 +64,66 @@ import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff private const val MAX_VALUE_LENGTH = 80 +/** Follows counted the way [ContactListDiff] matches them: valid keys, one per pubkey. */ +internal fun ContactListEvent.uniqueFollowCount() = follows().mapTo(HashSet()) { it.pubKey }.size + +/** 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. 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 index d6dddcc93d..b093fae8e9 100644 --- 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 @@ -47,6 +47,7 @@ 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 @@ -170,9 +171,18 @@ fun BackupConflictReviewScreen( val conflicts by settings.backupConflicts.collectAsStateWithLifecycle() val conflict = conflicts[slot] - // Resolved from here or elsewhere: nothing left to review. + // 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) { nav.popBack() } + LaunchedEffect(Unit) { + if (!popped.value) { + popped.value = true + nav.popBack() + } + } return } @@ -180,14 +190,8 @@ fun BackupConflictReviewScreen( conflict = conflict, accountViewModel = accountViewModel, nav = nav, - onRestore = { - accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } - nav.popBack() - }, - onKeepNew = { - accountViewModel.account.acceptExternalVersion(conflict) - nav.popBack() - }, + onRestore = { accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }, + onKeepNew = { accountViewModel.account.acceptExternalVersion(conflict) }, ) } @@ -327,8 +331,8 @@ internal fun eventTitle(conflict: ReplaceableBackupConflict): String = stringRes private fun actionLabels(conflict: ReplaceableBackupConflict): Pair = when (val diff = conflict.diff) { is ContactListDiff -> { - val saved = (conflict.saved as? ContactListEvent)?.followCount() ?: 0 - val new = (conflict.incoming as? ContactListEvent)?.followCount() ?: 0 + val saved = (conflict.saved as? ContactListEvent)?.uniqueFollowCount() ?: 0 + val new = (conflict.incoming as? ContactListEvent)?.uniqueFollowCount() ?: 0 stringRes(R.string.backup_action_keep_count, new.toString()) to stringRes(R.string.backup_action_restore_count, saved.toString()) } is MuteListDiff -> { 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 87213b0472..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 @@ -94,7 +94,8 @@ class MetadataEvent( lud06 = text(before.lud06, after.lud06), lud16 = text(before.lud16, after.lud16), clinkOffer = text(before.clinkOffer, after.clinkOffer), - bot = ValueChange.of(before.bot, after.bot), + // "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 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 index a6b91fd36f..17f08eec23 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -170,6 +170,15 @@ class EventDiffTest { 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"))) From 31daf557c8ca49f15200bd2979e4a3be1b98d4f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:11:22 +0000 Subject: [PATCH 18/31] docs: describe the lead card + pills layout in the backup conflicts plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- amethyst/plans/2026-09-22-replaceable-backup-conflicts.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 7c1fbe27fe..bddcda961c 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -53,8 +53,9 @@ only surviving copy of the user's data is gone. Home shows a card per open conflict at the top of the feed (`BackupConflictCards`, next to the key-backup nudge): "Your mute list changed in another app" with a "12 removed · 3 added" summary. Cards can't be dismissed; they stay until the user - decides, and with more than two conflicts they fold into one card with a row per - conflict. Tapping one opens `Route.BackupConflictReview(slot)` + decides. The most recent conflict gets a lead card with an event-specific headline + ("3 relays were unblocked", "Your follow list shrank" with its bar); the others collapse + into slim pills. 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 From acc1bb2793865ce636ab3fda2799b841bc9af64e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 21:06:37 -0400 Subject: [PATCH 19/31] fix: count the follows a conflict dropped, and keep conflicts across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the backup-conflict cards got wrong. The follow-list headline was the one headline that asserted a direction instead of counting: every other event type builds a plural from its own diff, ContactListDiff alone said "Your follow list shrank". A version that drops 3 follows and adds 100 still raises a conflict, so the card announced a shrink over its own mostly-green growth bar — while the user was deciding between Restore and Keep. It now counts what was dropped, like the rest. Open conflicts lived only in a MutableStateFlow, so process death answered the user's question for them: the card was gone on the next launch although nobody decided, and the held-back backup stayed held back with nothing left to explain why. They are now written alongside the backups they guard, as the three events each is made of, and read back on load. The diff is recomputed rather than stored, so a version that no longer removes anything — the other app put it back — stops being a conflict by itself instead of being resurrected from a stale description. Keeping a restored version still works. Retries are closures and cannot be stored, so a conflict read back from disk has none until its version arrives again: re-delivery now refreshes the retry, and a claim with no retry rebuilds it from the event's own type. NIP-78 is the one slot that cannot be rebuilt that way, because its update also needs the decrypted synced settings; keeping it is recorded in acceptedExternalVersions first, so it lands on the next delivery instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/LocalPreferences.kt | 15 +- .../amethyst/model/AccountSettings.kt | 85 ++++++++++- .../model/backups/BackupConflictStorage.kt | 81 ++++++++++ .../loggedIn/backups/BackupConflictCards.kt | 4 +- amethyst/src/main/res/values/strings.xml | 5 +- .../backups/BackupConflictStorageTest.kt | 144 ++++++++++++++++++ 6 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorageTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index d0692b5a44..ccfdf021d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -36,6 +36,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.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -158,6 +159,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" @@ -581,6 +583,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) @@ -807,6 +819,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) @@ -1062,7 +1075,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/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 7ef42eef23..9910e3742b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1214,7 +1214,12 @@ class AccountSettings( if (pending != null) { // The same version re-emitted by the cache: the open conflict already describes it. - if (pending.incoming.id == incoming.id) return@synchronized false + // 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) { + backupConflictRetries[slot] = retry + return@synchronized 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@synchronized false @@ -1235,6 +1240,7 @@ class AccountSettings( val conflict = ReplaceableBackupConflict(reference, incoming, cause, diff) backupConflictRetries[slot] = retry backupConflicts.update { it + (slot to conflict) } + saveAccountSettings() return@synchronized false } } @@ -1248,10 +1254,77 @@ class AccountSettings( private fun dropBackupConflict(slot: String) { if (backupConflicts.value.containsKey(slot)) { backupConflicts.update { it - slot } + saveAccountSettings() } backupConflictRetries.remove(slot) } + /** + * Re-seeds the open conflicts read back from storage. + * + * A conflict outlives the process that raised it: it is the user's undecided question, and + * the whole point of holding the backup back is that nobody answered it yet. 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 restoreBackupConflicts(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.isNotEmpty()) backupConflicts.update { it + restored } + } + + /** The open conflicts, as the three events storage needs to rebuild each one. */ + fun openBackupConflicts(): List> = backupConflicts.value.values.map { Triple(it.saved, it.incoming, it.cause) } + + /** + * Re-runs the update that a restored conflict no longer carries. + * + * [backupConflictRetries] holds closures, so it 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 is deliberately absent: its update also takes the decrypted + // AccountSyncedSettingsInternal, which only the caller can produce. Keeping that + // version still works — [keepIncomingVersion] records the id in + // [acceptedExternalVersions] first, so the next delivery walks into the backup + // unchallenged — it just lands when the event next arrives rather than right now. + else -> Unit + } + } + /** * Removes [conflict] if it is still the open conflict of its slot. Returns false when it * was already resolved or replaced by a newer one, so callers never act on a stale conflict. @@ -1268,7 +1341,10 @@ class AccountSettings( if (claimed) current - conflict.slot else current } if (!claimed) return null - return backupConflictRetries.remove(conflict.slot) ?: {} + saveAccountSettings() + // 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 backupConflictRetries.remove(conflict.slot) ?: { reapplyBackup(conflict.incoming) } } /** Keeps the new version: the backup moves forward to [ReplaceableBackupConflict.incoming]. */ @@ -1322,7 +1398,10 @@ class AccountSettings( reopened = !current.containsKey(conflict.slot) if (reopened) current + (conflict.slot to conflict) else current } - if (reopened) backupConflictRetries[conflict.slot] = token + if (reopened) { + backupConflictRetries[conflict.slot] = token + saveAccountSettings() + } } fun updateLocalRelayServers(servers: Set) { 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..065c154a9b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt @@ -0,0 +1,81 @@ +/* + * 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 { + fun encode(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/ui/screen/loggedIn/backups/BackupConflictCards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt index 6f5c747ca0..5f602df953 100644 --- 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 @@ -132,7 +132,9 @@ private fun headlineOf( val diff = conflict.diff val specific: String? = when (diff) { - is ContactListDiff -> stringRes(R.string.backup_card_follows_shrank) + // 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) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e994375284..5748aaa8b9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2844,7 +2844,10 @@ %1$s removed %1$s added %1$s changed - Your follow list shrank + + %1$d follow was dropped + %1$d follows were dropped + Changed by another app · %1$s Keep %1$s Restore %1$s 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()) + } +} From 588d63393cdb8053069c833e0c16bdef2f385ccd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 23 Sep 2026 08:13:00 -0400 Subject: [PATCH 20/31] fix: look for an account's own events even when it publishes nowhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account's profile, follow list and every backed-up list are fetched by AccountMetadataEoseManager, which buckets accounts by their home relays. Home relays are the NIP-65 write set (plus private storage and local), so an account whose kind:10002 lists no write relay has none — and, keyed on that set alone, it joined no bucket and the subscription asked nothing at all on its behalf. Nothing said so. The account kept whatever was last stored, forever: on the tablet its follow list stayed frozen while a newer one sat on five relays it was connected to, across restarts, a fresh logout and login, and a publish made while the app was running and watching. acceptIntoBackup was never called, so the backup guard could not raise the conflict it exists to raise — the feature looked broken when the events were simply never requested. Where an account publishes and where its existing events can be found are different questions, so the second now falls back: inbox relays first, since a client that rewrote these lists most likely also put them where the account says to reach it, then the app's event finders, which is what "we do not know" means everywhere else. Reads only — no publish is redirected. The inbox list is watched alongside home relays so the fallback is not frozen at whatever it resolved to during startup. Deliberately not fixed in relayListOrDefaultsWhenUnknown: an empty list there means "they told us: nothing", and defaulting it would override an explicit choice about publishing. The precedence is split into pickAccountDataRelays so it can be tested without standing up an account. Verified on the tablet: the account went from no account-data REQ at all to being asked on five relays, the external follow list arrived, and the conflict card appeared and survived a restart. Co-Authored-By: Claude Opus 5 (1M context) --- .../metadata/AccountMetadataEoseManager.kt | 55 +++++++++++++- .../metadata/PickAccountDataRelaysTest.kt | 75 +++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/PickAccountDataRelaysTest.kt 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/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)) + } +} From ca0966c08a9d6b44170807f349f44055bdfe45a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:40:10 +0000 Subject: [PATCH 21/31] refactor: extract the backup conflict guard into commons and test it AccountSettings' acceptIntoBackup/claim/restore bookkeeping, including the persistence hooks (restore/open, retry refresh on re-emit, reapply fallback, save on change), moves into BackupConflictGuard (commons, model/backups) with the locally-signed check, save and reapply injected. AccountSettings owns one as backupGuard and keeps reapplyBackup. 25 JVM tests cover local vs external versions, the frozen reference, re-emits, older versions, local edits during a conflict, keep/restore/failed restore, stale and repeated answers, wipes, drops, per-kind slots and the storage round trip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 22 +- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../amethyst/model/AccountSettings.kt | 268 ++---------- .../model/backups/BackupConflictGuard.kt | 226 ++++++++++ .../model/backups/BackupConflictGuardTest.kt | 402 ++++++++++++++++++ 5 files changed, 691 insertions(+), 233 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index bddcda961c..df03471591 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -35,8 +35,9 @@ only surviving copy of the user's data is gone. `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.** `AccountSettings.acceptIntoBackup` guards - every `update*` backup method. A newer version that was not signed here goes through +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()`. @@ -49,13 +50,18 @@ only surviving copy of the user's data is gone. 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 `AccountSettings.backupConflicts`. + `ReplaceableBackupConflict` is published on `BackupConflictGuard.conflicts`. Home shows a card per open conflict at the top of the feed (`BackupConflictCards`, next to the key-backup nudge): "Your mute list changed 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 ("3 relays were unblocked", "Your follow list shrank" with its bar); the others collapse - into slim pills. Tapping one opens `Route.BackupConflictReview(slot)` + into slim pills. At most two pills show; more open conflicts sit behind an "N more lists + changed" toggle that expands into a capped, scrollable area, since the cards float over + the feed instead of scrolling with it. List headlines lead with what was lost and add + what was added to the same list, worded per list ("12 follows were dropped, and 40 were + added", "You'd leave 2 groups, and join 1", "3 relays were unblocked, and 1 newly + blocked"). 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 @@ -112,8 +118,12 @@ only surviving copy of the user's data is gone. silently clear the conflict. They move the conflict's `incoming` forward but keep the external version as its `cause` (the screen shows that date). -Conflicts are not persisted: the frozen backup is reloaded on start, the newer relay -version replaces it in `LocalCache`, and the same conflict is detected again. +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 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 c511f0ae27..4bc2869f2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1901,7 +1901,7 @@ class Account( * dropped because the event was deleted), which also makes a double tap harmless. */ suspend fun restoreBackupOver(conflict: ReplaceableBackupConflict) { - val token = settings.startRestoringSavedVersion(conflict) ?: return + val token = settings.backupGuard.startRestoring(conflict) ?: return var restored = false try { val saved = conflict.saved @@ -1914,14 +1914,14 @@ class Account( broadcaster.sendRestoredVersion(resigned) restored = true } finally { - settings.finishRestoringSavedVersion(conflict, token, restored) + 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.keepIncomingVersion(conflict) + fun acceptExternalVersion(conflict: ReplaceableBackupConflict) = settings.backupGuard.keepIncoming(conflict) suspend fun signAndSendPrivately( template: EventTemplate, 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 9910e3742b..cac2a69496 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -23,10 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.HomeFeedType -import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents -import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict -import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupDiff -import com.vitorpamplona.amethyst.commons.model.backups.backupSlot +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 @@ -44,7 +41,6 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory -import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -97,7 +93,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable -import java.util.concurrent.ConcurrentHashMap val DefaultSignerPermissions = listOf( @@ -1167,124 +1162,28 @@ class AccountSettings( // ---- /** - * 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. Not persisted: - * after a restart the same newer event is detected again against the same frozen backup. - * Home shows a card for each until the user keeps the new version or restores the saved one. + * 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 backupConflicts = MutableStateFlow>(emptyMap()) + val backupGuard = + BackupConflictGuard( + onChange = { saveAccountSettings() }, + reapply = { reapplyBackup(it) }, + ) - // Re-runs the update that raised each open conflict, once the user keeps the new version. - private val backupConflictRetries = ConcurrentHashMap Unit>() + val backupConflicts get() = backupGuard.conflicts - // Versions whose conflict is being resolved (accepted or restored). They must not raise - // the same conflict again while the resolution is in flight. - private val resolvingBackupVersions = ConcurrentSet() - - // Externally signed versions the user explicitly chose to keep over the backup. - private val acceptedExternalVersions = ConcurrentSet() - - // Serializes the conflict bookkeeping: the backup collectors run on IO threads while - // the user resolves conflicts from the UI thread. - private val backupConflictLock = Any() - - /** - * 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. - */ - private fun acceptIntoBackup( - saved: T?, - incoming: T, - isEmpty: Boolean = false, - retry: () -> Unit, - ): Boolean = - synchronized(backupConflictLock) { - if (saved?.id == incoming.id) return@synchronized false - if (resolvingBackupVersions.contains(incoming.id)) return@synchronized false - - val slot = backupSlot(incoming) - val pending = backupConflicts.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) { - backupConflictRetries[slot] = retry - return@synchronized 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@synchronized false - } - - val isLocal = LocallySignedEvents.contains(incoming.id) - val accepted = acceptedExternalVersions.contains(incoming.id) - - // 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 && !accepted) { - 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 - val conflict = ReplaceableBackupConflict(reference, incoming, cause, diff) - backupConflictRetries[slot] = retry - backupConflicts.update { it + (slot to conflict) } - saveAccountSettings() - return@synchronized false - } - } - - dropBackupConflict(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. - !isEmpty || isLocal || accepted - } - - private fun dropBackupConflict(slot: String) { - if (backupConflicts.value.containsKey(slot)) { - backupConflicts.update { it - slot } - saveAccountSettings() - } - backupConflictRetries.remove(slot) - } - - /** - * Re-seeds the open conflicts read back from storage. - * - * A conflict outlives the process that raised it: it is the user's undecided question, and - * the whole point of holding the backup back is that nobody answered it yet. 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 restoreBackupConflicts(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.isNotEmpty()) backupConflicts.update { it + restored } - } + /** 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> = backupConflicts.value.values.map { Triple(it.saved, it.incoming, it.cause) } + fun openBackupConflicts(): List> = backupGuard.open() /** * Re-runs the update that a restored conflict no longer carries. * - * [backupConflictRetries] holds closures, so it cannot be written to disk; a conflict read + * 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. @@ -1318,92 +1217,13 @@ class AccountSettings( is MuteListEvent -> updateMuteList(incoming) // NIP-78 is deliberately absent: its update also takes the decrypted // AccountSyncedSettingsInternal, which only the caller can produce. Keeping that - // version still works — [keepIncomingVersion] records the id in - // [acceptedExternalVersions] first, so the next delivery walks into the backup + // version still works — [BackupConflictGuard.keepIncoming] records the id as accepted + // first, so the next delivery walks into the backup // unchallenged — it just lands when the event next arrives rather than right now. else -> Unit } } - /** - * Removes [conflict] if it is still the open conflict of its slot. Returns false when it - * was already resolved or replaced by a newer one, so callers never act on a stale conflict. - */ - private fun claimBackupConflict(conflict: ReplaceableBackupConflict): (() -> Unit)? = - synchronized(backupConflictLock) { - claimBackupConflictLocked(conflict) - } - - private fun claimBackupConflictLocked(conflict: ReplaceableBackupConflict): (() -> Unit)? { - var claimed = false - backupConflicts.update { current -> - claimed = current[conflict.slot] === conflict - if (claimed) current - conflict.slot else current - } - if (!claimed) return null - saveAccountSettings() - // 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 backupConflictRetries.remove(conflict.slot) ?: { reapplyBackup(conflict.incoming) } - } - - /** Keeps the new version: the backup moves forward to [ReplaceableBackupConflict.incoming]. */ - fun keepIncomingVersion(conflict: ReplaceableBackupConflict) { - val retry = - synchronized(backupConflictLock) { - val claimed = claimBackupConflictLocked(conflict) ?: return - acceptedExternalVersions.add(conflict.incoming.id) - claimed - } - // Outside the lock: the retry re-enters acceptIntoBackup, which takes it again. - 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 - * [finishRestoringSavedVersion]. - */ - fun startRestoringSavedVersion(conflict: ReplaceableBackupConflict): (() -> Unit)? = - synchronized(backupConflictLock) { - val retry = claimBackupConflictLocked(conflict) ?: return@synchronized null - resolvingBackupVersions.add(conflict.incoming.id) - 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 finishRestoringSavedVersion( - conflict: ReplaceableBackupConflict, - token: () -> Unit, - restored: Boolean, - ) { - synchronized(backupConflictLock) { - finishRestoringLocked(conflict, token, restored) - } - } - - private fun finishRestoringLocked( - conflict: ReplaceableBackupConflict, - token: () -> Unit, - restored: Boolean, - ) { - resolvingBackupVersions.remove(conflict.incoming.id) - if (restored) return - var reopened = false - backupConflicts.update { current -> - reopened = !current.containsKey(conflict.slot) - if (reopened) current + (conflict.slot to conflict) else current - } - if (reopened) { - backupConflictRetries[conflict.slot] = token - saveAccountSettings() - } - } - fun updateLocalRelayServers(servers: Set) { if (localRelayServers.value != servers) { localRelayServers.update { servers } @@ -1422,7 +1242,7 @@ class AccountSettings( fun updateUserMetadata(newMetadata: MetadataEvent?) { if (newMetadata == null) return - if (acceptIntoBackup(backupUserMetadata, newMetadata) { updateUserMetadata(newMetadata) }) { + if (backupGuard.accept(backupUserMetadata, newMetadata) { updateUserMetadata(newMetadata) }) { backupUserMetadata = newMetadata saveAccountSettings() } @@ -1431,7 +1251,7 @@ class AccountSettings( fun updateContactListTo(newContactList: ContactListEvent?) { if (newContactList == null) return - if (acceptIntoBackup(backupContactList, newContactList, isEmpty = newContactList.tags.isEmpty()) { updateContactListTo(newContactList) }) { + if (backupGuard.accept(backupContactList, newContactList, isEmpty = newContactList.tags.isEmpty()) { updateContactListTo(newContactList) }) { backupContactList = newContactList saveAccountSettings() } @@ -1440,7 +1260,7 @@ class AccountSettings( fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) { if (newDMRelayList == null) return - if (acceptIntoBackup(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) { + if (backupGuard.accept(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) { backupDMRelayList = newDMRelayList saveAccountSettings() } @@ -1449,7 +1269,7 @@ class AccountSettings( fun updateKeyPackageRelayList(newKeyPackageRelayList: KeyPackageRelayListEvent?) { if (newKeyPackageRelayList == null) return - if (acceptIntoBackup(backupKeyPackageRelayList, newKeyPackageRelayList, isEmpty = newKeyPackageRelayList.tags.isEmpty()) { updateKeyPackageRelayList(newKeyPackageRelayList) }) { + if (backupGuard.accept(backupKeyPackageRelayList, newKeyPackageRelayList, isEmpty = newKeyPackageRelayList.tags.isEmpty()) { updateKeyPackageRelayList(newKeyPackageRelayList) }) { backupKeyPackageRelayList = newKeyPackageRelayList saveAccountSettings() } @@ -1458,7 +1278,7 @@ class AccountSettings( fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) { if (newNIP65RelayList == null) return - if (acceptIntoBackup(backupNIP65RelayList, newNIP65RelayList, isEmpty = newNIP65RelayList.tags.isEmpty()) { updateNIP65RelayList(newNIP65RelayList) }) { + if (backupGuard.accept(backupNIP65RelayList, newNIP65RelayList, isEmpty = newNIP65RelayList.tags.isEmpty()) { updateNIP65RelayList(newNIP65RelayList) }) { backupNIP65RelayList = newNIP65RelayList saveAccountSettings() } @@ -1467,7 +1287,7 @@ class AccountSettings( fun updateCashuWallet(newWallet: CashuWalletEvent?) { if (newWallet == null) return // Replaceable: keep the latest by id (id changes on each re-sign). - if (acceptIntoBackup(backupCashuWallet, newWallet) { updateCashuWallet(newWallet) }) { + if (backupGuard.accept(backupCashuWallet, newWallet) { updateCashuWallet(newWallet) }) { backupCashuWallet = newWallet saveAccountSettings() } @@ -1477,7 +1297,7 @@ class AccountSettings( 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 (!acceptIntoBackup(backupNutzapInfo, newNutzapInfo) { updateNutzapInfo(newNutzapInfo) }) return + 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 @@ -1498,7 +1318,7 @@ class AccountSettings( */ fun clearCashuWallet() { // A deleted wallet must not be restorable from an open conflict. - dropBackupConflict(backupSlotOf(CashuWalletEvent.KIND)) + backupGuard.drop(backupSlotOf(CashuWalletEvent.KIND)) if (backupCashuWallet != null) { backupCashuWallet = null saveAccountSettings() @@ -1507,7 +1327,7 @@ class AccountSettings( /** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */ fun clearNutzapInfo() { - dropBackupConflict(backupSlotOf(NutzapInfoEvent.KIND)) + backupGuard.drop(backupSlotOf(NutzapInfoEvent.KIND)) if (backupNutzapInfo != null) { backupNutzapInfo = null saveAccountSettings() @@ -1561,7 +1381,7 @@ class AccountSettings( fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) { if (newNIPA3PaymentTargets == null) return - if (acceptIntoBackup(backupNipA3PaymentTargets, newNIPA3PaymentTargets, isEmpty = newNIPA3PaymentTargets.tags.isEmpty()) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) { + if (backupGuard.accept(backupNipA3PaymentTargets, newNIPA3PaymentTargets, isEmpty = newNIPA3PaymentTargets.tags.isEmpty()) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) { backupNipA3PaymentTargets = newNIPA3PaymentTargets saveAccountSettings() } @@ -1570,7 +1390,7 @@ class AccountSettings( fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) { if (newBolt12Offers == null) return - if (acceptIntoBackup(backupBolt12Offers, newBolt12Offers, isEmpty = newBolt12Offers.tags.isEmpty()) { updateBolt12Offers(newBolt12Offers) }) { + if (backupGuard.accept(backupBolt12Offers, newBolt12Offers, isEmpty = newBolt12Offers.tags.isEmpty()) { updateBolt12Offers(newBolt12Offers) }) { backupBolt12Offers = newBolt12Offers saveAccountSettings() } @@ -1579,7 +1399,7 @@ class AccountSettings( fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) { if (newSearchRelayList == null) return - if (acceptIntoBackup(backupSearchRelayList, newSearchRelayList, isEmpty = newSearchRelayList.tags.isEmpty()) { updateSearchRelayList(newSearchRelayList) }) { + if (backupGuard.accept(backupSearchRelayList, newSearchRelayList, isEmpty = newSearchRelayList.tags.isEmpty()) { updateSearchRelayList(newSearchRelayList) }) { backupSearchRelayList = newSearchRelayList saveAccountSettings() } @@ -1588,7 +1408,7 @@ class AccountSettings( fun updateIndexRelayList(newIndexRelayList: IndexerRelayListEvent?) { if (newIndexRelayList == null) return - if (acceptIntoBackup(backupIndexRelayList, newIndexRelayList, isEmpty = newIndexRelayList.tags.isEmpty()) { updateIndexRelayList(newIndexRelayList) }) { + if (backupGuard.accept(backupIndexRelayList, newIndexRelayList, isEmpty = newIndexRelayList.tags.isEmpty()) { updateIndexRelayList(newIndexRelayList) }) { backupIndexRelayList = newIndexRelayList saveAccountSettings() } @@ -1597,7 +1417,7 @@ class AccountSettings( fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) { if (newRelayFeedList == null) return - if (acceptIntoBackup(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) { + if (backupGuard.accept(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) { backupRelayFeedsList = newRelayFeedList saveAccountSettings() } @@ -1606,7 +1426,7 @@ class AccountSettings( fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) { if (newBlockedRelayList == null) return - if (acceptIntoBackup(backupBlockedRelayList, newBlockedRelayList, isEmpty = newBlockedRelayList.tags.isEmpty()) { updateBlockedRelayList(newBlockedRelayList) }) { + if (backupGuard.accept(backupBlockedRelayList, newBlockedRelayList, isEmpty = newBlockedRelayList.tags.isEmpty()) { updateBlockedRelayList(newBlockedRelayList) }) { backupBlockedRelayList = newBlockedRelayList saveAccountSettings() } @@ -1615,7 +1435,7 @@ class AccountSettings( fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) { if (newTrustedRelayList == null) return - if (acceptIntoBackup(backupTrustedRelayList, newTrustedRelayList, isEmpty = newTrustedRelayList.tags.isEmpty()) { updateTrustedRelayList(newTrustedRelayList) }) { + if (backupGuard.accept(backupTrustedRelayList, newTrustedRelayList, isEmpty = newTrustedRelayList.tags.isEmpty()) { updateTrustedRelayList(newTrustedRelayList) }) { backupTrustedRelayList = newTrustedRelayList saveAccountSettings() } @@ -1624,7 +1444,7 @@ class AccountSettings( fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) { if (newPrivateHomeRelayList == null) return - if (acceptIntoBackup(backupPrivateHomeRelayList, newPrivateHomeRelayList, isEmpty = newPrivateHomeRelayList.tags.isEmpty()) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) { + if (backupGuard.accept(backupPrivateHomeRelayList, newPrivateHomeRelayList, isEmpty = newPrivateHomeRelayList.tags.isEmpty()) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) { backupPrivateHomeRelayList = newPrivateHomeRelayList saveAccountSettings() } @@ -1635,7 +1455,7 @@ class AccountSettings( override fun updateChannelListTo(newChannelList: ChannelListEvent?) { if (newChannelList == null) return - if (acceptIntoBackup(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) { + if (backupGuard.accept(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) { backupChannelList = newChannelList saveAccountSettings() } @@ -1644,7 +1464,7 @@ class AccountSettings( fun updateGeohashListTo(newGeohashList: GeohashListEvent?) { if (newGeohashList == null) return - if (acceptIntoBackup(backupGeohashList, newGeohashList, isEmpty = newGeohashList.tags.isEmpty()) { updateGeohashListTo(newGeohashList) }) { + if (backupGuard.accept(backupGeohashList, newGeohashList, isEmpty = newGeohashList.tags.isEmpty()) { updateGeohashListTo(newGeohashList) }) { backupGeohashList = newGeohashList saveAccountSettings() } @@ -1653,7 +1473,7 @@ class AccountSettings( fun updateHashtagListTo(newHashtagList: HashtagListEvent?) { if (newHashtagList == null) return - if (acceptIntoBackup(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) { + if (backupGuard.accept(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) { backupHashtagList = newHashtagList saveAccountSettings() } @@ -1662,7 +1482,7 @@ class AccountSettings( fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null) return - if (acceptIntoBackup(backupFavoriteAlgoFeedsList, newFavoriteDvmList, isEmpty = newFavoriteDvmList.tags.isEmpty()) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) { + if (backupGuard.accept(backupFavoriteAlgoFeedsList, newFavoriteDvmList, isEmpty = newFavoriteDvmList.tags.isEmpty()) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) { backupFavoriteAlgoFeedsList = newFavoriteDvmList saveAccountSettings() } @@ -1671,7 +1491,7 @@ class AccountSettings( fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { if (newCommunityList == null) return - if (acceptIntoBackup(backupCommunityList, newCommunityList, isEmpty = newCommunityList.tags.isEmpty()) { updateCommunityListTo(newCommunityList) }) { + if (backupGuard.accept(backupCommunityList, newCommunityList, isEmpty = newCommunityList.tags.isEmpty()) { updateCommunityListTo(newCommunityList) }) { backupCommunityList = newCommunityList saveAccountSettings() } @@ -1682,7 +1502,7 @@ class AccountSettings( override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) { if (newEphemeralChatList == null) return - if (acceptIntoBackup(backupEphemeralChatList, newEphemeralChatList, isEmpty = newEphemeralChatList.tags.isEmpty()) { updateEphemeralChatListTo(newEphemeralChatList) }) { + if (backupGuard.accept(backupEphemeralChatList, newEphemeralChatList, isEmpty = newEphemeralChatList.tags.isEmpty()) { updateEphemeralChatListTo(newEphemeralChatList) }) { backupEphemeralChatList = newEphemeralChatList saveAccountSettings() } @@ -1695,7 +1515,7 @@ class AccountSettings( // so an empty `tags` is NOT an empty list — guard only on null. if (newRelayGroupList == null) return - if (acceptIntoBackup(backupRelayGroupList, newRelayGroupList) { updateRelayGroupListTo(newRelayGroupList) }) { + if (backupGuard.accept(backupRelayGroupList, newRelayGroupList) { updateRelayGroupListTo(newRelayGroupList) }) { backupRelayGroupList = newRelayGroupList saveAccountSettings() } @@ -1708,7 +1528,7 @@ class AccountSettings( // so an empty `tags` is NOT an empty list — guard only on null. if (newConcordList == null) return - if (acceptIntoBackup(backupConcordList, newConcordList) { updateConcordListTo(newConcordList) }) { + if (backupGuard.accept(backupConcordList, newConcordList) { updateConcordListTo(newConcordList) }) { backupConcordList = newConcordList saveAccountSettings() } @@ -1717,7 +1537,7 @@ class AccountSettings( fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) { if (trustProviderList == null) return - if (acceptIntoBackup(backupTrustProviderList, trustProviderList, isEmpty = trustProviderList.tags.isEmpty()) { updateTrustProviderListTo(trustProviderList) }) { + if (backupGuard.accept(backupTrustProviderList, trustProviderList, isEmpty = trustProviderList.tags.isEmpty()) { updateTrustProviderListTo(trustProviderList) }) { backupTrustProviderList = trustProviderList saveAccountSettings() } @@ -1726,7 +1546,7 @@ class AccountSettings( fun updateMuteList(newMuteList: MuteListEvent?) { if (newMuteList == null) return - if (acceptIntoBackup(backupMuteList, newMuteList, isEmpty = newMuteList.tags.isEmpty()) { updateMuteList(newMuteList) }) { + if (backupGuard.accept(backupMuteList, newMuteList, isEmpty = newMuteList.tags.isEmpty()) { updateMuteList(newMuteList) }) { backupMuteList = newMuteList saveAccountSettings() } @@ -1738,7 +1558,7 @@ class AccountSettings( ) { if (appSettings == null) return - if (acceptIntoBackup(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isEmpty()) { updateAppSpecificData(appSettings, newSyncedSettings) }) { + if (backupGuard.accept(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isEmpty()) { updateAppSpecificData(appSettings, newSyncedSettings) }) { backupAppSpecificData = appSettings // An emptied blob (signed here or accepted by the user) has no settings to merge. if (appSettings.content.isEmpty()) { 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..088dc95275 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt @@ -0,0 +1,226 @@ +/* + * 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() + + /** + * 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 + + 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. + !isEmpty || isLocal || wasAccepted + } + + /** 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) + 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) + if (restored || _conflicts.value.containsKey(conflict.slot)) return@withLock + _conflicts.update { it + (conflict.slot to conflict) } + retries[conflict.slot] = token + onChange() + } + } +} 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..b9f9e40021 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt @@ -0,0 +1,402 @@ +/* + * 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) + } +} From 212ef22d9bb68b6a449825a124201a8cae43dca5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:40:12 +0000 Subject: [PATCH 22/31] fix: say what was added in conflict headlines and cap Home cards List headlines now add what the other app added to the same list, worded per list ("12 follows were dropped, and 40 were added", "You'd leave 2 groups, and join 1", "3 relays were unblocked, and 1 newly blocked"); pills show +N next to -N and the follow count turns green when the list grew. Home shows the lead card and at most two pills; the rest sit behind an "N more lists changed" toggle that expands into a capped, scrollable area, since the cards float over the feed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../loggedIn/backups/BackupConflictCards.kt | 104 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 17 +++ 2 files changed, 116 insertions(+), 5 deletions(-) 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 index 5f602df953..18542c4d46 100644 --- 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 @@ -30,25 +30,32 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource 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.compose.ui.unit.sp @@ -68,6 +75,7 @@ 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.nip02FollowList.ContactListEvent @@ -119,10 +127,48 @@ fun BackupConflictCards( verticalArrangement = Arrangement.spacedBy(8.dp), ) { LeadConflictCard(conflicts.first()) { open(conflicts.first()) } - conflicts.drop(1).forEach { conflict -> ConflictPill(conflict) { open(conflict) } } + + // The cards float over the feed, so they can't grow with the number of conflicts: + // a few pills show, the rest wait behind a toggle and scroll inside a capped area. + val rest = conflicts.drop(1) + var expanded by rememberSaveable { mutableStateOf(false) } + if (rest.size <= MAX_PILLS || expanded) { + Column( + Modifier.heightIn(max = 280.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + rest.forEach { conflict -> ConflictPill(conflict) { open(conflict) } } + } + if (rest.size > MAX_PILLS) MoreConflictsToggle(stringRes(R.string.backup_card_show_less)) { expanded = false } + } else { + rest.take(MAX_PILLS).forEach { conflict -> ConflictPill(conflict) { open(conflict) } } + val hidden = rest.size - MAX_PILLS + MoreConflictsToggle(pluralStringResource(R.plurals.backup_card_more_lists, hidden, hidden)) { expanded = true } + } } } +private const val MAX_PILLS = 2 + +@Composable +private fun MoreConflictsToggle( + label: String, + onClick: () -> Unit, +) { + Text( + label, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) +} + /** A headline that says what happened, in the event's own terms. */ @Composable private fun headlineOf( @@ -161,10 +207,57 @@ private fun headlineOf( else -> null } // A headline counting removed items reads wrong when only encrypted items were lost. - return specific?.takeUnless { it.isEmpty() } - ?: stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) + 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: joining chats, newly blocking relays, adding everything else. + val added = addedToHeadlinedList(diff, conflict.eventType) + if (added <= 0) return specific + val tail = + when { + 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( @@ -243,7 +336,7 @@ private fun LeadConflictCard( Modifier.weight(1f), height = 8.dp, ) - Text(new.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = tones.removed) + Text(new.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = if (new < saved) tones.removed else tones.added) } } else { CountChips(counts) @@ -285,7 +378,8 @@ private fun ConflictPill( 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) - if (counts.removed > 0) StatusTag("−" + counts.removed, tones.removed) + if (counts.removed > 0) StatusTag("\u2212" + counts.removed, tones.removed) + if (counts.added > 0) StatusTag("+" + counts.added, tones.added) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5748aaa8b9..d5b29b7be0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2848,6 +2848,7 @@ %1$d follow was dropped %1$d follows were dropped + Show less Changed by another app · %1$s Keep %1$s Restore %1$s @@ -3040,6 +3041,22 @@ %1$d BOLT12 offer removed %1$d BOLT12 offers removed + + %1$s, and %2$d was added + %1$s, and %2$d were added + + + %1$s, and join %2$d + %1$s, and join %2$d + + + %1$s, and %2$d newly blocked + %1$s, and %2$d newly blocked + + + %1$d more list changed + %1$d more lists changed + Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems. Your nutzap key was replaced Your nutzap key was removed From c17d84fe272faaa8911209fed0337fe36af654c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:04:20 +0000 Subject: [PATCH 23/31] fix: make keeping the new version the default on conflict reviews Keep new version is now the filled button on the right; Restore, Re-mute, Rejoin and Block again become an outlined red button, since each one reverts another app's change. Applies to every review screen through the shared bottom bar. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../2026-09-22-replaceable-backup-conflicts.md | 2 ++ .../backups/BackupConflictReviewScreen.kt | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index df03471591..9d4ee47668 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -90,6 +90,8 @@ only surviving copy of the user's data is gone. encrypted-only Cashu wallet and Concord list as one explained panel. Only NIP-78 app settings still uses the generic removed / added / changed sections. 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 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 index b093fae8e9..1026079ffc 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 @@ -37,6 +38,7 @@ 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 @@ -312,11 +314,18 @@ private fun ReviewActions( .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - OutlinedButton(onClick = onKeepNew, modifier = Modifier.weight(1f).height(52.dp)) { - Text(keepLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold) + // 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. + 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 = onRestore, modifier = Modifier.weight(1f).height(52.dp)) { - Text(restoreLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) + Button(onClick = onKeepNew, modifier = Modifier.weight(1f).height(52.dp)) { + Text(keepLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) } } } From 9678107d32c5c0629eebf828242f7f185ac2fb7c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:08:12 +0000 Subject: [PATCH 24/31] fix: word backup conflicts as changes the user made on purpose The other client acted for the user, so the copy now says what they did, in the past tense, and only asks to confirm: "You left 1 public chat" instead of "You'd leave", "You unfollowed 12 people and followed 40", "You unblocked 3 relays", "You changed your mute list in another app", "You removed all private items". Nothing is called lost or dropped in headlines anymore; the NIP-78 warning keeps its requested wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 17 +- .../loggedIn/backups/BackupConflictCards.kt | 4 +- .../backups/BackupConflictEventViews.kt | 4 +- .../backups/BackupConflictListViews.kt | 4 +- amethyst/src/main/res/values/strings.xml | 152 +++++++++--------- 5 files changed, 97 insertions(+), 84 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 9d4ee47668..6eb5721d58 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -52,16 +52,19 @@ only surviving copy of the user's data is gone. 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 at the top of the feed (`BackupConflictCards`, - next to the key-backup nudge): "Your mute list changed in another app" with a + next to the key-backup nudge): "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 - ("3 relays were unblocked", "Your follow list shrank" with its bar); the others collapse + ("You unblocked 3 relays", "You unfollowed 12 people" with its bar); the others collapse into slim pills. At most two pills show; more open conflicts sit behind an "N more lists changed" toggle that expands into a capped, scrollable area, since the cards float over the feed instead of scrolling with it. List headlines lead with what was lost and add - what was added to the same list, worded per list ("12 follows were dropped, and 40 were - added", "You'd leave 2 groups, and join 1", "3 relays were unblocked, and 1 newly - blocked"). Tapping one opens `Route.BackupConflictReview(slot)` + 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 @@ -80,12 +83,12 @@ only surviving copy of the user's data is gone. cards, then inline field diffs with images as thumbnails; NIP-65 reach numbers ("people find your posts on 2 of 3 relays") over 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'd leave it. The other lists (`BackupConflictListViews.kt`) get their own too: + when you left it. The other lists (`BackupConflictListViews.kt`) get their own too: each relay list framed by what it is for (DM, key-package, search, indexer, relay-feed, private-outbox and trusted relays as "N of M" reach over per-relay rows; 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'd leave them, hashtags and places as a pill cloud, trust providers as a + faded when you left them, hashtags and places as a pill cloud, 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. 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 index 18542c4d46..017dc0fca0 100644 --- 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 @@ -210,11 +210,13 @@ private fun headlineOf( 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: joining chats, newly blocking relays, adding everything else. + // 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 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 index aebd997841..4bc4f626cf 100644 --- 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 @@ -1060,7 +1060,7 @@ private fun MintRow( } // --------------------------------------------------------------------------------------- -// Groups: a tile per group, faded when you'd leave it, badged when new or renamed. +// Groups: a tile per group, faded when you left it, badged when new or renamed. // --------------------------------------------------------------------------------------- private class GroupTile( @@ -1097,7 +1097,7 @@ private fun LazyListScope.groupItems( StatTile( diff.groups.removed.size .toString(), - stringRes(R.string.backup_review_would_leave), + stringRes(R.string.backup_review_left), tones.removed, Modifier.weight(1f), ) 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 index 63466e4488..c0e5183485 100644 --- 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 @@ -410,7 +410,7 @@ private fun LazyListScope.spaceGrid( key: (T) -> String, content: @Composable (T, ItemFate, Modifier) -> Unit, ) { - countTiles(items, R.string.backup_review_would_leave, R.string.backup_review_joined) + 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)) } @@ -561,7 +561,7 @@ private fun LazyListScope.ephemeralRoomItems( ) { val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty() val rooms = fates(saved, diff.rooms) { it } - countTiles(rooms, R.string.backup_review_would_leave, R.string.backup_review_joined) + 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) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d5b29b7be0..ac57a40e06 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2829,13 +2829,13 @@ %1$d video %1$d videos - Your %1$s changed in another app + You changed your %1$s in another app Removed (%1$s) Added (%1$s) Changed (%1$s) - All private (encrypted) items were removed. - Private (encrypted) items also changed. They can\'t be compared here. - Private (encrypted) items were added. + 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 Review changes On this device @@ -2845,11 +2845,11 @@ %1$s added %1$s changed - %1$d follow was dropped - %1$d follows were dropped + You unfollowed %1$d person + You unfollowed %1$d people Show less - Changed by another app · %1$s + Changed in another app · %1$s Keep %1$s Restore %1$s Re-mute %1$s @@ -2869,11 +2869,11 @@ %1$s newly muted Words & hashtags %1$s and %2$s more - Private items were wiped + You removed all private items They\'re encrypted, so only you can see what they were. Restoring brings them all back. - Items were made private - They\'re encrypted now, which may be where the public items above went. - Private items were rewritten + 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 @@ -2892,7 +2892,7 @@ 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. - you\'d leave + left joined renamed was “%1$s” @@ -2925,7 +2925,7 @@ Trusted Untrusted %1$s unblocked - Amethyst would connect to them again + Amethyst will connect to them again No longer blocked Newly blocked Still blocked @@ -2948,122 +2948,130 @@ %1$s offers removed People paying you only see what\'s in this list. BOLT12 offer - It was emptied - It was created - It was rewritten + 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. - %1$d muted thing is visible again - %1$d muted things are visible again + You unmuted %1$d thing + You unmuted %1$d things - Your profile lost %1$d field - Your profile lost %1$d fields + You removed %1$d profile field + You removed %1$d profile fields - Your posts lost %1$d relay - Your posts lost %1$d relays + You removed %1$d public relay + You removed %1$d public relays - DMs may miss you: %1$d inbox relay dropped - DMs may miss you: %1$d inbox relays dropped + You removed %1$d DM relay + You removed %1$d DM relays - %1$d group-chat key relay dropped - %1$d group-chat key relays dropped + You removed %1$d group-chat key relay + You removed %1$d group-chat key relays - %1$d search relay dropped - %1$d search relays dropped + You removed %1$d search relay + You removed %1$d search relays - %1$d indexer relay dropped - %1$d indexer relays dropped + You removed %1$d indexer relay + You removed %1$d indexer relays - %1$d relay feed removed - %1$d relay feeds removed + You removed %1$d relay feed + You removed %1$d relay feeds - Your drafts lost %1$d storage relay - Your drafts lost %1$d storage relays + You removed %1$d private relay + You removed %1$d private relays - %1$d relay is no longer trusted - %1$d relays are no longer trusted + You stopped trusting %1$d relay + You stopped trusting %1$d relays - %1$d relay was unblocked - %1$d relays were unblocked + You unblocked %1$d relay + You unblocked %1$d relays - You\'d leave %1$d public chat - You\'d leave %1$d public chats + You left %1$d public chat + You left %1$d public chats - You\'d leave %1$d community - You\'d leave %1$d communities + You left %1$d community + You left %1$d communities - You\'d leave %1$d chat room - You\'d leave %1$d chat rooms + You left %1$d chat room + You left %1$d chat rooms - You\'d leave %1$d group - You\'d leave %1$d groups + You left %1$d group + You left %1$d groups - %1$d favorite feed removed - %1$d favorite feeds removed + You removed %1$d favorite feed + You removed %1$d favorite feeds - %1$d hashtag unfollowed - %1$d hashtags unfollowed + You unfollowed %1$d hashtag + You unfollowed %1$d hashtags - %1$d place unfollowed - %1$d places unfollowed + You unfollowed %1$d place + You unfollowed %1$d places - %1$d trust service changed provider - %1$d trust services changed provider + You changed the provider of %1$d trust service + You changed the provider of %1$d trust services - %1$d nutzap mint removed - %1$d nutzap mints removed + You removed %1$d nutzap mint + You removed %1$d nutzap mints - %1$d way to pay you was removed - %1$d ways to pay you were removed + You removed %1$d way to get paid + You removed %1$d ways to get paid - %1$d BOLT12 offer removed - %1$d BOLT12 offers removed + You removed %1$d BOLT12 offer + You removed %1$d BOLT12 offers - %1$s, and %2$d was added - %1$s, and %2$d were added + %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 join %2$d - %1$s, and join %2$d + %1$s and joined %2$d + %1$s and joined %2$d - %1$s, and %2$d newly blocked - %1$s, and %2$d newly blocked + %1$s and blocked %2$d + %1$s and blocked %2$d %1$d more list changed %1$d more lists changed Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems. - Your nutzap key was replaced - Your nutzap key was removed - Your Cashu wallet was emptied - Your Cashu wallet was rewritten - Your Concord list was emptied - Your Concord list was rewritten + 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 From d7351cb30e6571d366256924d0ca79ce4db5bd03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:12:01 +0000 Subject: [PATCH 25/31] fix: show backup conflict cards inside the Home feed The cards floated over the feed, covering its first posts until every conflict was resolved. They are now the first item of each Home tab's LazyColumn (added only while a conflict is open, so scroll anchoring is unchanged otherwise) and scroll away with the posts. Empty, loading and error states show them above the placeholder. With nothing covered, the two-pill cap and its "N more" toggle are gone, and the cards rely on the feed's own padding instead of their own window insets. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 9 +-- .../loggedIn/backups/BackupConflictCards.kt | 76 +++---------------- .../ui/screen/loggedIn/home/HomeScreen.kt | 61 +++++++++++++-- amethyst/src/main/res/values/strings.xml | 5 -- 4 files changed, 68 insertions(+), 83 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 6eb5721d58..a9a0662ce3 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -51,14 +51,13 @@ only surviving copy of the user's data is gone. 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 at the top of the feed (`BackupConflictCards`, - next to the key-backup nudge): "You changed your mute list in another app" with a + 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. At most two pills show; more open conflicts sit behind an "N more lists - changed" toggle that expands into a capped, scrollable area, since the cards float over - the feed instead of scrolling with it. List headlines lead with what was lost and add + 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 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 index 017dc0fca0..729fb0707b 100644 --- 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 @@ -27,39 +27,25 @@ 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.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource 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.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -68,7 +54,6 @@ import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflic 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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff @@ -95,80 +80,37 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDif import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff /** - * Cards at the top of Home, one per open [ReplaceableBackupConflict]: another app replaced - * one of the account's backed-up lists (or the profile) with a version that lost data. They - * can't be dismissed. They stay until the user decides on the review screen they open - * ([BackupConflictReviewScreen]), where the differences can be inspected. + * 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 shrink bar, or lost/gained counts); the rest collapse into slim pills - * so they don't cover the feed. + * (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( - accountViewModel: AccountViewModel, + conflictMap: Map, nav: INav, modifier: Modifier = Modifier, ) { - val conflictMap by accountViewModel.account.settings.backupConflicts - .collectAsStateWithLifecycle() if (conflictMap.isEmpty()) return - val conflicts = conflictMap.values.sortedByDescending { it.cause.createdAt } + val conflicts = remember(conflictMap) { conflictMap.values.sortedByDescending { it.cause.createdAt } } val open = { conflict: ReplaceableBackupConflict -> nav.nav(Route.BackupConflictReview(conflict.slot)) } - // Home's padding only covers the top bar; in landscape a side 3-button navigation bar - // or a display cutout would otherwise sit on top of the cards. Column( modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) .padding(horizontal = 12.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { LeadConflictCard(conflicts.first()) { open(conflicts.first()) } - - // The cards float over the feed, so they can't grow with the number of conflicts: - // a few pills show, the rest wait behind a toggle and scroll inside a capped area. - val rest = conflicts.drop(1) - var expanded by rememberSaveable { mutableStateOf(false) } - if (rest.size <= MAX_PILLS || expanded) { - Column( - Modifier.heightIn(max = 280.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - rest.forEach { conflict -> ConflictPill(conflict) { open(conflict) } } - } - if (rest.size > MAX_PILLS) MoreConflictsToggle(stringRes(R.string.backup_card_show_less)) { expanded = false } - } else { - rest.take(MAX_PILLS).forEach { conflict -> ConflictPill(conflict) { open(conflict) } } - val hidden = rest.size - MAX_PILLS - MoreConflictsToggle(pluralStringResource(R.plurals.backup_card_more_lists, hidden, hidden)) { expanded = true } - } + conflicts.drop(1).forEach { conflict -> ConflictPill(conflict) { open(conflict) } } } } -private const val MAX_PILLS = 2 - -@Composable -private fun MoreConflictsToggle( - label: String, - onClick: () -> Unit, -) { - Text( - label, - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .clickable(onClick = onClick) - .padding(vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - ) -} - /** A headline that says what happened, in the event's own terms. */ @Composable private fun headlineOf( 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 b094fa4348..6036ed1169 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 @@ -52,6 +56,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 @@ -71,6 +76,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 @@ -301,11 +308,6 @@ private fun HomePages( .align(Alignment.TopCenter) .padding(top = paddingValues.calculateTopPadding()), ) { - BackupConflictCards( - accountViewModel = accountViewModel, - nav = nav, - ) - BackupKeysNudge( accountViewModel = accountViewModel, nav = nav, @@ -398,7 +400,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() } }, ) } } @@ -431,11 +435,20 @@ fun FeedLoaded( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val backupConflicts by accountViewModel.account.settings.backupConflicts + .collectAsStateWithLifecycle() 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 (backupConflicts.isNotEmpty()) { + item(key = "backupConflicts", contentType = "backupConflicts") { + BackupConflictCards(backupConflicts, nav) + } + } if (liveSection != null) { item { DisplayLiveBubbles(liveSection, accountViewModel, nav) @@ -516,6 +529,42 @@ 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, + 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 ac57a40e06..3409be8179 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2848,7 +2848,6 @@ You unfollowed %1$d person You unfollowed %1$d people - Show less Changed in another app · %1$s Keep %1$s Restore %1$s @@ -3061,10 +3060,6 @@ %1$s and blocked %2$d %1$s and blocked %2$d - - %1$d more list changed - %1$d more lists changed - 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 From 67deb78226ae05edf0cf001450f524275d110bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:30:35 +0000 Subject: [PATCH 26/31] feat: accept backup conflicts from Home and title reviews by what changed Each Home card (lead card and pills) gets an Accept button that keeps the new version in one tap, replacing the pills' -N/+N tags and the lead card's chevron; tapping the card still opens the review. The review screen's title now says what happened to the event: "Community list updated", or "... cleared" when the other app emptied it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 4 +++ .../loggedIn/backups/BackupConflictCards.kt | 31 ++++++++++++++++--- .../backups/BackupConflictReviewScreen.kt | 9 ++++-- .../ui/screen/loggedIn/home/HomeScreen.kt | 3 +- amethyst/src/main/res/values/strings.xml | 4 ++- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index a9a0662ce3..7a3c749661 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -91,6 +91,10 @@ only surviving copy of the user's data is gone. 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. 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 index 729fb0707b..112bb4c143 100644 --- 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 @@ -26,12 +26,15 @@ 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 @@ -93,6 +96,7 @@ import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff fun BackupConflictCards( conflictMap: Map, nav: INav, + onAccept: (ReplaceableBackupConflict) -> Unit, modifier: Modifier = Modifier, ) { if (conflictMap.isEmpty()) return @@ -106,8 +110,9 @@ fun BackupConflictCards( .padding(horizontal = 12.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - LeadConflictCard(conflicts.first()) { open(conflicts.first()) } - conflicts.drop(1).forEach { conflict -> ConflictPill(conflict) { open(conflict) } } + val lead = conflicts.first() + LeadConflictCard(lead, onAccept = { onAccept(lead) }) { open(lead) } + conflicts.drop(1).forEach { conflict -> ConflictPill(conflict, onAccept = { onAccept(conflict) }) { open(conflict) } } } } @@ -236,6 +241,7 @@ private fun encryptedHeadline( @Composable private fun LeadConflictCard( conflict: ReplaceableBackupConflict, + onAccept: () -> Unit, onClick: () -> Unit, ) { val tones = conflictTones() @@ -263,7 +269,7 @@ private fun LeadConflictCard( color = MaterialTheme.colorScheme.placeholderText, ) } - Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = stringRes(R.string.backup_conflict_review), tint = MaterialTheme.colorScheme.placeholderText) + AcceptButton(onAccept) } val diff = conflict.diff if (diff is ContactListDiff) { @@ -302,6 +308,7 @@ private fun CountChips(counts: ConflictCounts) { @Composable private fun ConflictPill( conflict: ReplaceableBackupConflict, + onAccept: () -> Unit, onClick: () -> Unit, ) { val tones = conflictTones() @@ -322,8 +329,22 @@ private fun ConflictPill( 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) - if (counts.removed > 0) StatusTag("\u2212" + counts.removed, tones.removed) - if (counts.added > 0) StatusTag("+" + counts.added, tones.added) + 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/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt index 1026079ffc..919b0b0dbd 100644 --- 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 @@ -331,9 +331,14 @@ private fun ReviewActions( } } -/** "Follow list", "Mute list"…: the event's name as a title. */ +/** "Follow list updated", "Mute list cleared"…: the event and what happened to it, as a title. */ @Composable -internal fun eventTitle(conflict: ReplaceableBackupConflict): String = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() } +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 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 6036ed1169..8a74430215 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 @@ -446,7 +446,7 @@ fun FeedLoaded( // scroll position anchors when new posts arrive) is unchanged the rest of the time. if (backupConflicts.isNotEmpty()) { item(key = "backupConflicts", contentType = "backupConflicts") { - BackupConflictCards(backupConflicts, nav) + BackupConflictCards(backupConflicts, nav, onAccept = accountViewModel.account::acceptExternalVersion) } } if (liveSection != null) { @@ -552,6 +552,7 @@ private fun WithBackupConflicts( BackupConflictCards( backupConflicts, nav, + onAccept = accountViewModel.account::acceptExternalVersion, Modifier .weight(1f, fill = false) .verticalScroll(rememberScrollState()) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3409be8179..c79075e1f4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2837,7 +2837,6 @@ You also changed private (encrypted) items. They can\'t be compared here. You added private (encrypted) items. Restore saved version - Review changes On this device New version Not set @@ -2848,6 +2847,9 @@ 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 From e7e886b45636f451574a7aca219d6f0d262b4d11 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 23 Sep 2026 09:41:00 -0400 Subject: [PATCH 27/31] fix: let an emptied NIP-78 blob reach the backup guard, and name the places button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things found while sweeping every conflict screen. A kind:30078 another app emptied never reached the backup guard. AccountSettings already accepts one — updateAppSpecificData passes `isEmpty` and returns before merging — but its only caller decrypts first, and empty content cannot be decrypted, so the event died in the catch ("Error Decrypting latestAppSpecificData", once per launch, forever). A wipe therefore could not raise the conflict the card was written for. Empty content now goes straight to the guard; the settings handed over with it are never merged, so the defaults cannot overwrite real ones. This closes the gap but does not yet make the card appear on the tablet: that account's backupAppSpecificData reads back null on every launch, so there is nothing to diff the wipe against. That is a separate, undiagnosed problem. The follow-up: geohash conflicts borrowed the hashtag button. The screen counts "places gone" under "Followed locations", so "Restore topics" contradicted the page it sat on; geohashes get their own string. Co-Authored-By: Claude Opus 5 (1M context) --- .../nip78AppSpecific/AppSpecificState.kt | 21 ++++++++++++++++--- .../backups/BackupConflictReviewScreen.kt | 5 ++++- amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 23 insertions(+), 4 deletions(-) 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..7df428358e 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.isEmpty()) { + 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/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt index 919b0b0dbd..1ad76ab8af 100644 --- 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 @@ -376,7 +376,10 @@ private fun actionLabels(conflict: ReplaceableBackupConflict): Pair 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, is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics) + 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) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c79075e1f4..2090cdd57e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2900,6 +2900,7 @@ Block %1$s again Restore feeds Restore topics + Restore places Restore providers Restore wallet kept From a49a338d061c4d5aeb05e37e0381e0bca3cb403f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:47:34 +0000 Subject: [PATCH 28/31] fix: show followed places as city and geohash in conflict reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Place pills showed the bare geohash. They now resolve it through LoadCityName like the rest of the app, reading "City · #geohash" and falling back to "#geohash" while loading or without a geocoder. Place and hashtag pills also open their feeds when tapped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 2 +- .../backups/BackupConflictListViews.kt | 41 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index 7a3c749661..ef0ce09114 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -87,7 +87,7 @@ only surviving copy of the user's data is gone. private-outbox and trusted relays as "N of M" reach over per-relay rows; 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, trust providers as a + 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. 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 index c0e5183485..a9b39965b9 100644 --- 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 @@ -69,6 +69,7 @@ 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 @@ -132,8 +133,8 @@ internal fun LazyListScope.listDiffItems( 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) - is GeohashListDiff -> topicItems(diff.geohashes, (conflict.saved as? GeohashListEvent)?.publicGeohashes().orEmpty(), diff.privateItems, isPlace = true) + 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) @@ -617,6 +618,7 @@ private fun LazyListScope.topicItems( 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) @@ -637,18 +639,22 @@ private fun LazyListScope.topicItems( 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)) - Text( - if (isPlace) topic else "#$topic", - color = color, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null, - ) + 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) + } } } } @@ -657,6 +663,23 @@ private fun LazyListScope.topicItems( 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. // --------------------------------------------------------------------------------------- From 5291ba9b8333167d700eedd49f5fe1500c8d64f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 14:11:54 +0000 Subject: [PATCH 29/31] fix: drop the "N of M relays" cards from relay list reviews The public relay list led with "People find your posts on 2 of 3 relays" and the other relay lists with "Search runs on 1 of 2 relays" and the like. The screen is about the change, which those cards don't describe; the tagged rows underneath already show what was kept, dropped and added. The trust provider summary stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 9 ++-- .../backups/BackupConflictEventViews.kt | 25 ----------- .../backups/BackupConflictListViews.kt | 43 +++++-------------- amethyst/src/main/res/values/strings.xml | 19 -------- 4 files changed, 15 insertions(+), 81 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index ef0ce09114..ef3e890881 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -79,12 +79,13 @@ only surviving copy of the user's data is gone. 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 reach numbers ("people - find your posts on 2 of 3 relays") over outbox and inbox lanes; nutzap info a key swap + 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 framed by what it is for (DM, key-package, search, indexer, relay-feed, - private-outbox and trusted relays as "N of M" reach over per-relay rows; blocked relays + 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 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 index 4bc4f626cf..c0d186b28d 100644 --- 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 @@ -837,35 +837,10 @@ private fun LazyListScope.nip65Items( val outbox = lane(saved, new, writes) val inbox = lane(saved, new, reads) - item(key = "relay-reach", contentType = "reach") { - Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - ReachTile(stringRes(R.string.backup_review_reach_posts), outbox, Modifier.weight(1f)) - ReachTile(stringRes(R.string.backup_review_reach_replies), inbox, Modifier.weight(1f)) - } - } 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) } -@Composable -private fun ReachTile( - caption: String, - lane: List, - modifier: Modifier, -) { - val tones = conflictTones() - val had = lane.count { it.state != LaneState.ADDED } - val kept = lane.count { it.state == LaneState.KEPT } - val color = if (kept < had) tones.removed else tones.added - Column(modifier.clip(RoundedCornerShape(20.dp)).background(color.copy(alpha = 0.12f)).padding(14.dp)) { - Text(caption, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) - Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text(kept.toString(), fontSize = 34.sp, lineHeight = 36.sp, fontWeight = FontWeight.Bold, color = color) - Text(stringRes(R.string.backup_review_of_relays, had.toString()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp)) - } - } -} - private fun LazyListScope.laneItems( key: String, stringRes: Int, 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 index a9b39965b9..6842232edc 100644 --- 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 @@ -211,26 +211,23 @@ private fun LazyListScope.privateItemsCard(change: ContentChange) { // Relay lists, framed by what each one is for. // --------------------------------------------------------------------------------------- -/** How a relay list is presented: the reach it gives, or the protection it takes away. */ +/** 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 headline: Int, - val explainer: Int, val keptTag: Int, val droppedTag: Int = R.string.backup_review_dropped_tag, - val lossIsWarning: Boolean = false, ) private fun framingOf(type: BackupEventType): RelayFraming = when (type) { - BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_reach_dms, R.string.backup_review_reach_dms_explainer, R.string.backup_review_receiving_tag) - BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_reach_key_packages, R.string.backup_review_reach_key_packages_explainer, R.string.backup_review_listed_tag) - BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_reach_search, R.string.backup_review_reach_search_explainer, R.string.backup_review_searching_tag) - BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_reach_indexers, R.string.backup_review_reach_indexers_explainer, R.string.backup_review_listed_tag) - BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_reach_relay_feeds, R.string.backup_review_reach_relay_feeds_explainer, R.string.backup_review_listed_tag) - BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_reach_private_outbox, R.string.backup_review_reach_private_outbox_explainer, R.string.backup_review_storing_tag) - BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_reach_trusted, R.string.backup_review_reach_trusted_explainer, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag, lossIsWarning = true) - else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_reach_generic, R.string.backup_review_reach_generic_explainer, R.string.backup_review_listed_tag) + 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 = @@ -255,28 +252,8 @@ private fun LazyListScope.relayListItems( 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) - val had = rows.count { it.second != ItemFate.ADDED } - val kept = rows.count { it.second == ItemFate.KEPT } - item(key = "relay-hero", contentType = "hero") { - val tones = conflictTones() - val color = if (framing.lossIsWarning) tones.changed else tones.removed - 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 = framing.icon, contentDescription = null, tint = color) - } - Column { - Text(stringRes(framing.headline), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) - Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text(kept.toString(), fontSize = 34.sp, lineHeight = 36.sp, fontWeight = FontWeight.Bold, color = if (kept < had) color else tones.added) - Text(stringRes(R.string.backup_review_of_relays, had.toString()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp)) - } - Text(stringRes(framing.explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) - } - } - } - } items(rows, key = { "relay-" + it.first.url }, contentType = { "relay-row" }) { (url, fate) -> RelayFateRow(url, fate, framing, nav) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2090cdd57e..2d5cd1d607 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2883,9 +2883,6 @@ Units Left Renamed - People find your posts on - Replies reach you on - of %1$s relays Outbox where you publish Inbox @@ -2904,22 +2901,6 @@ Restore providers Restore wallet kept - People can DM you on - Messages sent to the dropped relays won\'t reach you. Most senders only look at this list. - New group chats can find you on - Others look here for the keys that let them add you to encrypted group chats. - Search runs on - Searches in Amethyst only query these relays. - Profiles and relay lists are found on - Amethyst asks these relays where other people publish. - You browse as feeds - Relays you open directly as a feed. - Drafts and private data are stored on - Dropped relays won\'t get your new drafts, and other devices may not find the old ones. - Amethyst trusts without asking - Amethyst will ask again before connecting to relays you no longer trust. - You use - Relays saved in this list. Receiving Listed Searching From 648bd8db393dfb7148ca92d2b8ab5102ab9b1231 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 14:30:12 +0000 Subject: [PATCH 30/31] fix: tighten which rewrites count as data loss in event diffs - NIP-65: a relay that stops being read or write counts as a loss, and split read/write tags for one URL are merged into both before diffing. - Nutzap info: a mint that drops a unit is a loss; mints match with a normalized URL and units compare as a set. - Group lists match relays by normalized URL; geohashes ignore case. - Profiles: dropping the deprecated NIP-24 displayName/username aliases is not a loss. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- .../quartz/nip01Core/metadata/MetadataDiff.kt | 9 ++- .../geohashList/GeohashListEvent.kt | 2 +- .../simpleGroupList/SimpleGroupListEvent.kt | 3 +- .../nip61Nutzaps/info/NutzapInfoDiff.kt | 7 +- .../nip61Nutzaps/info/NutzapInfoEvent.kt | 2 +- .../nip65RelayList/AdvertisedRelayListDiff.kt | 6 +- .../AdvertisedRelayListEvent.kt | 15 ++++- .../quartz/nip01Core/diff/EventDiffTest.kt | 66 +++++++++++++++++++ 8 files changed, 103 insertions(+), 7 deletions(-) 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 index 429b3f48bf..e4660df8ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt @@ -51,10 +51,17 @@ class MetadataDiff( ) : EventDiff { private fun fieldChanges(): List> = listOfNotNull(name, displayName, picture, banner, website, about, pronouns, nip05, lud06, lud16, clinkOffer, bot, birthday) - override fun removesData() = fieldChanges().any { it.isRemoval() } || otherFields.hasRemovals() || identityClaims.hasRemovals() + // 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/nip51Lists/geohashList/GeohashListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt index ed1d1aa565..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 @@ -53,7 +53,7 @@ class GeohashListEvent( 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 }), + ListDiff.of(older.publicGeohashes(), publicGeohashes(), { it.lowercase() }), privateItemsChangeFrom(older), ) } 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 7c2c059f54..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 @@ -27,6 +27,7 @@ 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 @@ -47,7 +48,7 @@ class SimpleGroupListEvent( 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 + "@" + it.relayUrl }, { a, b -> a.name == b.name }), + ListDiff.of(older.publicGroups(), publicGroups(), { it.groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.url ?: it.relayUrl) }, { a, b -> a.name == b.name }), privateItemsChangeFrom(older), ) } 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 index c07d7669c8..ad8ab36916 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt @@ -35,5 +35,10 @@ class NutzapInfoDiff( val relays: ListDiff, val p2pkPubkey: ValueChange?, ) : EventDiff { - override fun removesData() = mints.hasRemovals() || relays.hasRemovals() || p2pkPubkey?.isRemoval() == true + // 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 4d0437e5d7..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 @@ -47,7 +47,7 @@ class NutzapInfoEvent( override fun diffFrom(older: Event): NutzapInfoDiff? { if (older !is NutzapInfoEvent || older.pubKey != pubKey) return null return NutzapInfoDiff( - ListDiff.of(older.mints(), mints(), { it.mintUrl }, { a, b -> a.units == b.units }), + 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()), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt index e427df46ee..3af1f02cb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt @@ -30,5 +30,9 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo class AdvertisedRelayListDiff( val relays: ListDiff, ) : EventDiff { - override fun removesData() = relays.hasRemovals() + // 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 89dab81d3e..e9689b3fc0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -27,10 +27,12 @@ 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 @@ -45,7 +47,18 @@ class AdvertisedRelayListEvent( DiffableEvent { override fun diffFrom(older: Event): AdvertisedRelayListDiff? { if (older !is AdvertisedRelayListEvent || older.pubKey != pubKey) return null - return AdvertisedRelayListDiff(ListDiff.of(older.relays(), relays(), { it.relayUrl }, { a, b -> a.type == b.type })) + 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) 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 index 17f08eec23..6f2e981e8f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -26,6 +26,7 @@ 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 @@ -294,4 +295,69 @@ class EventDiffTest { 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()) + } } From 61a1347c66f869db98d41a90c45fd28d6133c445 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 14:30:14 +0000 Subject: [PATCH 31/31] fix: address the final audit of backup conflicts Guard and restore: - Never roll a backup back to an older version, and don't reopen a failed restore's conflict when the backup moved on meanwhile (a Keep on it rolled the backup back to the stale version). - Refuse to restore over a version dated beyond the 15-minute cap instead of sending one that loses everywhere and closing the card. - Keep on a stored NIP-78 conflict applies right away; whitespace-only NIP-78 content counts as emptied. - Keep runs off the main thread; read-only accounts get no Restore. - Open conflicts reuse their last encoding on settings saves. Review screens: - Deduplicate saved mints and groups (a repeated row key crashed). - Trust providers compare the set of providers per service. - Switching follow tabs clears the search that only long tabs show. - Follow counts and the presentation are computed once per conflict. - Cards that appear while Home sits at the top scroll into view. - Payment target and offer counts use plurals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g --- ...2026-09-22-replaceable-backup-conflicts.md | 7 +++ .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../amethyst/model/AccountSettings.kt | 14 ++--- .../model/backups/BackupConflictStorage.kt | 13 ++++- .../nip78AppSpecific/AppSpecificState.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 7 +++ .../loggedIn/backups/BackupConflictCards.kt | 4 +- .../backups/BackupConflictEventViews.kt | 33 +++++++---- .../backups/BackupConflictListViews.kt | 35 +++++++++--- .../backups/BackupConflictPresentation.kt | 55 +++++++++++-------- .../backups/BackupConflictReviewScreen.kt | 31 ++++++----- .../ui/screen/loggedIn/home/HomeScreen.kt | 17 +++++- amethyst/src/main/res/values/strings.xml | 10 +++- .../model/backups/BackupConflictGuard.kt | 20 ++++++- .../backups/ReplaceableBackupConflict.kt | 9 +++ .../model/backups/BackupConflictGuardTest.kt | 25 +++++++++ 16 files changed, 208 insertions(+), 80 deletions(-) diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md index ef3e890881..b7cc7e9823 100644 --- a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -145,3 +145,10 @@ lands on the next delivery. - 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/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4bc2869f2a..6f0918dea1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1908,8 +1908,10 @@ class Account( 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; the restore then goes out at "now". - val createdAt = (conflict.incoming.createdAt + 1).takeIf { it <= now + maxRestoreFutureSeconds }?.coerceAtLeast(now) ?: now + // 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 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 cac2a69496..1fea151adf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1215,11 +1215,11 @@ class AccountSettings( is ConcordCommunityListEvent -> updateConcordListTo(incoming) is TrustProviderListEvent -> updateTrustProviderListTo(incoming) is MuteListEvent -> updateMuteList(incoming) - // NIP-78 is deliberately absent: its update also takes the decrypted - // AccountSyncedSettingsInternal, which only the caller can produce. Keeping that - // version still works — [BackupConflictGuard.keepIncoming] records the id as accepted - // first, so the next delivery walks into the backup - // unchallenged — it just lands when the event next arrives rather than right now. + // 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 } } @@ -1558,10 +1558,10 @@ class AccountSettings( ) { if (appSettings == null) return - if (backupGuard.accept(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isEmpty()) { updateAppSpecificData(appSettings, newSyncedSettings) }) { + 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.isEmpty()) { + if (appSettings.content.isBlank()) { saveAccountSettings() return } 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 index 065c154a9b..b6ba88290e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt @@ -39,7 +39,18 @@ import com.vitorpamplona.quartz.utils.Log * Kept out of `LocalPreferences` so it is reachable from a plain JVM test. */ object BackupConflictStorage { - fun encode(conflicts: List>): String = + // 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( 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 7df428358e..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 @@ -122,7 +122,7 @@ class AppSpecificState( // 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.isEmpty()) { + if (event.content.isBlank()) { settings.updateAppSpecificData(event, AccountSyncedSettingsInternal()) return@let } 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 87097743bb..c416558bcc 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 @@ -48,6 +48,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 @@ -1625,6 +1626,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 index 112bb4c143..c3a87792ac 100644 --- 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 @@ -66,7 +66,6 @@ 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.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff @@ -273,8 +272,7 @@ private fun LeadConflictCard( } val diff = conflict.diff if (diff is ContactListDiff) { - val saved = (conflict.saved as? ContactListEvent)?.uniqueFollowCount() ?: 0 - val new = (conflict.incoming as? ContactListEvent)?.uniqueFollowCount() ?: 0 + val (saved, new) = conflict.followCounts Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Text(saved.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold) SplitBar( 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 index c0d186b28d..39b5408657 100644 --- 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 @@ -94,7 +94,6 @@ 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.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag @@ -106,6 +105,7 @@ 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 @@ -158,8 +158,7 @@ private fun LazyListScope.followListItems( accountViewModel: AccountViewModel, nav: INav, ) { - val saved = (conflict.saved as? ContactListEvent)?.uniqueFollowCount() ?: 0 - val new = (conflict.incoming as? ContactListEvent)?.uniqueFollowCount() ?: 0 + val (saved, new) = conflict.followCounts val dropped = diff.follows.removed.size val gained = diff.follows.added.size val edited = diff.follows.changed.size @@ -192,7 +191,12 @@ private fun LazyListScope.followListItems( stringRes(R.string.backup_review_tab_edited, edited.toString()), ), selected = ui.tab, - onSelect = { ui.tab = it }, + 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), ) } @@ -951,17 +955,20 @@ private fun LazyListScope.nutzapItems( } } - val saved = (conflict.saved as? NutzapInfoEvent)?.mints().orEmpty() + // 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 { it.mintUrl } + .map(mintKey) .toSet() - val changedUrls = diff.mints.changed.associate { it.after.mintUrl to it } + val changedUrls = diff.mints.changed.associateBy { mintKey(it.before) } val mintRows = saved.map { m -> - when (m.mintUrl) { + when (mintKey(m)) { in removedUrls -> Triple(m.mintUrl, m.units.joinToString(), LaneState.DROPPED) - in changedUrls -> Triple(m.mintUrl, changedUrls.getValue(m.mintUrl).let { it.before.units.joinToString() + " → " + it.after.units.joinToString() }, LaneState.DEMOTED) + 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) } @@ -1050,13 +1057,15 @@ private fun LazyListScope.groupItems( accountViewModel: AccountViewModel, nav: INav, ) { - val key = { g: GroupTag -> g.groupId + "@" + g.relayUrl } + // 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.after) } - val saved = (conflict.saved as? SimpleGroupListEvent)?.publicGroups().orEmpty() + 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)) { 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 index 6842232edc..557d39df95 100644 --- 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 @@ -48,6 +48,7 @@ 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 @@ -673,10 +674,26 @@ private fun LazyListScope.trustProviderItems( accountViewModel: AccountViewModel, nav: INav, ) { - val before = (conflict.saved as? TrustProviderListEvent)?.serviceProviders().orEmpty().associate { it.service.type to it.pubkey } - val after = (conflict.incoming as? TrustProviderListEvent)?.serviceProviders().orEmpty().associate { it.service.type to it.pubkey } - val rows = (before.keys + after.keys).distinct().map { ServiceRow(it, before[it], after[it]) } - val affected = rows.count { it.before != it.after } + // 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() @@ -686,7 +703,7 @@ private fun LazyListScope.trustProviderItems( Icon(symbol = MaterialSymbols.Group, contentDescription = null, tint = tones.changed) } Column { - Text(stringRes(R.string.backup_review_services_changed, affected.toString(), rows.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + 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) } } @@ -699,7 +716,7 @@ private fun LazyListScope.trustProviderItems( } } } - items(rows, key = { "service-" + it.service }, contentType = { "service-row" }) { row -> ServiceTableRow(row, accountViewModel, nav) } + 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( @@ -806,7 +823,7 @@ private fun LazyListScope.paymentTargetItems( ) { val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty() val targets = fates(saved, diff.targets) { it } - moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.string.backup_review_payment_targets_gone) + 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) } } @@ -816,7 +833,7 @@ private fun LazyListScope.offerItems( ) { val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty() val offers = fates(saved, diff.offers) { it } - moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.string.backup_review_offers_gone) + 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) } @@ -835,7 +852,7 @@ private fun LazyListScope.moneyHero( Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = color) } Column { - Text(stringRes(headline, dropped.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color) + 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) } } 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 index 5c4688c293..6c7772ba36 100644 --- 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 @@ -22,9 +22,10 @@ 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.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff @@ -40,7 +41,6 @@ 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.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff @@ -64,9 +64,6 @@ import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff private const val MAX_VALUE_LENGTH = 80 -/** Follows counted the way [ContactListDiff] matches them: valid keys, one per pubkey. */ -internal fun ContactListEvent.uniqueFollowCount() = follows().mapTo(HashSet()) { it.pubKey }.size - /** Items lost, gained and changed in a conflict, counting encrypted content as one item. */ @Immutable class ConflictCounts( @@ -239,9 +236,21 @@ private fun arrow( after: String?, ) = (before ?: "∅") + " → " + (after ?: "∅") -/** Turns each event's own diff into typed, labelled groups. */ +/** + * 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 presentationOf(diff: EventDiff): DiffPresentation { +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 -> { @@ -251,19 +260,19 @@ fun presentationOf(diff: EventDiff): DiffPresentation { fieldGroup( R.string.backup_entry_profile_field, listOf( - stringRes(R.string.backup_profile_field_name) to diff.name, - stringRes(R.string.backup_profile_field_display_name) to diff.displayName, - stringRes(R.string.backup_profile_field_about) to diff.about, - stringRes(R.string.backup_profile_field_picture) to diff.picture, - stringRes(R.string.backup_profile_field_banner) to diff.banner, - stringRes(R.string.backup_profile_field_website) to diff.website, - stringRes(R.string.backup_profile_field_nip05) to diff.nip05, - stringRes(R.string.backup_profile_field_lud16) to diff.lud16, - stringRes(R.string.backup_profile_field_lud06) to diff.lud06, - stringRes(R.string.backup_profile_field_clink_offer) to diff.clinkOffer, - stringRes(R.string.backup_profile_field_pronouns) to diff.pronouns, - stringRes(R.string.backup_profile_field_birthday) to birthday, - stringRes(R.string.backup_profile_field_bot) to bot, + 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( @@ -307,9 +316,9 @@ fun presentationOf(diff: EventDiff): DiffPresentation { is AdvertisedRelayListDiff -> { val types = mapOf( - AdvertisedRelayType.BOTH to stringRes(R.string.backup_conflict_relay_read_write), - AdvertisedRelayType.READ to stringRes(R.string.backup_conflict_relay_read_only), - AdvertisedRelayType.WRITE to stringRes(R.string.backup_conflict_relay_write_only), + 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( 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 index 1ad76ab8af..e29ba64147 100644 --- 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 @@ -86,7 +86,6 @@ import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDif import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff @@ -192,8 +191,9 @@ fun BackupConflictReviewScreen( conflict = conflict, accountViewModel = accountViewModel, nav = nav, - onRestore = { accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }, - onKeepNew = { accountViewModel.account.acceptExternalVersion(conflict) }, + // 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) }, ) } @@ -202,10 +202,10 @@ private fun BackupConflictReview( conflict: ReplaceableBackupConflict, accountViewModel: AccountViewModel, nav: INav, - onRestore: () -> Unit, + onRestore: (() -> Unit)?, onKeepNew: () -> Unit, ) { - val presentation = presentationOf(conflict.diff) + val presentation = rememberPresentation(conflict.diff) val ui = remember(conflict.slot) { ReviewUiState() } val (keepLabel, restoreLabel) = actionLabels(conflict) @@ -297,7 +297,7 @@ internal fun SectionRow(row: ReviewRow.Section) { private fun ReviewActions( keepLabel: String, restoreLabel: String, - onRestore: () -> Unit, + onRestore: (() -> Unit)?, onKeepNew: () -> Unit, ) { Surface(tonalElevation = 3.dp) { @@ -316,13 +316,15 @@ private fun ReviewActions( ) { // 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. - 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) + 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) @@ -345,8 +347,7 @@ internal fun eventTitle(conflict: ReplaceableBackupConflict): String { private fun actionLabels(conflict: ReplaceableBackupConflict): Pair = when (val diff = conflict.diff) { is ContactListDiff -> { - val saved = (conflict.saved as? ContactListEvent)?.uniqueFollowCount() ?: 0 - val new = (conflict.incoming as? ContactListEvent)?.uniqueFollowCount() ?: 0 + 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 -> { 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 8a74430215..5bef4c030f 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 @@ -49,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 @@ -438,15 +439,25 @@ fun FeedLoaded( 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 (backupConflicts.isNotEmpty()) { + if (hasConflicts) { item(key = "backupConflicts", contentType = "backupConflicts") { - BackupConflictCards(backupConflicts, nav, onAccept = accountViewModel.account::acceptExternalVersion) + BackupConflictCards(backupConflicts, nav, onAccept = accountViewModel::acceptExternalBackupVersion) } } if (liveSection != null) { @@ -552,7 +563,7 @@ private fun WithBackupConflicts( BackupConflictCards( backupConflicts, nav, - onAccept = accountViewModel.account::acceptExternalVersion, + onAccept = accountViewModel::acceptExternalBackupVersion, Modifier .weight(1f, fill = false) .verticalScroll(rememberScrollState()) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2d5cd1d607..311e689755 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2927,8 +2927,14 @@ Followers Topics Ranks, follower counts and spam filtering you see come from these providers. - %1$s ways to pay you removed - %1$s offers removed + + %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 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 index 088dc95275..ca4caa338f 100644 --- 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 @@ -72,6 +72,11 @@ class BackupConflictGuard( // 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 @@ -91,6 +96,9 @@ class BackupConflictGuard( 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] @@ -130,7 +138,12 @@ class BackupConflictGuard( 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. - !isEmpty || isLocal || wasAccepted + 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. */ @@ -203,6 +216,7 @@ class BackupConflictGuard( lock.withLock { val retry = claimLocked(conflict) ?: return@withLock null resolving.add(conflict.incoming.id) + restoringSlots.add(conflict.slot) retry } @@ -217,7 +231,9 @@ class BackupConflictGuard( ) { lock.withLock { resolving.remove(conflict.incoming.id) - if (restored || _conflicts.value.containsKey(conflict.slot)) return@withLock + 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/ReplaceableBackupConflict.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt index ed98d029c6..dd348ce0da 100644 --- 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 @@ -143,6 +143,15 @@ class ReplaceableBackupConflict( 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()) 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 index b9f9e40021..ef034b2f0f 100644 --- 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 @@ -399,4 +399,29 @@ class BackupConflictGuardTest { 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) + } }