mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #4174 from vitorpamplona/claude/loving-wright-aqlrvd
Add backup conflict detection and resolution UI
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
# Replaceable-event backup conflicts
|
||||
|
||||
## Problem
|
||||
|
||||
`AccountSettings` keeps an on-disk backup of ~26 of the account's replaceable events
|
||||
(profile, follow list, mute list, relay lists, NIP-51 lists, wallet events, app settings).
|
||||
Each `*State` collector copies whatever is newest in `LocalCache` into that backup. When
|
||||
another client rewrites one of these events without reading the previous version (a fresh
|
||||
follow list, a mute list without the private items, a profile missing fields), the newer
|
||||
event wins on relays, lands in `LocalCache`, and the backup is overwritten with it: the
|
||||
only surviving copy of the user's data is gone.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Know what we signed.** `LocallySignedEvents` (commons, `model/backups/`) records the
|
||||
ids of replaceable/addressable events passing through `justConsumeMyOwnEvent`, the
|
||||
choke point for every local publish and for the backup restore at startup. It is a
|
||||
bounded LRU (500 ids): only the latest versions of each list matter.
|
||||
2. **Events diff themselves.** Every backed-up event implements
|
||||
`DiffableEvent<D>` (quartz, `nip01Core/diff/`): `diffFrom(older)` returns its own diff
|
||||
class built from its own parsed objects, e.g. `ContactListDiff(follows:
|
||||
ListDiff<ContactTag>)`, `MuteListDiff(publicMutes: ListDiff<MuteTag>, privateItems)`,
|
||||
`AdvertisedRelayListDiff(relays: ListDiff<AdvertisedRelayInfo>)`, `NutzapInfoDiff(mints:
|
||||
ListDiff<NutzapMintTag>, relays, p2pkPubkey: ValueChange<HexKey>)`, `MetadataDiff` with
|
||||
one `ValueChange` per `UserMetadata` field plus NIP-39 claims and unmodeled JSON fields.
|
||||
The relay-tag lists (DM, key package, search, indexer, feeds, blocked, trusted, private
|
||||
outbox) share `RelayListDiff`; encrypted-only events (Cashu wallet, Concord list, NIP-78
|
||||
data) diff their content as a whole.
|
||||
|
||||
Tags an event's parser doesn't read never reach its diff, so bookkeeping other clients
|
||||
rewrite on every save (`client`, NIP-13 `nonce`, NIP-40 `expiration`) is ignored by
|
||||
construction; `EventDiffTest.clientPowAndExpirationTagsAreIgnored` pins it.
|
||||
|
||||
`nip01Core/diff/` only holds the containers: `EventDiff` (`removesData()`),
|
||||
`ListDiff<T>` (items matched by an identity key; a matched pair with different details
|
||||
is a change, not a removal plus an addition), `ValueChange<T>`, and `ContentChange` for
|
||||
NIP-44 private items, which can't be compared item by item without decrypting.
|
||||
3. **Only question lossy external rewrites.** `BackupConflictGuard` (commons,
|
||||
`model/backups/`, tested in `BackupConflictGuardTest`) guards every `update*` backup
|
||||
method of `AccountSettings`, which owns one as `backupGuard`. A newer version that was not signed here goes through
|
||||
`ReplaceableBackupDiff.detectLoss` (commons), which asks the event for its diff and
|
||||
keeps it only when `removesData()`.
|
||||
|
||||
If nothing was dropped, the other app evidently built on the previous version, and the
|
||||
backup is updated silently as before.
|
||||
|
||||
Wiped versions (no tags, empty app data, a mints-less nutzap info) reach the guard too,
|
||||
so wiping everything is questioned like any other loss. An empty version only becomes
|
||||
the backup when this app signed it or the user kept it. The guard runs under a lock
|
||||
shared with the resolutions, and a version older than an open conflict's incoming can
|
||||
never replace or clear it (e.g. the retry of an accept that a newer change overtook).
|
||||
4. **Freeze and ask.** On a loss, the backup keeps the saved version and a
|
||||
`ReplaceableBackupConflict` is published on `BackupConflictGuard.conflicts`.
|
||||
Home shows a card per open conflict as the first item of each tab's feed
|
||||
(`BackupConflictCards`), so they scroll away with the posts instead of covering them;
|
||||
an empty, loading or failed feed shows them above its placeholder: "You changed your mute list in another app" with a
|
||||
"12 removed · 3 added" summary. Cards can't be dismissed; they stay until the user
|
||||
decides. The most recent conflict gets a lead card with an event-specific headline
|
||||
("You unblocked 3 relays", "You unfollowed 12 people" with its bar); the others collapse
|
||||
into slim pills. List headlines lead with what was removed and add
|
||||
what was added to the same list, worded per list ("You unfollowed 12 people and
|
||||
followed 40", "You left 2 groups and joined 1", "You unblocked 3 relays and blocked 1").
|
||||
All copy assumes the change was on purpose: the user did it in another client, and
|
||||
Amethyst is only confirming before its backup moves on. So it says what "you" did, in
|
||||
the past tense ("You left 1 public chat", not "You'd leave"), and never calls it a loss.
|
||||
Tapping one opens `Route.BackupConflictReview(slot)`
|
||||
(`BackupConflictReviewScreen`): a `LazyColumn` of every entry, so lists with hundreds of
|
||||
items scroll lazily. Entries are typed (`ReviewItem`, built per diff class in
|
||||
`BackupConflictPresentation.kt`) and rendered with the app's loaders, which subscribe to
|
||||
relays and recompose when data arrives: people (`LoadUser` + `UsernameDisplay`, tap →
|
||||
profile), muted threads (`LoadNote` + `NoteCompose`), communities and feeds
|
||||
(`LoadAddressableNote` + `NoteCompose`), public chats (`observeChannel`, tap → chat),
|
||||
ephemeral rooms (tap → room) and relays (tap → relay info). Leaving the screen without
|
||||
deciding keeps the card and the frozen backup.
|
||||
|
||||
The list is laid out per event (`BackupConflictEventViews.kt`, `eventDiffItems`), each
|
||||
leading with one picture of the change (`BackupConflictVisuals.kt`; red lost, green
|
||||
gained, amber changed, all from the theme): the follow list shows saved → new counts, a
|
||||
kept/dropped/new split bar, Dropped/New/Edited tabs and a searchable grid of faces; the
|
||||
mute list a shield with the unmuted count, per-kind tiles, a face pile, struck-through
|
||||
words and the private-items card; the profile both versions side by side as mini
|
||||
cards, then inline field diffs with images as thumbnails; NIP-65 outbox and inbox
|
||||
lanes; nutzap info a key swap
|
||||
drawn as color fingerprints with a warning, then mints; groups a tile per group, faded
|
||||
when you left it. The other lists (`BackupConflictListViews.kt`) get their own too:
|
||||
each relay list as per-relay rows tagged by what the list is for (DM, key-package,
|
||||
search, indexer, relay-feed, private-outbox and trusted relays; no "N of M" summary card,
|
||||
since the change is the tagged rows, not a reach number); blocked relays
|
||||
as an "N unblocked" warning with allowed / newly blocked / still blocked), public
|
||||
chats, communities, favorite feeds and ephemeral rooms as tiles loaded from relays and
|
||||
faded when you left them, hashtags and places as a pill cloud (places as "City · #geohash" through `LoadCityName`, each pill opening its feed), trust providers as a
|
||||
per-service before/after table, payment targets and BOLT12 offers as cards, and the
|
||||
encrypted-only Cashu wallet and Concord list as one explained panel. Only NIP-78 app
|
||||
settings still uses the generic removed / added / changed sections.
|
||||
Each card has an Accept button that keeps the new version right from Home (the change
|
||||
was made on purpose; reverting needs the review screen, a tap on the card away). The
|
||||
review screen is titled with the event and what happened to it ("Community list
|
||||
updated", "Mute list cleared").
|
||||
Buttons are worded per event ("Keep 120" / "Restore 523", "Re-mute 37", "Rejoin 3").
|
||||
Keeping the new version is the default (the filled button, on the right); restoring
|
||||
always reverts another app's change, so it is an outlined red button on every screen.
|
||||
The screen offers:
|
||||
- **Restore saved version** — `Account.restoreBackupOver` re-signs the saved kind, tags
|
||||
and content (NIP-44 self-encrypted items stay valid) with
|
||||
`created_at = max(now, incoming + 1)` (capped at 15 minutes ahead; a far-future
|
||||
external version is answered at "now"), dropping the old `client` tag (so the signer
|
||||
adds the current one), the `nonce` (its PoW was for the old id) and an already-past
|
||||
`expiration` (`BackupRestore.tagsToResign`), and publishes it through
|
||||
`EventBroadcaster.sendRestoredVersion`: where a normal save of that kind goes, and for
|
||||
the user's own relay lists (NIP-65, DM, key package) and nutzap info also to every
|
||||
relay the restored list names, since the lossy version may have dropped them from the
|
||||
outbox set; profiles, like their normal saves, go everywhere.
|
||||
- **Use new version** — `Account.acceptExternalVersion` whitelists that id and re-runs
|
||||
the original update, so the backup moves forward.
|
||||
|
||||
Both resolutions first *claim* the conflict: they act only if it is still the open
|
||||
conflict of its slot, so a stale screen, a double tap, or a conflict dropped because the
|
||||
wallet/nutzap info was deleted does nothing. While a restore is signing, its incoming
|
||||
version can't re-raise the conflict; a failed restore reopens it. The same incoming
|
||||
version re-emitted by the cache is ignored without re-diffing.
|
||||
|
||||
Public items removed while the private section went from empty to filled are treated as
|
||||
made private, not lost (`ContentChange.publicRemovalsAreLoss`).
|
||||
|
||||
While a conflict is open, edits made in Amethyst are compared against the frozen
|
||||
saved version too (they are built on top of the external one), so they cannot
|
||||
silently clear the conflict. They move the conflict's `incoming` forward but keep the
|
||||
external version as its `cause` (the screen shows that date).
|
||||
|
||||
Open conflicts are persisted with the backups (`BackupConflictStorage`, as the saved,
|
||||
incoming and cause events) and re-seeded on load through `BackupConflictGuard.restore`. The
|
||||
diff is recomputed, so a version the other app has since fixed stops being a conflict. A
|
||||
restored conflict has no retry closure until its version arrives again; keeping it rebuilds
|
||||
the update from the event's type (`AccountSettings.reapplyBackup`), except NIP-78, which
|
||||
lands on the next delivery.
|
||||
|
||||
## Known limits / follow-ups
|
||||
|
||||
- Private (encrypted) list items are only compared as "all gone"; a rewrite that drops
|
||||
*some* private items isn't detected. Decrypting both sides would fix it but needs the
|
||||
signer (an Amber round-trip), so it's left out of the passive path.
|
||||
- The ids of locally signed events are in memory only. If the app dies inside the 1s
|
||||
settings-save debounce right after a local removal, the next launch may ask once about
|
||||
our own event. Harmless (either answer keeps the data the user chose).
|
||||
- Edits from the same user on another Amethyst install count as "external" when they
|
||||
remove items. That's intended: the device can't tell them apart from a careless app.
|
||||
- Desktop marks its own events too but has no backup store or conflict UI yet.
|
||||
- Moving public items into a private section that already existed is still questioned: the
|
||||
private section re-encrypts on every save, so it only reads as "changed", and telling
|
||||
"made private" from "deleted" would need decrypting it.
|
||||
- A version dated more than 15 minutes in the future can't be outranked safely, so Restore
|
||||
refuses it (the conflict reopens) and only keeping it works.
|
||||
- Read-only (npub) accounts can't re-sign the saved version, so their review offers only
|
||||
keeping the new one.
|
||||
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
@@ -159,6 +160,7 @@ private object PrefKeys {
|
||||
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
|
||||
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
|
||||
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
|
||||
const val OPEN_BACKUP_CONFLICTS = "openBackupConflicts"
|
||||
const val LATEST_USER_METADATA = "latestUserMetadata"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
|
||||
@@ -582,6 +584,16 @@ object LocalPreferences {
|
||||
|
||||
putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
|
||||
|
||||
// The undecided conflicts themselves, not just the backups they hold back.
|
||||
// Without these the card vanishes on the next launch and the user never
|
||||
// answers the question the backup is still waiting on.
|
||||
val openConflicts = settings.openBackupConflicts()
|
||||
if (openConflicts.isEmpty()) {
|
||||
remove(PrefKeys.OPEN_BACKUP_CONFLICTS)
|
||||
} else {
|
||||
putString(PrefKeys.OPEN_BACKUP_CONFLICTS, BackupConflictStorage.encode(openConflicts))
|
||||
}
|
||||
|
||||
putOrRemove(PrefKeys.LATEST_USER_METADATA, settings.backupUserMetadata)
|
||||
putOrRemove(PrefKeys.LATEST_DM_RELAY_LIST, settings.backupDMRelayList)
|
||||
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
|
||||
@@ -808,6 +820,7 @@ object LocalPreferences {
|
||||
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
|
||||
|
||||
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
|
||||
val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null)
|
||||
val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null)
|
||||
val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null)
|
||||
val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null)
|
||||
@@ -1064,7 +1077,7 @@ object LocalPreferences {
|
||||
backupCashuWallet = latestCashuWalletResolved,
|
||||
backupNutzapInfo = latestNutzapInfoResolved,
|
||||
callsEnabled = MutableStateFlow(callsEnabled),
|
||||
)
|
||||
).also { it.restoreBackupConflicts(BackupConflictStorage.decode(openBackupConflictsStr)) }
|
||||
}
|
||||
}
|
||||
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
|
||||
|
||||
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupRestore
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
@@ -1889,6 +1891,40 @@ class Account(
|
||||
|
||||
fun sendLiterallyEverywhere(event: Event) = broadcaster.sendLiterallyEverywhere(event)
|
||||
|
||||
/**
|
||||
* Resolves a [ReplaceableBackupConflict] in favor of this device: re-signs the saved
|
||||
* version (same kind, tags and content, so NIP-44 private items stay readable, minus the
|
||||
* old client, proof-of-work and past expiration tags; see [BackupRestore.tagsToResign])
|
||||
* with a timestamp newer than the current one, and publishes it so it replaces that version.
|
||||
*
|
||||
* Does nothing if the conflict is stale (already resolved, replaced by a newer one, or
|
||||
* dropped because the event was deleted), which also makes a double tap harmless.
|
||||
*/
|
||||
suspend fun restoreBackupOver(conflict: ReplaceableBackupConflict) {
|
||||
val token = settings.backupGuard.startRestoring(conflict) ?: return
|
||||
var restored = false
|
||||
try {
|
||||
val saved = conflict.saved
|
||||
val now = TimeUtils.now()
|
||||
// Newer than the version it replaces, but never far in the future: relays reject
|
||||
// those, and later edits signed at "now" would lose to it. A far-future external
|
||||
// version can't be outranked safely, and a restore signed at "now" would lose to it
|
||||
// everywhere, so it isn't sent: the conflict reopens and only keeping it works.
|
||||
val createdAt = (conflict.incoming.createdAt + 1).coerceAtLeast(now)
|
||||
if (createdAt > now + maxRestoreFutureSeconds) return
|
||||
val resigned = signer.sign<Event>(createdAt, saved.kind, BackupRestore.tagsToResign(saved, now), saved.content)
|
||||
broadcaster.sendRestoredVersion(resigned)
|
||||
restored = true
|
||||
} finally {
|
||||
settings.backupGuard.finishRestoring(conflict, token, restored)
|
||||
}
|
||||
}
|
||||
|
||||
private val maxRestoreFutureSeconds = 15 * 60L
|
||||
|
||||
/** Resolves a [ReplaceableBackupConflict] in favor of the current version. No-op when stale. */
|
||||
fun acceptExternalVersion(conflict: ReplaceableBackupConflict) = settings.backupGuard.keepIncoming(conflict)
|
||||
|
||||
suspend fun <T : Event> signAndSendPrivately(
|
||||
template: EventTemplate<T>,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
|
||||
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
|
||||
import com.vitorpamplona.amethyst.commons.cashu.UnavailableCashuKeysetCounterStore
|
||||
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupConflictGuard
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.backupSlotOf
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
|
||||
@@ -51,6 +53,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEve
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -1041,6 +1044,69 @@ class AccountSettings(
|
||||
// Backup Lists
|
||||
// ----
|
||||
|
||||
/**
|
||||
* Holds back external rewrites that dropped data from a backup, see [BackupConflictGuard].
|
||||
* Home shows a card for each open conflict until the user keeps the new version or
|
||||
* restores the saved one. Open conflicts are saved with the backups they hold back.
|
||||
*/
|
||||
val backupGuard =
|
||||
BackupConflictGuard(
|
||||
onChange = { saveAccountSettings() },
|
||||
reapply = { reapplyBackup(it) },
|
||||
)
|
||||
|
||||
val backupConflicts get() = backupGuard.conflicts
|
||||
|
||||
/** Re-seeds the open conflicts read back from storage, see [BackupConflictGuard.restore]. */
|
||||
fun restoreBackupConflicts(saved: List<Triple<Event, Event, Event>>) = backupGuard.restore(saved)
|
||||
|
||||
/** The open conflicts, as the three events storage needs to rebuild each one. */
|
||||
fun openBackupConflicts(): List<Triple<Event, Event, Event>> = backupGuard.open()
|
||||
|
||||
/**
|
||||
* Re-runs the update that a restored conflict no longer carries.
|
||||
*
|
||||
* The guard's retries are closures, so they cannot be written to disk; a conflict read
|
||||
* back after a restart has none until its version happens to arrive again. Keeping the new
|
||||
* version must work regardless, and every update is the same shape — hand the event to the
|
||||
* function that owns its slot — so the slot can be recovered from the event's own type.
|
||||
*/
|
||||
private fun reapplyBackup(incoming: Event) {
|
||||
when (incoming) {
|
||||
is MetadataEvent -> updateUserMetadata(incoming)
|
||||
is ContactListEvent -> updateContactListTo(incoming)
|
||||
is ChatMessageRelayListEvent -> updateDMRelayList(incoming)
|
||||
is KeyPackageRelayListEvent -> updateKeyPackageRelayList(incoming)
|
||||
is AdvertisedRelayListEvent -> updateNIP65RelayList(incoming)
|
||||
is CashuWalletEvent -> updateCashuWallet(incoming)
|
||||
is NutzapInfoEvent -> updateNutzapInfo(incoming)
|
||||
is PaymentTargetsEvent -> updateNIPA3PaymentTargets(incoming)
|
||||
is Bolt12OfferListEvent -> updateBolt12Offers(incoming)
|
||||
is SearchRelayListEvent -> updateSearchRelayList(incoming)
|
||||
is IndexerRelayListEvent -> updateIndexRelayList(incoming)
|
||||
is RelayFeedsListEvent -> updateRelayFeedList(incoming)
|
||||
is BlockedRelayListEvent -> updateBlockedRelayList(incoming)
|
||||
is TrustedRelayListEvent -> updateTrustedRelayList(incoming)
|
||||
is PrivateOutboxRelayListEvent -> updatePrivateHomeRelayList(incoming)
|
||||
is ChannelListEvent -> updateChannelListTo(incoming)
|
||||
is GeohashListEvent -> updateGeohashListTo(incoming)
|
||||
is HashtagListEvent -> updateHashtagListTo(incoming)
|
||||
is FavoriteAlgoFeedsListEvent -> updateFavoriteAlgoFeedsListTo(incoming)
|
||||
is CommunityListEvent -> updateCommunityListTo(incoming)
|
||||
is EphemeralChatListEvent -> updateEphemeralChatListTo(incoming)
|
||||
is SimpleGroupListEvent -> updateRelayGroupListTo(incoming)
|
||||
is ConcordCommunityListEvent -> updateConcordListTo(incoming)
|
||||
is TrustProviderListEvent -> updateTrustProviderListTo(incoming)
|
||||
is MuteListEvent -> updateMuteList(incoming)
|
||||
// NIP-78 only raises a conflict when its blob was emptied, and an empty blob is
|
||||
// stored without merging the settings handed with it, so defaults are safe here.
|
||||
// A non-empty one needs the decrypted settings only the caller has; it lands when
|
||||
// the event next arrives, already recorded as accepted.
|
||||
is AppSpecificDataEvent -> if (incoming.content.isBlank()) updateAppSpecificData(incoming, AccountSyncedSettingsInternal())
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLocalRelayServers(servers: Set<String>) {
|
||||
if (localRelayServers.value != servers) {
|
||||
localRelayServers.update { servers }
|
||||
@@ -1059,48 +1125,43 @@ class AccountSettings(
|
||||
fun updateUserMetadata(newMetadata: MetadataEvent?) {
|
||||
if (newMetadata == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupUserMetadata?.id != newMetadata.id) {
|
||||
if (backupGuard.accept(backupUserMetadata, newMetadata) { updateUserMetadata(newMetadata) }) {
|
||||
backupUserMetadata = newMetadata
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList == null || newContactList.tags.isEmpty()) return
|
||||
if (newContactList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList.id) {
|
||||
if (backupGuard.accept(backupContactList, newContactList, isEmpty = newContactList.tags.isEmpty()) { updateContactListTo(newContactList) }) {
|
||||
backupContactList = newContactList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
|
||||
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
|
||||
if (newDMRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupDMRelayList?.id != newDMRelayList.id) {
|
||||
if (backupGuard.accept(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) {
|
||||
backupDMRelayList = newDMRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateKeyPackageRelayList(newKeyPackageRelayList: KeyPackageRelayListEvent?) {
|
||||
if (newKeyPackageRelayList == null || newKeyPackageRelayList.tags.isEmpty()) return
|
||||
if (newKeyPackageRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupKeyPackageRelayList?.id != newKeyPackageRelayList.id) {
|
||||
if (backupGuard.accept(backupKeyPackageRelayList, newKeyPackageRelayList, isEmpty = newKeyPackageRelayList.tags.isEmpty()) { updateKeyPackageRelayList(newKeyPackageRelayList) }) {
|
||||
backupKeyPackageRelayList = newKeyPackageRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
|
||||
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
|
||||
if (newNIP65RelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
|
||||
if (backupGuard.accept(backupNIP65RelayList, newNIP65RelayList, isEmpty = newNIP65RelayList.tags.isEmpty()) { updateNIP65RelayList(newNIP65RelayList) }) {
|
||||
backupNIP65RelayList = newNIP65RelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1109,14 +1170,17 @@ class AccountSettings(
|
||||
fun updateCashuWallet(newWallet: CashuWalletEvent?) {
|
||||
if (newWallet == null) return
|
||||
// Replaceable: keep the latest by id (id changes on each re-sign).
|
||||
if (backupCashuWallet?.id != newWallet.id) {
|
||||
if (backupGuard.accept(backupCashuWallet, newWallet) { updateCashuWallet(newWallet) }) {
|
||||
backupCashuWallet = newWallet
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) {
|
||||
if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return
|
||||
if (newNutzapInfo == null) return
|
||||
// The guard runs first, so another app wiping the nutzap info is questioned like any
|
||||
// other lossy rewrite instead of silently clearing the backup.
|
||||
if (!backupGuard.accept(backupNutzapInfo, newNutzapInfo) { updateNutzapInfo(newNutzapInfo) }) return
|
||||
// A mints-less kind:10019 is the "stop receiving nutzaps" tombstone
|
||||
// (an empty replacement carrying only an `alt` tag). Don't restore it
|
||||
// on next launch — backing it up would undo clearNutzapInfo() once the
|
||||
@@ -1125,10 +1189,8 @@ class AccountSettings(
|
||||
clearNutzapInfo()
|
||||
return
|
||||
}
|
||||
if (backupNutzapInfo?.id != newNutzapInfo.id) {
|
||||
backupNutzapInfo = newNutzapInfo
|
||||
saveAccountSettings()
|
||||
}
|
||||
backupNutzapInfo = newNutzapInfo
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1138,6 +1200,8 @@ class AccountSettings(
|
||||
* LocalCache on next launch and resurrect the deleted wallet.
|
||||
*/
|
||||
fun clearCashuWallet() {
|
||||
// A deleted wallet must not be restorable from an open conflict.
|
||||
backupGuard.drop(backupSlotOf(CashuWalletEvent.KIND))
|
||||
if (backupCashuWallet != null) {
|
||||
backupCashuWallet = null
|
||||
saveAccountSettings()
|
||||
@@ -1146,6 +1210,7 @@ class AccountSettings(
|
||||
|
||||
/** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */
|
||||
fun clearNutzapInfo() {
|
||||
backupGuard.drop(backupSlotOf(NutzapInfoEvent.KIND))
|
||||
if (backupNutzapInfo != null) {
|
||||
backupNutzapInfo = null
|
||||
saveAccountSettings()
|
||||
@@ -1184,80 +1249,72 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
|
||||
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
|
||||
if (newNIPA3PaymentTargets == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) {
|
||||
if (backupGuard.accept(backupNipA3PaymentTargets, newNIPA3PaymentTargets, isEmpty = newNIPA3PaymentTargets.tags.isEmpty()) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) {
|
||||
backupNipA3PaymentTargets = newNIPA3PaymentTargets
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
|
||||
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
|
||||
if (newBolt12Offers == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupBolt12Offers?.id != newBolt12Offers.id) {
|
||||
if (backupGuard.accept(backupBolt12Offers, newBolt12Offers, isEmpty = newBolt12Offers.tags.isEmpty()) { updateBolt12Offers(newBolt12Offers) }) {
|
||||
backupBolt12Offers = newBolt12Offers
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
if (newSearchRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupSearchRelayList?.id != newSearchRelayList.id) {
|
||||
if (backupGuard.accept(backupSearchRelayList, newSearchRelayList, isEmpty = newSearchRelayList.tags.isEmpty()) { updateSearchRelayList(newSearchRelayList) }) {
|
||||
backupSearchRelayList = newSearchRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateIndexRelayList(newIndexRelayList: IndexerRelayListEvent?) {
|
||||
if (newIndexRelayList == null || newIndexRelayList.tags.isEmpty()) return
|
||||
if (newIndexRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupIndexRelayList?.id != newIndexRelayList.id) {
|
||||
if (backupGuard.accept(backupIndexRelayList, newIndexRelayList, isEmpty = newIndexRelayList.tags.isEmpty()) { updateIndexRelayList(newIndexRelayList) }) {
|
||||
backupIndexRelayList = newIndexRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) {
|
||||
if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return
|
||||
if (newRelayFeedList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupRelayFeedsList?.id != newRelayFeedList.id) {
|
||||
if (backupGuard.accept(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) {
|
||||
backupRelayFeedsList = newRelayFeedList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
|
||||
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
|
||||
if (newBlockedRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupBlockedRelayList?.id != newBlockedRelayList.id) {
|
||||
if (backupGuard.accept(backupBlockedRelayList, newBlockedRelayList, isEmpty = newBlockedRelayList.tags.isEmpty()) { updateBlockedRelayList(newBlockedRelayList) }) {
|
||||
backupBlockedRelayList = newBlockedRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) {
|
||||
if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return
|
||||
if (newTrustedRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupTrustedRelayList?.id != newTrustedRelayList.id) {
|
||||
if (backupGuard.accept(backupTrustedRelayList, newTrustedRelayList, isEmpty = newTrustedRelayList.tags.isEmpty()) { updateTrustedRelayList(newTrustedRelayList) }) {
|
||||
backupTrustedRelayList = newTrustedRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
|
||||
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
|
||||
if (newPrivateHomeRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
|
||||
if (backupGuard.accept(backupPrivateHomeRelayList, newPrivateHomeRelayList, isEmpty = newPrivateHomeRelayList.tags.isEmpty()) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) {
|
||||
backupPrivateHomeRelayList = newPrivateHomeRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1266,50 +1323,45 @@ class AccountSettings(
|
||||
override fun channelList() = backupChannelList
|
||||
|
||||
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
|
||||
if (newChannelList == null || newChannelList.tags.isEmpty()) return
|
||||
if (newChannelList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupChannelList?.id != newChannelList.id) {
|
||||
if (backupGuard.accept(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) {
|
||||
backupChannelList = newChannelList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGeohashListTo(newGeohashList: GeohashListEvent?) {
|
||||
if (newGeohashList == null || newGeohashList.tags.isEmpty()) return
|
||||
if (newGeohashList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupGeohashList?.id != newGeohashList.id) {
|
||||
if (backupGuard.accept(backupGeohashList, newGeohashList, isEmpty = newGeohashList.tags.isEmpty()) { updateGeohashListTo(newGeohashList) }) {
|
||||
backupGeohashList = newGeohashList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateHashtagListTo(newHashtagList: HashtagListEvent?) {
|
||||
if (newHashtagList == null || newHashtagList.tags.isEmpty()) return
|
||||
if (newHashtagList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupHashtagList?.id != newHashtagList.id) {
|
||||
if (backupGuard.accept(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) {
|
||||
backupHashtagList = newHashtagList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) {
|
||||
if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return
|
||||
if (newFavoriteDvmList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) {
|
||||
if (backupGuard.accept(backupFavoriteAlgoFeedsList, newFavoriteDvmList, isEmpty = newFavoriteDvmList.tags.isEmpty()) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) {
|
||||
backupFavoriteAlgoFeedsList = newFavoriteDvmList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
|
||||
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
|
||||
if (newCommunityList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupCommunityList?.id != newCommunityList.id) {
|
||||
if (backupGuard.accept(backupCommunityList, newCommunityList, isEmpty = newCommunityList.tags.isEmpty()) { updateCommunityListTo(newCommunityList) }) {
|
||||
backupCommunityList = newCommunityList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1318,10 +1370,9 @@ class AccountSettings(
|
||||
override fun ephemeralChatList() = backupEphemeralChatList
|
||||
|
||||
override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
|
||||
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
|
||||
if (newEphemeralChatList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupEphemeralChatList?.id != newEphemeralChatList.id) {
|
||||
if (backupGuard.accept(backupEphemeralChatList, newEphemeralChatList, isEmpty = newEphemeralChatList.tags.isEmpty()) { updateEphemeralChatListTo(newEphemeralChatList) }) {
|
||||
backupEphemeralChatList = newEphemeralChatList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1334,8 +1385,7 @@ class AccountSettings(
|
||||
// so an empty `tags` is NOT an empty list — guard only on null.
|
||||
if (newRelayGroupList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupRelayGroupList?.id != newRelayGroupList.id) {
|
||||
if (backupGuard.accept(backupRelayGroupList, newRelayGroupList) { updateRelayGroupListTo(newRelayGroupList) }) {
|
||||
backupRelayGroupList = newRelayGroupList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1348,27 +1398,25 @@ class AccountSettings(
|
||||
// so an empty `tags` is NOT an empty list — guard only on null.
|
||||
if (newConcordList == null) return
|
||||
|
||||
if (backupConcordList?.id != newConcordList.id) {
|
||||
if (backupGuard.accept(backupConcordList, newConcordList) { updateConcordListTo(newConcordList) }) {
|
||||
backupConcordList = newConcordList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
|
||||
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
|
||||
if (trustProviderList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupTrustProviderList?.id != trustProviderList.id) {
|
||||
if (backupGuard.accept(backupTrustProviderList, trustProviderList, isEmpty = trustProviderList.tags.isEmpty()) { updateTrustProviderListTo(trustProviderList) }) {
|
||||
backupTrustProviderList = trustProviderList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMuteList(newMuteList: MuteListEvent?) {
|
||||
if (newMuteList == null || newMuteList.tags.isEmpty()) return
|
||||
if (newMuteList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupMuteList?.id != newMuteList.id) {
|
||||
if (backupGuard.accept(backupMuteList, newMuteList, isEmpty = newMuteList.tags.isEmpty()) { updateMuteList(newMuteList) }) {
|
||||
backupMuteList = newMuteList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1378,11 +1426,15 @@ class AccountSettings(
|
||||
appSettings: AppSpecificDataEvent?,
|
||||
newSyncedSettings: AccountSyncedSettingsInternal,
|
||||
) {
|
||||
if (appSettings == null || appSettings.content.isEmpty()) return
|
||||
if (appSettings == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupAppSpecificData?.id != appSettings.id) {
|
||||
if (backupGuard.accept(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isBlank()) { updateAppSpecificData(appSettings, newSyncedSettings) }) {
|
||||
backupAppSpecificData = appSettings
|
||||
// An emptied blob (signed here or accepted by the user) has no settings to merge.
|
||||
if (appSettings.content.isBlank()) {
|
||||
saveAccountSettings()
|
||||
return
|
||||
}
|
||||
syncedSettings.updateFrom(newSyncedSettings)
|
||||
|
||||
// Null means an older client rewrote the blob without this key — leave the
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
@@ -49,6 +51,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
@@ -369,6 +373,35 @@ class EventBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a re-signed backup that replaces a lossy version from another app. It goes
|
||||
* where a normal save of that event goes; lists of the user's own relays (NIP-65, DM,
|
||||
* key package) and the wallet go everywhere, and also to every relay the restored list
|
||||
* names, since the lossy version may have removed them from the outbox set. Profiles and
|
||||
* nutzap info go everywhere too, like their normal saves.
|
||||
*/
|
||||
fun sendRestoredVersion(event: Event) {
|
||||
when (event) {
|
||||
is AdvertisedRelayListEvent -> sendEverywhereAnd(event, event.relays().mapTo(mutableSetOf()) { it.relayUrl })
|
||||
is ChatMessageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
is KeyPackageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
is CashuWalletEvent -> sendLiterallyEverywhere(event)
|
||||
// Profiles and nutzap info are normally saved everywhere so others can find them;
|
||||
// the restore must reach the same relays or they keep serving the lossy version.
|
||||
is MetadataEvent -> sendLiterallyEverywhere(event)
|
||||
is NutzapInfoEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
else -> sendMyPublicAndPrivateOutbox(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendEverywhereAnd(
|
||||
event: Event,
|
||||
extraRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + extraRelays)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
fun sendLiterallyEverywhere(event: Event) {
|
||||
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.backups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* How an undecided backup conflict is written to storage.
|
||||
*
|
||||
* A conflict is a question put to the user, and the backup it holds back stays held back until
|
||||
* they answer. That outlives the process it was raised in, so the three events it is made of —
|
||||
* the saved version, the incoming one, and the change that caused it — are stored and read back.
|
||||
*
|
||||
* The diff is **not** stored. It is recomputed from the two versions on load, so a conflict whose
|
||||
* incoming version no longer removes anything (the other app put it back) simply stops being one
|
||||
* instead of being resurrected from a stale description of itself.
|
||||
*
|
||||
* Kept out of `LocalPreferences` so it is reachable from a plain JVM test.
|
||||
*/
|
||||
object BackupConflictStorage {
|
||||
// Every settings save writes the open conflicts, and each can hold a follow list with
|
||||
// thousands of entries: reuse the last encoding while the conflicts themselves are the same.
|
||||
@Volatile
|
||||
private var lastEncoded: Pair<List<String>, String>? = null
|
||||
|
||||
fun encode(conflicts: List<Triple<Event, Event, Event>>): String {
|
||||
val ids = conflicts.flatMap { (saved, incoming, cause) -> listOf(saved.id, incoming.id, cause.id) }
|
||||
lastEncoded?.let { (cachedIds, encoded) -> if (cachedIds == ids) return encoded }
|
||||
return encodeUncached(conflicts).also { lastEncoded = ids to it }
|
||||
}
|
||||
|
||||
private fun encodeUncached(conflicts: List<Triple<Event, Event, Event>>): String =
|
||||
JsonMapper.toJson(
|
||||
conflicts.map { (saved, incoming, cause) ->
|
||||
listOf(
|
||||
OptimizedJsonMapper.toJson(saved),
|
||||
OptimizedJsonMapper.toJson(incoming),
|
||||
OptimizedJsonMapper.toJson(cause),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Unreadable entries are dropped one at a time: a conflict that cannot be parsed is not one
|
||||
* the user can be asked about, and it must not take the readable ones down with it.
|
||||
*/
|
||||
fun decode(stored: String?): List<Triple<Event, Event, Event>> {
|
||||
if (stored.isNullOrBlank()) return emptyList()
|
||||
val rows =
|
||||
try {
|
||||
JsonMapper.fromJson<List<List<String>>>(stored)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BackupConflictStorage", "Could not read the stored backup conflicts", e)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return rows.mapNotNull { row ->
|
||||
if (row.size != 3) return@mapNotNull null
|
||||
try {
|
||||
Triple(
|
||||
OptimizedJsonMapper.fromJson(row[0]),
|
||||
OptimizedJsonMapper.fromJson(row[1]),
|
||||
OptimizedJsonMapper.fromJson(row[2]),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BackupConflictStorage", "Could not read a stored backup conflict", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -111,11 +111,26 @@ class AppSpecificState(
|
||||
getAppSpecificDataFlow().collect {
|
||||
try {
|
||||
Log.d("AccountRegisterObservers") { "Updating AppSpecificData for ${signer.pubKey}" }
|
||||
(it.note.event as? AppSpecificDataEvent)?.let {
|
||||
val decrypted = signer.decrypt(it.content, it.pubKey)
|
||||
(it.note.event as? AppSpecificDataEvent)?.let { event ->
|
||||
// A blob another app emptied has nothing to decrypt, so it used to die
|
||||
// in the catch below — yet an empty version is exactly the wipe the
|
||||
// backup guard exists to question, and [AccountSettings] already
|
||||
// accepts one (`isEmpty`). Without this it never got there: the guard
|
||||
// was never called, no conflict was raised, and the NIP-78 card could
|
||||
// not appear for the case it was written for.
|
||||
//
|
||||
// The settings handed over are never merged — updateAppSpecificData
|
||||
// returns before that whenever the content is empty — so defaults
|
||||
// here cannot overwrite the real ones.
|
||||
if (event.content.isBlank()) {
|
||||
settings.updateAppSpecificData(event, AccountSyncedSettingsInternal())
|
||||
return@let
|
||||
}
|
||||
|
||||
val decrypted = signer.decrypt(event.content, event.pubKey)
|
||||
try {
|
||||
val syncedSettings = JsonMapper.fromJson<AccountSyncedSettingsInternal>(decrypted)
|
||||
settings.updateAppSpecificData(it, syncedSettings)
|
||||
settings.updateAppSpecificData(event, syncedSettings)
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences", e)
|
||||
|
||||
+54
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.account.metadata.filterBasicAccountInfoFromKeys
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.account.metadata.filterBookmarksAndReportsFromKey
|
||||
@@ -38,6 +39,24 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Which relays to ask for an account's own events, in order of what we actually know.
|
||||
*
|
||||
* Split out from the manager so the precedence can be tested without standing up an account:
|
||||
* the rule is the whole fix, and the case that broke — every set but [defaults] empty — is the
|
||||
* one no live account reproduces on demand.
|
||||
*/
|
||||
fun pickAccountDataRelays(
|
||||
home: Set<NormalizedRelayUrl>,
|
||||
inbox: Set<NormalizedRelayUrl>,
|
||||
defaults: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> =
|
||||
when {
|
||||
home.isNotEmpty() -> home
|
||||
inbox.isNotEmpty() -> inbox
|
||||
else -> defaults
|
||||
}
|
||||
|
||||
/**
|
||||
* Each account's own profile, lists and recent posts — for **every** logged-in account, in one
|
||||
* subscription.
|
||||
@@ -59,13 +78,40 @@ class AccountMetadataEoseManager(
|
||||
|
||||
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
|
||||
|
||||
/**
|
||||
* Where to look for an account's **own** profile, follow list and lists.
|
||||
*
|
||||
* Normally its home relays: where it publishes is where its own events are. But home relays
|
||||
* are NIP-65 write relays (plus private storage and local ones), and a kind:10002 that lists
|
||||
* none — read-only, or simply wrong — is a statement about publishing, not about where the
|
||||
* account's existing events can be found. Keyed on that set alone, such an account joined no
|
||||
* relay bucket at all and this subscription asked **nothing** on its behalf: its profile,
|
||||
* follows and every backed-up list stayed frozen at whatever was last stored, forever, with
|
||||
* nothing on screen to say why. A follow list replaced from another client never arrived, so
|
||||
* the backup guard could not even raise the conflict it exists to raise.
|
||||
*
|
||||
* The fallbacks only widen where we *read*; they never redirect a publish. Its inbox relays
|
||||
* come first — a client that rewrote these lists most likely also put them where the account
|
||||
* says to reach it — and the app's event finders last, which is what "we don't know" means
|
||||
* everywhere else.
|
||||
*
|
||||
* Deliberately not fixed in [relayListOrDefaultsWhenUnknown]: an empty list there means "they
|
||||
* told us: nothing", and defaulting it would override an explicit choice about publishing.
|
||||
*/
|
||||
fun relaysToQuery(query: AccountQueryState): Set<NormalizedRelayUrl> =
|
||||
pickAccountDataRelays(
|
||||
home = relayFlow(query).value,
|
||||
inbox = query.account.nip65RelayList.inboxFlow.value,
|
||||
defaults = Constants.eventFinderRelays,
|
||||
)
|
||||
|
||||
override fun updateFilter(
|
||||
keys: List<AccountQueryState>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val accountsPerRelay = mutableMapOf<NormalizedRelayUrl, MutableList<AccountQueryState>>()
|
||||
keys.forEach { key ->
|
||||
relayFlow(key).value.forEach { relay ->
|
||||
relaysToQuery(key).forEach { relay ->
|
||||
accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +163,13 @@ class AccountMetadataEoseManager(
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
relayFlow(key).collectLatest { invalidateFilters() }
|
||||
},
|
||||
// The inbox list is watched too because [relaysToQuery] falls back to it:
|
||||
// an account with no home relays would otherwise keep querying whatever
|
||||
// the fallback resolved to at startup, deaf to its own NIP-65 changing.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.nip65RelayList.inboxFlow
|
||||
.collectLatest { invalidateFilters() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawe
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.ProfileAppRecommendationsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictReviewScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen
|
||||
@@ -680,6 +681,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BackupConflictReview> { BackupConflictReviewScreen(it.slot, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RelayManagement> { RelayManagementScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RelayMembers> { RelayMembersScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
|
||||
@@ -829,6 +829,10 @@ sealed class Route {
|
||||
val replyTo: HexKey? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BackupConflictReview(
|
||||
val slot: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayInfo(
|
||||
val url: String,
|
||||
) : Route()
|
||||
|
||||
+7
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.model.Dao
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
@@ -1663,6 +1664,12 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps another app's version of a backed-up list. Off the main thread: it waits for the
|
||||
* backup guard's lock, which the backup collectors hold while diffing large lists.
|
||||
*/
|
||||
fun acceptExternalBackupVersion(conflict: ReplaceableBackupConflict) = viewModelScope.launch(Dispatchers.IO) { account.acceptExternalVersion(conflict) }
|
||||
|
||||
inline fun launchSigner(crossinline action: suspend () -> Unit) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
reportSignerErrors { action() }
|
||||
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
|
||||
|
||||
/**
|
||||
* Cards at the top of Home's feed, one per open [ReplaceableBackupConflict]: the user
|
||||
* changed one of the account's backed-up lists (or the profile) in another app, and
|
||||
* Amethyst asks to confirm before its backup moves on. They can't be dismissed. They stay
|
||||
* until the user decides on the review screen they open ([BackupConflictReviewScreen]).
|
||||
*
|
||||
* The most recent conflict gets a lead card with a headline and a picture of the change
|
||||
* (the follow list's bar, or removed/added counts); the rest are slim pills. They are the
|
||||
* first item of the feed, so they scroll away with it instead of covering posts.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupConflictCards(
|
||||
conflictMap: Map<String, ReplaceableBackupConflict>,
|
||||
nav: INav,
|
||||
onAccept: (ReplaceableBackupConflict) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (conflictMap.isEmpty()) return
|
||||
|
||||
val conflicts = remember(conflictMap) { conflictMap.values.sortedByDescending { it.cause.createdAt } }
|
||||
val open = { conflict: ReplaceableBackupConflict -> nav.nav(Route.BackupConflictReview(conflict.slot)) }
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val lead = conflicts.first()
|
||||
LeadConflictCard(lead, onAccept = { onAccept(lead) }) { open(lead) }
|
||||
conflicts.drop(1).forEach { conflict -> ConflictPill(conflict, onAccept = { onAccept(conflict) }) { open(conflict) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** A headline that says what happened, in the event's own terms. */
|
||||
@Composable
|
||||
private fun headlineOf(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
counts: ConflictCounts,
|
||||
): String {
|
||||
val diff = conflict.diff
|
||||
val specific: String? =
|
||||
when (diff) {
|
||||
// Counted, not "shrank": a version that drops 3 follows and adds 100 still raises
|
||||
// a conflict, and calling that a shrink contradicts the card's own bar.
|
||||
is ContactListDiff -> plural(R.plurals.backup_card_follows_dropped, diff.follows.removed.size)
|
||||
is MuteListDiff -> plural(R.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size)
|
||||
is MetadataDiff -> plural(R.plurals.backup_card_profile_lost_fields, counts.removed)
|
||||
is AdvertisedRelayListDiff -> plural(R.plurals.backup_card_nip65_lost, diff.relays.removed.size)
|
||||
is RelayListDiff -> relayListHeadline(conflict.eventType, diff.relays.removed.size)
|
||||
is ChannelListDiff -> plural(R.plurals.backup_card_leave_chats, diff.channels.removed.size)
|
||||
is CommunityListDiff -> plural(R.plurals.backup_card_leave_communities, diff.communities.removed.size)
|
||||
is EphemeralChatListDiff -> plural(R.plurals.backup_card_leave_rooms, diff.rooms.removed.size)
|
||||
is SimpleGroupListDiff -> plural(R.plurals.backup_card_leave_groups, diff.groups.removed.size)
|
||||
is FavoriteAlgoFeedsListDiff -> plural(R.plurals.backup_card_feeds_removed, diff.feeds.removed.size)
|
||||
is HashtagListDiff -> plural(R.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size)
|
||||
is GeohashListDiff -> plural(R.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size)
|
||||
is TrustProviderListDiff -> plural(R.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size)
|
||||
is NutzapInfoDiff ->
|
||||
when {
|
||||
diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_removed)
|
||||
diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_replaced)
|
||||
else -> plural(R.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size)
|
||||
}
|
||||
is PaymentTargetsDiff -> plural(R.plurals.backup_card_payment_targets_removed, diff.targets.removed.size)
|
||||
is Bolt12OfferListDiff -> plural(R.plurals.backup_card_offers_removed, diff.offers.removed.size)
|
||||
is CashuWalletDiff -> encryptedHeadline(R.string.backup_card_wallet_emptied, R.string.backup_card_wallet_rewritten, diff.wallet)
|
||||
is ConcordCommunityListDiff -> encryptedHeadline(R.string.backup_card_concord_emptied, R.string.backup_card_concord_rewritten, diff.communities)
|
||||
is AppSpecificDataDiff -> stringRes(R.string.backup_card_app_settings)
|
||||
else -> null
|
||||
}
|
||||
// A headline counting removed items reads wrong when only encrypted items were lost.
|
||||
if (specific.isNullOrEmpty()) return stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType)))
|
||||
// Headlines lead with the loss; what the other app added to the same list is said too,
|
||||
// so a list that dropped 3 and gained 40 doesn't read as if it only shrank. The wording
|
||||
// follows the headline's: following people, muting, joining chats, blocking relays.
|
||||
val added = addedToHeadlinedList(diff, conflict.eventType)
|
||||
if (added <= 0) return specific
|
||||
val tail =
|
||||
when {
|
||||
diff is ContactListDiff -> R.plurals.backup_card_and_followed
|
||||
diff is MuteListDiff -> R.plurals.backup_card_and_muted
|
||||
diff is ChannelListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> R.plurals.backup_card_and_joined
|
||||
conflict.eventType == BackupEventType.BLOCKED_RELAYS -> R.plurals.backup_card_and_blocked
|
||||
else -> R.plurals.backup_card_and_added
|
||||
}
|
||||
return pluralStringResource(tail, added, specific, added)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many items were added to the list the headline counts removals from, or 0 when the
|
||||
* headline isn't a count of list items (a key swap, an encrypted rewrite, a profile).
|
||||
*/
|
||||
private fun addedToHeadlinedList(
|
||||
diff: EventDiff,
|
||||
type: BackupEventType,
|
||||
): Int =
|
||||
when (diff) {
|
||||
is ContactListDiff -> diff.follows.added.size
|
||||
is MuteListDiff -> diff.publicMutes.added.size
|
||||
is AdvertisedRelayListDiff -> diff.relays.added.size
|
||||
is RelayListDiff -> if (relayListHasHeadline(type)) diff.relays.added.size else 0
|
||||
is ChannelListDiff -> diff.channels.added.size
|
||||
is CommunityListDiff -> diff.communities.added.size
|
||||
is EphemeralChatListDiff -> diff.rooms.added.size
|
||||
is SimpleGroupListDiff -> diff.groups.added.size
|
||||
is FavoriteAlgoFeedsListDiff -> diff.feeds.added.size
|
||||
is HashtagListDiff -> diff.hashtags.added.size
|
||||
is GeohashListDiff -> diff.geohashes.added.size
|
||||
is NutzapInfoDiff -> if (diff.p2pkPubkey == null) diff.mints.added.size else 0
|
||||
is PaymentTargetsDiff -> diff.targets.added.size
|
||||
is Bolt12OfferListDiff -> diff.offers.added.size
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/** The relay lists [relayListHeadline] has a counted headline for. */
|
||||
private fun relayListHasHeadline(type: BackupEventType): Boolean =
|
||||
when (type) {
|
||||
BackupEventType.DM_RELAYS, BackupEventType.KEY_PACKAGE_RELAYS, BackupEventType.SEARCH_RELAYS,
|
||||
BackupEventType.INDEXER_RELAYS, BackupEventType.RELAY_FEEDS, BackupEventType.PRIVATE_OUTBOX_RELAYS,
|
||||
BackupEventType.TRUSTED_RELAYS, BackupEventType.BLOCKED_RELAYS,
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** The count's plural, or "" when nothing of that kind was removed. */
|
||||
@Composable
|
||||
private fun plural(
|
||||
id: Int,
|
||||
count: Int,
|
||||
): String = if (count > 0) pluralStringResource(id, count, count) else ""
|
||||
|
||||
@Composable
|
||||
private fun relayListHeadline(
|
||||
type: BackupEventType,
|
||||
removed: Int,
|
||||
): String =
|
||||
when (type) {
|
||||
BackupEventType.DM_RELAYS -> plural(R.plurals.backup_card_dm_relays_lost, removed)
|
||||
BackupEventType.KEY_PACKAGE_RELAYS -> plural(R.plurals.backup_card_key_package_relays_lost, removed)
|
||||
BackupEventType.SEARCH_RELAYS -> plural(R.plurals.backup_card_search_relays_lost, removed)
|
||||
BackupEventType.INDEXER_RELAYS -> plural(R.plurals.backup_card_indexer_relays_lost, removed)
|
||||
BackupEventType.RELAY_FEEDS -> plural(R.plurals.backup_card_relay_feeds_lost, removed)
|
||||
BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(R.plurals.backup_card_private_outbox_lost, removed)
|
||||
BackupEventType.TRUSTED_RELAYS -> plural(R.plurals.backup_card_trusted_lost, removed)
|
||||
BackupEventType.BLOCKED_RELAYS -> plural(R.plurals.backup_card_unblocked, removed)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun encryptedHeadline(
|
||||
emptied: Int,
|
||||
rewritten: Int,
|
||||
change: ContentChange,
|
||||
): String = if (change == ContentChange.CLEARED) stringRes(emptied) else stringRes(rewritten)
|
||||
|
||||
@Composable
|
||||
private fun LeadConflictCard(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
onAccept: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
val counts = remember(conflict) { countsOf(conflict.diff) }
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
Modifier.border(1.dp, tones.removed.copy(alpha = 0.35f), RoundedCornerShape(22.dp)).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(Modifier.size(40.dp).clip(CircleShape).background(tones.removedContainer()), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.removed)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(headlineOf(conflict, counts), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
AcceptButton(onAccept)
|
||||
}
|
||||
val diff = conflict.diff
|
||||
if (diff is ContactListDiff) {
|
||||
val (saved, new) = conflict.followCounts
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(saved.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
||||
SplitBar(
|
||||
listOf(
|
||||
BarSegment(saved - diff.follows.removed.size, tones.kept),
|
||||
BarSegment(diff.follows.removed.size, tones.removed.copy(alpha = 0.6f)),
|
||||
BarSegment(diff.follows.added.size, tones.added),
|
||||
),
|
||||
Modifier.weight(1f),
|
||||
height = 8.dp,
|
||||
)
|
||||
Text(new.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = if (new < saved) tones.removed else tones.added)
|
||||
}
|
||||
} else {
|
||||
CountChips(counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CountChips(counts: ConflictCounts) {
|
||||
val tones = conflictTones()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (counts.removed > 0) StatusTag(stringRes(R.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed)
|
||||
if (counts.added > 0) StatusTag(stringRes(R.string.backup_conflict_count_added, counts.added.toString()), tones.added)
|
||||
if (counts.changed > 0) StatusTag(stringRes(R.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConflictPill(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
onAccept: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
val counts = remember(conflict) { countsOf(conflict.diff) }
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(32.dp).clip(CircleShape).background(tones.changedContainer()), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Text(headlineOf(conflict, counts), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
AcceptButton(onAccept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the change right from Home: the change was made on purpose in another app, so
|
||||
* keeping it is the quick path. Reverting needs the review screen, one tap on the card away.
|
||||
*/
|
||||
@Composable
|
||||
private fun AcceptButton(onAccept: () -> Unit) {
|
||||
FilledTonalButton(
|
||||
onClick = onAccept,
|
||||
modifier = Modifier.height(36.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
) {
|
||||
Text(stringRes(R.string.backup_card_accept), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
+1183
File diff suppressed because it is too large
Load Diff
+937
@@ -0,0 +1,937 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.channels
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
|
||||
/**
|
||||
* Per-event layouts for the account's other lists: each relay list framed by what it is
|
||||
* for (who can reach you, what gets unblocked…), joined spaces as tiles, followed topics
|
||||
* as a pill cloud, trust providers as a per-service table, payment targets and offers as
|
||||
* cards, and the encrypted-only events as a single explained panel. Returns false when
|
||||
* [conflict] has no layout here, so the caller falls back to the generic one.
|
||||
*/
|
||||
internal fun LazyListScope.listDiffItems(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
): Boolean {
|
||||
when (val diff = conflict.diff) {
|
||||
is RelayListDiff -> relayListItems(diff, conflict, nav)
|
||||
is ChannelListDiff -> publicChatItems(diff, conflict, accountViewModel, nav)
|
||||
is CommunityListDiff -> communityItems(diff, conflict, accountViewModel, nav)
|
||||
is EphemeralChatListDiff -> ephemeralRoomItems(diff, conflict, nav)
|
||||
is FavoriteAlgoFeedsListDiff -> favoriteFeedItems(diff, conflict, accountViewModel, nav)
|
||||
is HashtagListDiff -> topicItems(diff.hashtags, (conflict.saved as? HashtagListEvent)?.publicHashtags().orEmpty(), diff.privateItems, isPlace = false, nav)
|
||||
is GeohashListDiff -> topicItems(diff.geohashes, (conflict.saved as? GeohashListEvent)?.publicGeohashes().orEmpty(), diff.privateItems, isPlace = true, nav)
|
||||
is TrustProviderListDiff -> trustProviderItems(diff, conflict, accountViewModel, nav)
|
||||
is PaymentTargetsDiff -> paymentTargetItems(diff, conflict)
|
||||
is Bolt12OfferListDiff -> offerItems(diff, conflict)
|
||||
is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, R.string.backup_review_wallet_body)
|
||||
is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, R.string.backup_review_concord_body)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private val Pad = Modifier.padding(horizontal = 16.dp)
|
||||
|
||||
/** Fate of an item between the saved version and the new one. */
|
||||
internal enum class ItemFate { KEPT, DROPPED, ADDED, CHANGED }
|
||||
|
||||
/**
|
||||
* The saved items in their order, each marked kept or dropped (or changed), then the new
|
||||
* ones: the full picture of a list, not just its differences.
|
||||
*/
|
||||
private fun <T, K> fates(
|
||||
saved: List<T>,
|
||||
diff: ListDiff<T>,
|
||||
key: (T) -> K,
|
||||
): List<Pair<T, ItemFate>> {
|
||||
val removed = diff.removed.mapTo(HashSet(), key)
|
||||
val changed = diff.changed.associateBy { key(it.after) }
|
||||
val seen = HashSet<K>()
|
||||
val result = mutableListOf<Pair<T, ItemFate>>()
|
||||
saved.forEach { item ->
|
||||
val k = key(item)
|
||||
if (!seen.add(k)) return@forEach
|
||||
when (k) {
|
||||
in removed -> result.add(item to ItemFate.DROPPED)
|
||||
in changed -> result.add(changed.getValue(k).after to ItemFate.CHANGED)
|
||||
else -> result.add(item to ItemFate.KEPT)
|
||||
}
|
||||
}
|
||||
diff.added.forEach { result.add(it to ItemFate.ADDED) }
|
||||
return result
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fateColor(fate: ItemFate): Color {
|
||||
val tones = conflictTones()
|
||||
return when (fate) {
|
||||
ItemFate.KEPT -> MaterialTheme.colorScheme.onSurface
|
||||
ItemFate.DROPPED -> tones.removed
|
||||
ItemFate.ADDED -> tones.added
|
||||
ItemFate.CHANGED -> tones.changed
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> LazyListScope.countTiles(
|
||||
items: List<Pair<T, ItemFate>>,
|
||||
removedCaption: Int,
|
||||
addedCaption: Int,
|
||||
) {
|
||||
item(key = "count-tiles", contentType = "tiles") {
|
||||
val tones = conflictTones()
|
||||
Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatTile(items.count { it.second == ItemFate.DROPPED }.toString(), stringRes(removedCaption), tones.removed, Modifier.weight(1f))
|
||||
StatTile(items.count { it.second == ItemFate.ADDED }.toString(), stringRes(addedCaption), tones.added, Modifier.weight(1f))
|
||||
StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(R.string.backup_review_kept), tones.kept, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.privateItemsCard(change: ContentChange) {
|
||||
if (change == ContentChange.NONE) return
|
||||
item(key = "private-items", contentType = "private") { PrivateItemsCard(change, Pad.padding(top = 18.dp)) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Relay lists, framed by what each one is for.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** How a relay list's rows are presented: its icon and what a kept or dropped relay is called. */
|
||||
private class RelayFraming(
|
||||
val icon: MaterialSymbol,
|
||||
val keptTag: Int,
|
||||
val droppedTag: Int = R.string.backup_review_dropped_tag,
|
||||
)
|
||||
|
||||
private fun framingOf(type: BackupEventType): RelayFraming =
|
||||
when (type) {
|
||||
BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_receiving_tag)
|
||||
BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_listed_tag)
|
||||
BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_searching_tag)
|
||||
BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag)
|
||||
BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_listed_tag)
|
||||
BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_storing_tag)
|
||||
BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag)
|
||||
else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag)
|
||||
}
|
||||
|
||||
private fun savedRelaysOf(event: Event): List<NormalizedRelayUrl> =
|
||||
when (event) {
|
||||
is ChatMessageRelayListEvent -> event.relays()
|
||||
is KeyPackageRelayListEvent -> event.relays()
|
||||
is SearchRelayListEvent -> event.publicRelays()
|
||||
is IndexerRelayListEvent -> event.publicRelays()
|
||||
is RelayFeedsListEvent -> event.publicRelays()
|
||||
is BlockedRelayListEvent -> event.publicRelays()
|
||||
is TrustedRelayListEvent -> event.publicRelays()
|
||||
is PrivateOutboxRelayListEvent -> event.publicRelays()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun LazyListScope.relayListItems(
|
||||
diff: RelayListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
nav: INav,
|
||||
) {
|
||||
val rows = fates(savedRelaysOf(conflict.saved), diff.relays) { it }
|
||||
if (conflict.eventType == BackupEventType.BLOCKED_RELAYS) {
|
||||
blockedRelayItems(rows, nav)
|
||||
} else {
|
||||
// No summary card: the rows themselves, tagged kept / dropped / new, are the change.
|
||||
val framing = framingOf(conflict.eventType)
|
||||
items(rows, key = { "relay-" + it.first.url }, contentType = { "relay-row" }) { (url, fate) ->
|
||||
RelayFateRow(url, fate, framing, nav)
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateRelays)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayFateRow(
|
||||
url: NormalizedRelayUrl,
|
||||
fate: ItemFate,
|
||||
framing: RelayFraming,
|
||||
nav: INav,
|
||||
) {
|
||||
val color = fateColor(fate)
|
||||
val tag =
|
||||
when (fate) {
|
||||
ItemFate.KEPT -> framing.keptTag
|
||||
ItemFate.DROPPED -> framing.droppedTag
|
||||
ItemFate.ADDED -> R.string.backup_review_new_tag
|
||||
ItemFate.CHANGED -> R.string.backup_review_demoted_tag
|
||||
}
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(Route.RelayInfo(url.url)) }
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(symbol = framing.icon, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
url.url.removePrefix("wss://").removeSuffix("/"),
|
||||
modifier = Modifier.weight(1f),
|
||||
color = color,
|
||||
textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
StatusTag(stringRes(tag), if (fate == ItemFate.KEPT) MaterialTheme.colorScheme.placeholderText else color)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun LazyListScope.blockedRelayItems(
|
||||
rows: List<Pair<NormalizedRelayUrl, ItemFate>>,
|
||||
nav: INav,
|
||||
) {
|
||||
val unblocked = rows.filter { it.second == ItemFate.DROPPED }.map { it.first }
|
||||
val stillBlocked = rows.filter { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.map { it.first }
|
||||
val newlyBlocked = rows.filter { it.second == ItemFate.ADDED }.map { it.first }
|
||||
|
||||
item(key = "blocked-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
TintedPanel(tones.changedContainer(), Pad.padding(top = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(44.dp))
|
||||
Column {
|
||||
Text(stringRes(R.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed)
|
||||
Text(stringRes(R.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unblocked.isNotEmpty()) {
|
||||
item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) }
|
||||
items(unblocked, key = { "unblocked-" + it.url }, contentType = { "relay-row" }) { url ->
|
||||
val tones = conflictTones()
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, tones.changed.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(Route.RelayInfo(url.url)) }
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(36.dp).clip(CircleShape).border(2.dp, tones.changed, CircleShape), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Text(url.url.removePrefix("wss://").removeSuffix("/"), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
StatusTag(stringRes(R.string.backup_review_allowed_tag), tones.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newlyBlocked.isNotEmpty()) {
|
||||
item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) }
|
||||
items(newlyBlocked, key = { "newly-blocked-" + it.url }, contentType = { "relay-row" }) { url ->
|
||||
RelayFateRow(url, ItemFate.ADDED, framingOf(BackupEventType.BLOCKED_RELAYS), nav)
|
||||
}
|
||||
}
|
||||
if (stillBlocked.isNotEmpty()) {
|
||||
item(key = "still-blocked", contentType = "chips") {
|
||||
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionCaption(stringRes(R.string.backup_review_still_blocked))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
stillBlocked.forEach { url ->
|
||||
Row(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(Route.RelayInfo(url.url)) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.Lock, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(16.dp))
|
||||
Text(url.url.removePrefix("wss://").removeSuffix("/"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Joined spaces: public chats, communities, ephemeral rooms and favorite feeds as tiles.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** One space tile: its picture, name and subtitle are loaded by [content]. */
|
||||
private fun <T> LazyListScope.spaceGrid(
|
||||
keyPrefix: String,
|
||||
items: List<Pair<T, ItemFate>>,
|
||||
key: (T) -> String,
|
||||
content: @Composable (T, ItemFate, Modifier) -> Unit,
|
||||
) {
|
||||
countTiles(items, R.string.backup_review_left, R.string.backup_review_joined)
|
||||
items(items.chunked(2), key = { keyPrefix + key(it.first().first) }, contentType = { "space-row" }) { row ->
|
||||
Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
row.forEach { (item, fate) -> content(item, fate, Modifier.weight(1f)) }
|
||||
if (row.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpaceTile(
|
||||
name: String,
|
||||
subtitle: String,
|
||||
robot: String,
|
||||
picture: String?,
|
||||
fate: ItemFate,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Column(Modifier.alpha(if (fate == ItemFate.DROPPED) 0.45f else 1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = picture,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
Column {
|
||||
Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
val tag =
|
||||
when (fate) {
|
||||
ItemFate.DROPPED -> R.string.backup_review_left_tag to tones.removed
|
||||
ItemFate.ADDED -> R.string.backup_review_new_tag to tones.added
|
||||
ItemFate.CHANGED -> R.string.backup_review_moved_tag to tones.changed
|
||||
ItemFate.KEPT -> null
|
||||
}
|
||||
tag?.let { (res, color) -> StatusTag(stringRes(res), color, Modifier.align(Alignment.TopEnd)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.publicChatItems(
|
||||
diff: ChannelListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? ChannelListEvent)?.tags?.channels().orEmpty()
|
||||
spaceGrid("chat-", fates(saved, diff.channels) { it.eventId }, { it.eventId }) { tag, fate, modifier ->
|
||||
LoadPublicChatChannel(tag.eventId, accountViewModel) { channel ->
|
||||
// Subscribes to the channel's metadata on relays and recomposes when it arrives.
|
||||
val state by observeChannel(channel, accountViewModel)
|
||||
val current = state?.channel ?: channel
|
||||
SpaceTile(
|
||||
name = current.toBestDisplayName(),
|
||||
subtitle = stringRes(R.string.backup_type_public_chats),
|
||||
robot = tag.eventId,
|
||||
picture = channel.profilePicture(),
|
||||
fate = fate,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = modifier,
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
private fun LazyListScope.communityItems(
|
||||
diff: CommunityListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? CommunityListEvent)?.publicCommunities().orEmpty()
|
||||
spaceGrid("community-", fates(saved, diff.communities) { it.address }, { it.address.toValue() }) { tag, fate, modifier ->
|
||||
AddressableSpaceTile(tag.address, fate, accountViewModel, modifier) { nav.nav(Route.Community(tag.address)) }
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
private fun LazyListScope.favoriteFeedItems(
|
||||
diff: FavoriteAlgoFeedsListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? FavoriteAlgoFeedsListEvent)?.publicFavoriteAlgoFeeds().orEmpty()
|
||||
spaceGrid("feed-", fates(saved, diff.feeds) { it.address }, { it.address.toValue() }) { bookmark, fate, modifier ->
|
||||
AddressableSpaceTile(bookmark.address, fate, accountViewModel, modifier) {
|
||||
nav.nav { accountViewModel.getAddressableNoteIfExists(bookmark.address)?.let { routeFor(it, accountViewModel.account) } }
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
/** A community or feed tile: loads the definition event from relays for its name and picture. */
|
||||
@Composable
|
||||
private fun AddressableSpaceTile(
|
||||
address: Address,
|
||||
fate: ItemFate,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
val event =
|
||||
if (note != null) {
|
||||
val state by observeNote(note, accountViewModel)
|
||||
state.note.event
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val (name, picture) =
|
||||
when (event) {
|
||||
is CommunityDefinitionEvent -> event.name() to event.image()?.imageUrl
|
||||
is AppDefinitionEvent -> event.appMetaData().let { (it?.name ?: it?.displayName) to (it?.picture ?: it?.image) }
|
||||
else -> null to null
|
||||
}
|
||||
SpaceTile(
|
||||
name = name ?: address.dTag.ifBlank { address.toValue().toShortDisplay() },
|
||||
subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) R.string.backup_review_feed else R.string.backup_type_communities),
|
||||
robot = address.toValue(),
|
||||
picture = picture,
|
||||
fate = fate,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.ephemeralRoomItems(
|
||||
diff: EphemeralChatListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty()
|
||||
val rooms = fates(saved, diff.rooms) { it }
|
||||
countTiles(rooms, R.string.backup_review_left, R.string.backup_review_joined)
|
||||
items(rooms, key = { "room-" + it.first.toKey() }, contentType = { "room-row" }) { (room, fate) -> RoomRow(room, fate, nav) }
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomRow(
|
||||
room: RoomId,
|
||||
fate: ItemFate,
|
||||
nav: INav,
|
||||
) {
|
||||
val color = fateColor(fate)
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(routeFor(room)) }
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(color.copy(alpha = 0.14f)), contentAlignment = Alignment.Center) {
|
||||
Text("#", fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, color = color)
|
||||
}
|
||||
Column(Modifier.weight(1f).alpha(if (fate == ItemFate.DROPPED) 0.55f else 1f)) {
|
||||
Text(room.id, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
room.relayUrl.url
|
||||
.removePrefix("wss://")
|
||||
.removeSuffix("/"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
when (fate) {
|
||||
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), color)
|
||||
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Followed hashtags and places: a pill cloud of what stays, goes and arrives.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun LazyListScope.topicItems(
|
||||
diff: ListDiff<String>,
|
||||
saved: List<String>,
|
||||
privateItems: ContentChange,
|
||||
isPlace: Boolean,
|
||||
nav: INav,
|
||||
) {
|
||||
val topics = fates(saved, diff) { it.lowercase() }
|
||||
countTiles(topics, if (isPlace) R.string.backup_review_places_gone else R.string.backup_review_topics_gone, R.string.backup_review_new_count_caption)
|
||||
item(key = "topic-cloud", contentType = "cloud") {
|
||||
val tones = conflictTones()
|
||||
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionCaption(stringRes(if (isPlace) R.string.backup_review_places_you_follow else R.string.backup_review_hashtags_you_follow))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) {
|
||||
topics.forEach { (topic, fate) ->
|
||||
val color = fateColor(fate)
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
when (fate) {
|
||||
ItemFate.KEPT -> MaterialTheme.colorScheme.surfaceContainer
|
||||
else -> color.copy(alpha = 0.12f)
|
||||
},
|
||||
).then(if (fate == ItemFate.DROPPED) Modifier.border(1.dp, tones.removed.copy(alpha = 0.5f), shape) else Modifier)
|
||||
.clickable { nav.nav(if (isPlace) Route.Geohash(topic) else Route.Hashtag(topic)) }
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (isPlace) Icon(symbol = MaterialSymbols.LocationOn, contentDescription = null, tint = color, modifier = Modifier.size(16.dp))
|
||||
val decoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null
|
||||
if (isPlace) {
|
||||
// Like the rest of the app: the city the geohash resolves to next to
|
||||
// its code, and just the code until (or unless) the name resolves.
|
||||
LoadCityName(topic, onLoading = { TopicText("#$topic", color, decoration) }) { city ->
|
||||
TopicText(if (city == topic) "#$topic" else "$city · #$topic", color, decoration)
|
||||
}
|
||||
} else {
|
||||
TopicText("#$topic", color, decoration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
privateItemsCard(privateItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopicText(
|
||||
text: String,
|
||||
color: Color,
|
||||
decoration: TextDecoration?,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = decoration,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Trust providers: one row per service, who provided it before and who does now.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private class ServiceRow(
|
||||
val service: String,
|
||||
val before: HexKey?,
|
||||
val after: HexKey?,
|
||||
)
|
||||
|
||||
private fun LazyListScope.trustProviderItems(
|
||||
diff: TrustProviderListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// A service can have several providers, so each side is a set per service: providers on
|
||||
// both sides are kept rows, and the ones only before or only after are paired up as swaps.
|
||||
val before = (conflict.saved as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey })
|
||||
val after = (conflict.incoming as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey })
|
||||
val services = (before.keys + after.keys).distinct()
|
||||
val rows =
|
||||
services.flatMap { service ->
|
||||
val had = before[service].orEmpty().distinct()
|
||||
val has = after[service].orEmpty().distinct()
|
||||
val gone = had - has.toSet()
|
||||
val new = has - had.toSet()
|
||||
had.filter { it in has }.map { ServiceRow(service, it, it) } +
|
||||
List(maxOf(gone.size, new.size)) { ServiceRow(service, gone.getOrNull(it), new.getOrNull(it)) }
|
||||
}
|
||||
val affected =
|
||||
rows
|
||||
.filter { it.before != it.after }
|
||||
.map { it.service }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
item(key = "trust-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
TintedPanel(MaterialTheme.colorScheme.surfaceContainer, Pad.padding(top = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(tones.changedContainer()), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.Group, contentDescription = null, tint = tones.changed)
|
||||
}
|
||||
Column {
|
||||
Text(stringRes(R.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
Text(stringRes(R.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "trust-header", contentType = "table-header") {
|
||||
Row(Pad.padding(top = 16.dp, bottom = 4.dp).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(R.string.backup_review_service, R.string.backup_review_saved, R.string.backup_review_new).forEachIndexed { index, res ->
|
||||
Text(stringRes(res).uppercase(), Modifier.weight(if (index == 0) 1.1f else 1f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(rows, key = { "service-" + it.service + "-" + it.before + "-" + it.after }, contentType = { "service-row" }) { row -> ServiceTableRow(row, accountViewModel, nav) }
|
||||
item(key = "trust-note", contentType = "note") {
|
||||
val tones = conflictTones()
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 14.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(tones.changedContainer())
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed)
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceTableRow(
|
||||
row: ServiceRow,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
val tint =
|
||||
when {
|
||||
row.before == row.after -> MaterialTheme.colorScheme.surfaceContainer
|
||||
row.after == null -> tones.removedContainer()
|
||||
row.before == null -> tones.addedContainer()
|
||||
else -> tones.changedContainer()
|
||||
}
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(tint)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(serviceLabel(row.service), Modifier.weight(1.1f), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, maxLines = 2)
|
||||
ProviderCell(row.before, struck = row.before != row.after, color = tones.removed, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f))
|
||||
ProviderCell(row.after, struck = false, color = if (row.before == row.after) MaterialTheme.colorScheme.onSurface else tones.added, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun serviceLabel(type: String): String =
|
||||
when (type) {
|
||||
"rank" -> stringRes(R.string.backup_review_service_rank)
|
||||
"followers" -> stringRes(R.string.backup_review_service_followers)
|
||||
"t" -> stringRes(R.string.backup_review_service_topics)
|
||||
else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderCell(
|
||||
pubKey: HexKey?,
|
||||
struck: Boolean,
|
||||
color: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
if (pubKey == null) {
|
||||
Text("—", modifier, color = MaterialTheme.colorScheme.placeholderText)
|
||||
return
|
||||
}
|
||||
LoadUser(pubKey, accountViewModel) { user ->
|
||||
Row(
|
||||
modifier.clip(RoundedCornerShape(8.dp)).clickable { nav.nav(Route.Profile(pubKey)) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = pubKey,
|
||||
// Observed, so the picture appears when the provider's metadata arrives.
|
||||
model = observedPicture(pubKey, accountViewModel),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(color = if (struck) color else MaterialTheme.colorScheme.onSurface, textDecoration = if (struck) TextDecoration.LineThrough else null)) {
|
||||
if (user != null) {
|
||||
UsernameDisplay(user, fontWeight = FontWeight.Medium, textColor = if (struck) color else Color.Unspecified, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(pubKey.toShortDisplay(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Payment targets and BOLT12 offers: the ways people pay you, as cards.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private fun LazyListScope.paymentTargetItems(
|
||||
diff: PaymentTargetsDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
) {
|
||||
val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty()
|
||||
val targets = fates(saved, diff.targets) { it }
|
||||
moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_payment_targets_gone)
|
||||
items(targets, key = { "target-" + it.first.type + ":" + it.first.authority }, contentType = { "money-row" }) { (target, fate) -> PaymentTargetRow(target, fate) }
|
||||
}
|
||||
|
||||
private fun LazyListScope.offerItems(
|
||||
diff: Bolt12OfferListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
) {
|
||||
val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty()
|
||||
val offers = fates(saved, diff.offers) { it }
|
||||
moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_offers_gone)
|
||||
items(offers, key = { "offer-" + it.first }, contentType = { "money-row" }) { (offer, fate) ->
|
||||
MoneyRow(stringRes(R.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.moneyHero(
|
||||
dropped: Int,
|
||||
headline: Int,
|
||||
) {
|
||||
item(key = "money-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
val color = if (dropped > 0) tones.removed else tones.added
|
||||
TintedPanel(color.copy(alpha = 0.12f), Pad.padding(top = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(color.copy(alpha = 0.18f)), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = color)
|
||||
}
|
||||
Column {
|
||||
Text(pluralStringResource(headline, dropped, dropped), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color)
|
||||
Text(stringRes(R.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentTargetRow(
|
||||
target: PaymentTarget,
|
||||
fate: ItemFate,
|
||||
) = MoneyRow(target.type.replaceFirstChar { it.uppercase() }, target.authority, fate)
|
||||
|
||||
@Composable
|
||||
private fun MoneyRow(
|
||||
kind: String,
|
||||
value: String,
|
||||
fate: ItemFate,
|
||||
) {
|
||||
val color = fateColor(fate)
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) {
|
||||
Text(kind.take(1), fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(kind, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = color,
|
||||
textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
when (fate) {
|
||||
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_dropped_tag), color)
|
||||
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Encrypted-only events (Cashu wallet, Concord list): one explained panel.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private fun LazyListScope.encryptedOnlyItems(
|
||||
icon: MaterialSymbol,
|
||||
change: ContentChange,
|
||||
body: Int,
|
||||
) {
|
||||
item(key = "encrypted-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
val (color, headline) =
|
||||
when (change) {
|
||||
ContentChange.CLEARED -> tones.removed to R.string.backup_review_encrypted_cleared
|
||||
ContentChange.ADDED -> tones.added to R.string.backup_review_encrypted_added
|
||||
else -> tones.changed to R.string.backup_review_encrypted_changed
|
||||
}
|
||||
Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
TintedPanel(color.copy(alpha = 0.12f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Icon(symbol = icon, contentDescription = null, tint = color, modifier = Modifier.size(44.dp))
|
||||
Text(stringRes(headline), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color)
|
||||
}
|
||||
}
|
||||
PrivateItemsCard(change)
|
||||
Text(stringRes(body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ItemChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.Birthday
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
|
||||
|
||||
private const val MAX_VALUE_LENGTH = 80
|
||||
|
||||
/** Items lost, gained and changed in a conflict, counting encrypted content as one item. */
|
||||
@Immutable
|
||||
class ConflictCounts(
|
||||
val removed: Int,
|
||||
val added: Int,
|
||||
val changed: Int,
|
||||
)
|
||||
|
||||
private fun ContentChange.asCounts() =
|
||||
ConflictCounts(
|
||||
removed = if (this == ContentChange.CLEARED) 1 else 0,
|
||||
added = if (this == ContentChange.ADDED) 1 else 0,
|
||||
changed = if (this == ContentChange.CHANGED) 1 else 0,
|
||||
)
|
||||
|
||||
private operator fun ConflictCounts.plus(other: ConflictCounts) = ConflictCounts(removed + other.removed, added + other.added, changed + other.changed)
|
||||
|
||||
private fun ListDiff<*>.asCounts() = ConflictCounts(removed.size, added.size, changed.size)
|
||||
|
||||
private fun ValueChange<*>?.asCounts() =
|
||||
when {
|
||||
this == null -> ConflictCounts(0, 0, 0)
|
||||
after == null -> ConflictCounts(1, 0, 0)
|
||||
before == null -> ConflictCounts(0, 1, 0)
|
||||
else -> ConflictCounts(0, 0, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The counts straight from a diff's own lists, without building display items: what the
|
||||
* Home cards need, however large the conflict.
|
||||
*/
|
||||
fun countsOf(diff: EventDiff): ConflictCounts =
|
||||
when (diff) {
|
||||
is MetadataDiff ->
|
||||
listOf(diff.name, diff.displayName, diff.picture, diff.banner, diff.website, diff.about, diff.pronouns, diff.nip05, diff.lud06, diff.lud16, diff.clinkOffer, diff.bot, diff.birthday)
|
||||
.fold(diff.otherFields.asCounts() + diff.identityClaims.asCounts()) { acc, change -> acc + change.asCounts() }
|
||||
is ContactListDiff -> diff.follows.asCounts()
|
||||
is MuteListDiff -> diff.publicMutes.asCounts() + diff.privateItems.asCounts()
|
||||
is AdvertisedRelayListDiff -> diff.relays.asCounts()
|
||||
is RelayListDiff -> diff.relays.asCounts() + diff.privateRelays.asCounts()
|
||||
is ChannelListDiff -> diff.channels.asCounts() + diff.privateItems.asCounts()
|
||||
is CommunityListDiff -> diff.communities.asCounts() + diff.privateItems.asCounts()
|
||||
is HashtagListDiff -> diff.hashtags.asCounts() + diff.privateItems.asCounts()
|
||||
is GeohashListDiff -> diff.geohashes.asCounts() + diff.privateItems.asCounts()
|
||||
is FavoriteAlgoFeedsListDiff -> diff.feeds.asCounts() + diff.privateItems.asCounts()
|
||||
is EphemeralChatListDiff -> diff.rooms.asCounts() + diff.privateItems.asCounts()
|
||||
is SimpleGroupListDiff -> diff.groups.asCounts() + diff.privateItems.asCounts()
|
||||
is TrustProviderListDiff -> diff.providers.asCounts() + diff.privateItems.asCounts()
|
||||
is NutzapInfoDiff -> diff.mints.asCounts() + diff.relays.asCounts() + diff.p2pkPubkey.asCounts()
|
||||
is PaymentTargetsDiff -> diff.targets.asCounts()
|
||||
is Bolt12OfferListDiff -> diff.offers.asCounts()
|
||||
is CashuWalletDiff -> diff.wallet.asCounts()
|
||||
is ConcordCommunityListDiff -> diff.communities.asCounts()
|
||||
is AppSpecificDataDiff -> diff.data.asCounts()
|
||||
else -> ConflictCounts(0, 0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of a conflict, typed by what it points to so the review screen can load it from
|
||||
* relays and link to it. [detail] is extra text: a petname, a relay marker, before → after.
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface ReviewItem {
|
||||
val detail: String?
|
||||
|
||||
class Person(
|
||||
val pubKey: HexKey,
|
||||
override val detail: String? = null,
|
||||
) : ReviewItem
|
||||
|
||||
class Thread(
|
||||
val eventId: HexKey,
|
||||
override val detail: String? = null,
|
||||
) : ReviewItem
|
||||
|
||||
class PublicChat(
|
||||
val channelId: HexKey,
|
||||
override val detail: String? = null,
|
||||
) : ReviewItem
|
||||
|
||||
class Addressable(
|
||||
val address: Address,
|
||||
override val detail: String? = null,
|
||||
) : ReviewItem
|
||||
|
||||
class ChatRoom(
|
||||
val roomId: RoomId,
|
||||
override val detail: String? = null,
|
||||
) : ReviewItem
|
||||
|
||||
class Relay(
|
||||
val url: NormalizedRelayUrl,
|
||||
override val detail: String? = null,
|
||||
) : ReviewItem
|
||||
|
||||
class Text(
|
||||
val text: String,
|
||||
override val detail: String? = null,
|
||||
) : ReviewItem
|
||||
}
|
||||
|
||||
/** One labelled group of differences (people, relays, profile fields…) of a conflict. */
|
||||
@Immutable
|
||||
class DiffGroup(
|
||||
val label: Int,
|
||||
val removed: List<ReviewItem>,
|
||||
val added: List<ReviewItem>,
|
||||
val changed: List<ReviewItem>,
|
||||
) {
|
||||
fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty()
|
||||
}
|
||||
|
||||
/** Everything a conflict shows: its non-empty groups and the change to its encrypted content. */
|
||||
@Immutable
|
||||
class DiffPresentation(
|
||||
val groups: List<DiffGroup>,
|
||||
val content: ContentChange,
|
||||
) {
|
||||
val removedCount = groups.sumOf { it.removed.size }
|
||||
val addedCount = groups.sumOf { it.added.size }
|
||||
val changedCount = groups.sumOf { it.changed.size }
|
||||
}
|
||||
|
||||
/** A group from a [ListDiff] of an event's parsed items. */
|
||||
private fun <T> listGroup(
|
||||
label: Int,
|
||||
diff: ListDiff<T>,
|
||||
item: (T) -> ReviewItem,
|
||||
change: (ItemChange<T>) -> ReviewItem,
|
||||
) = DiffGroup(label, diff.removed.map(item), diff.added.map(item), diff.changed.map(change))
|
||||
|
||||
/** A group whose items can't change, only be added or removed (hashtags, plain relays…). */
|
||||
private fun <T> listGroup(
|
||||
label: Int,
|
||||
diff: ListDiff<T>,
|
||||
item: (T) -> ReviewItem,
|
||||
) = listGroup(label, diff, item) { item(it.after) }
|
||||
|
||||
/** Keeps only the items of [T] from a list diff of a sealed family (e.g. one kind of [MuteTag]). */
|
||||
private inline fun <reified T> ListDiff<*>.only() =
|
||||
ListDiff(
|
||||
removed.filterIsInstance<T>(),
|
||||
added.filterIsInstance<T>(),
|
||||
changed.filter { it.before is T }.map { ItemChange(it.before as T, it.after as T) },
|
||||
)
|
||||
|
||||
/** A group from single-valued fields: a field set only after is added, only before is removed. */
|
||||
private fun fieldGroup(
|
||||
label: Int,
|
||||
fields: List<Pair<String, ValueChange<String>?>>,
|
||||
): DiffGroup {
|
||||
val removed = mutableListOf<ReviewItem>()
|
||||
val added = mutableListOf<ReviewItem>()
|
||||
val changed = mutableListOf<ReviewItem>()
|
||||
fields.forEach { (name, change) ->
|
||||
if (change == null) return@forEach
|
||||
val before = change.before
|
||||
val after = change.after
|
||||
when {
|
||||
before != null && after == null -> removed.add(ReviewItem.Text(name, clip(before)))
|
||||
before == null && after != null -> added.add(ReviewItem.Text(name, clip(after)))
|
||||
else -> changed.add(ReviewItem.Text(name, clip(before) + " → " + clip(after)))
|
||||
}
|
||||
}
|
||||
return DiffGroup(label, removed, added, changed)
|
||||
}
|
||||
|
||||
private fun arrow(
|
||||
before: String?,
|
||||
after: String?,
|
||||
) = (before ?: "∅") + " → " + (after ?: "∅")
|
||||
|
||||
/**
|
||||
* Each event's own diff as typed, labelled groups, built once per diff: it maps every item,
|
||||
* which for a large list is too much work to redo on each recomposition.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPresentation(diff: EventDiff): DiffPresentation {
|
||||
val context = LocalContext.current
|
||||
return remember(diff, context) { presentationOf(diff) { context.getString(it) } }
|
||||
}
|
||||
|
||||
/** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */
|
||||
fun presentationOf(
|
||||
diff: EventDiff,
|
||||
str: (Int) -> String,
|
||||
): DiffPresentation {
|
||||
val (groups, content) =
|
||||
when (diff) {
|
||||
is MetadataDiff -> {
|
||||
val birthday = diff.birthday?.let { ValueChange(it.before?.let(::birthdayText), it.after?.let(::birthdayText)) }
|
||||
val bot = diff.bot?.let { ValueChange(it.before?.toString(), it.after?.toString()) }
|
||||
listOf(
|
||||
fieldGroup(
|
||||
R.string.backup_entry_profile_field,
|
||||
listOf(
|
||||
str(R.string.backup_profile_field_name) to diff.name,
|
||||
str(R.string.backup_profile_field_display_name) to diff.displayName,
|
||||
str(R.string.backup_profile_field_about) to diff.about,
|
||||
str(R.string.backup_profile_field_picture) to diff.picture,
|
||||
str(R.string.backup_profile_field_banner) to diff.banner,
|
||||
str(R.string.backup_profile_field_website) to diff.website,
|
||||
str(R.string.backup_profile_field_nip05) to diff.nip05,
|
||||
str(R.string.backup_profile_field_lud16) to diff.lud16,
|
||||
str(R.string.backup_profile_field_lud06) to diff.lud06,
|
||||
str(R.string.backup_profile_field_clink_offer) to diff.clinkOffer,
|
||||
str(R.string.backup_profile_field_pronouns) to diff.pronouns,
|
||||
str(R.string.backup_profile_field_birthday) to birthday,
|
||||
str(R.string.backup_profile_field_bot) to bot,
|
||||
),
|
||||
),
|
||||
listGroup(
|
||||
R.string.backup_entry_identity_claim,
|
||||
diff.identityClaims,
|
||||
{ ReviewItem.Text(it.platformIdentity()) },
|
||||
{ ReviewItem.Text(it.after.platformIdentity(), arrow(it.before.proof, it.after.proof)) },
|
||||
),
|
||||
listGroup(
|
||||
R.string.backup_entry_other,
|
||||
diff.otherFields,
|
||||
{ ReviewItem.Text(it.first, clip(it.second)) },
|
||||
{ ReviewItem.Text(it.after.first, clip(it.before.second) + " → " + clip(it.after.second)) },
|
||||
),
|
||||
) to ContentChange.NONE
|
||||
}
|
||||
is ContactListDiff ->
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_person,
|
||||
diff.follows,
|
||||
{ ReviewItem.Person(it.pubKey, it.petname) },
|
||||
{
|
||||
ReviewItem.Person(
|
||||
it.after.pubKey,
|
||||
arrow(
|
||||
listOfNotNull(it.before.petname, it.before.relayUri?.url).joinToString().ifEmpty { null },
|
||||
listOfNotNull(it.after.petname, it.after.relayUri?.url).joinToString().ifEmpty { null },
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
) to ContentChange.NONE
|
||||
is MuteListDiff ->
|
||||
listOf(
|
||||
listGroup(R.string.backup_entry_person, diff.publicMutes.only<UserTag>(), { ReviewItem.Person(it.pubKey) }),
|
||||
listGroup(R.string.backup_entry_word, diff.publicMutes.only<WordTag>(), { ReviewItem.Text("\"" + it.word + "\"") }),
|
||||
listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only<HashtagTag>(), { ReviewItem.Text("#" + it.hashtag) }),
|
||||
listGroup(R.string.backup_entry_thread, diff.publicMutes.only<EventTag>(), { ReviewItem.Thread(it.eventId) }),
|
||||
) to diff.privateItems
|
||||
is AdvertisedRelayListDiff -> {
|
||||
val types =
|
||||
mapOf(
|
||||
AdvertisedRelayType.BOTH to str(R.string.backup_conflict_relay_read_write),
|
||||
AdvertisedRelayType.READ to str(R.string.backup_conflict_relay_read_only),
|
||||
AdvertisedRelayType.WRITE to str(R.string.backup_conflict_relay_write_only),
|
||||
)
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_relay,
|
||||
diff.relays,
|
||||
{ ReviewItem.Relay(it.relayUrl, types[it.type]) },
|
||||
{ ReviewItem.Relay(it.after.relayUrl, arrow(types[it.before.type], types[it.after.type])) },
|
||||
),
|
||||
) to ContentChange.NONE
|
||||
}
|
||||
is RelayListDiff -> listOf(listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays
|
||||
is ChannelListDiff ->
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_public_chat,
|
||||
diff.channels,
|
||||
{ ReviewItem.PublicChat(it.eventId) },
|
||||
{ ReviewItem.PublicChat(it.after.eventId, arrow(it.before.relay?.url, it.after.relay?.url)) },
|
||||
),
|
||||
) to diff.privateItems
|
||||
is CommunityListDiff ->
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_community,
|
||||
diff.communities,
|
||||
{ ReviewItem.Addressable(it.address) },
|
||||
{ ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) },
|
||||
),
|
||||
) to diff.privateItems
|
||||
is HashtagListDiff -> listOf(listGroup(R.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems
|
||||
is GeohashListDiff -> listOf(listGroup(R.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems
|
||||
is FavoriteAlgoFeedsListDiff ->
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_algo_feed,
|
||||
diff.feeds,
|
||||
{ ReviewItem.Addressable(it.address) },
|
||||
{ ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) },
|
||||
),
|
||||
) to diff.privateItems
|
||||
is EphemeralChatListDiff -> listOf(listGroup(R.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems
|
||||
is SimpleGroupListDiff ->
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_relay_group,
|
||||
diff.groups,
|
||||
{ ReviewItem.Text(it.name ?: it.groupId, it.relayUrl) },
|
||||
{ ReviewItem.Text(it.after.name ?: it.after.groupId, arrow(it.before.name, it.after.name)) },
|
||||
),
|
||||
) to diff.privateItems
|
||||
is TrustProviderListDiff ->
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_trust_provider,
|
||||
diff.providers,
|
||||
{ ReviewItem.Person(it.pubkey, it.service.type) },
|
||||
{ ReviewItem.Person(it.after.pubkey, it.after.service.type + ": " + arrow(it.before.relayUrl.url, it.after.relayUrl.url)) },
|
||||
),
|
||||
) to diff.privateItems
|
||||
is NutzapInfoDiff ->
|
||||
listOf(
|
||||
listGroup(
|
||||
R.string.backup_entry_mint,
|
||||
diff.mints,
|
||||
{ ReviewItem.Text(it.mintUrl, it.units.joinToString().ifEmpty { null }) },
|
||||
{ ReviewItem.Text(it.after.mintUrl, arrow(it.before.units.joinToString(), it.after.units.joinToString())) },
|
||||
),
|
||||
listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) }),
|
||||
nutzapKeyGroup(diff.p2pkPubkey),
|
||||
) to ContentChange.NONE
|
||||
is PaymentTargetsDiff ->
|
||||
listOf(
|
||||
listGroup(R.string.backup_entry_payment_target, diff.targets, { ReviewItem.Text(it.type, clip(it.authority)) }),
|
||||
) to ContentChange.NONE
|
||||
is Bolt12OfferListDiff -> listOf(listGroup(R.string.backup_entry_offer, diff.offers, { ReviewItem.Text(clip(it)) })) to ContentChange.NONE
|
||||
is CashuWalletDiff -> emptyList<DiffGroup>() to diff.wallet
|
||||
is ConcordCommunityListDiff -> emptyList<DiffGroup>() to diff.communities
|
||||
is AppSpecificDataDiff -> emptyList<DiffGroup>() to diff.data
|
||||
else -> emptyList<DiffGroup>() to ContentChange.NONE
|
||||
}
|
||||
return DiffPresentation(groups.filterNot { it.isEmpty() }, content)
|
||||
}
|
||||
|
||||
private fun nutzapKeyGroup(change: ValueChange<HexKey>?): DiffGroup {
|
||||
val none = emptyList<ReviewItem>()
|
||||
if (change == null) return DiffGroup(R.string.backup_entry_nutzap_key, none, none, none)
|
||||
val before = change.before
|
||||
val after = change.after
|
||||
return when {
|
||||
before != null && after == null -> DiffGroup(R.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none)
|
||||
before == null && after != null -> DiffGroup(R.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none)
|
||||
else -> DiffGroup(R.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after))))
|
||||
}
|
||||
}
|
||||
|
||||
private fun birthdayText(birthday: Birthday): String = listOfNotNull(birthday.year, birthday.month, birthday.day).joinToString("-")
|
||||
|
||||
fun clip(text: String?): String {
|
||||
if (text == null) return ""
|
||||
val oneLine = text.replace('\n', ' ')
|
||||
return if (oneLine.length > MAX_VALUE_LENGTH) oneLine.take(MAX_VALUE_LENGTH) + "…" else oneLine
|
||||
}
|
||||
|
||||
fun eventTypeName(type: BackupEventType): Int =
|
||||
when (type) {
|
||||
BackupEventType.PROFILE -> R.string.backup_type_profile
|
||||
BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list
|
||||
BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list
|
||||
BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays
|
||||
BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays
|
||||
BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays
|
||||
BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays
|
||||
BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays
|
||||
BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds
|
||||
BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays
|
||||
BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays
|
||||
BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays
|
||||
BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings
|
||||
BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats
|
||||
BackupEventType.COMMUNITIES -> R.string.backup_type_communities
|
||||
BackupEventType.HASHTAGS -> R.string.backup_type_hashtags
|
||||
BackupEventType.GEOHASHES -> R.string.backup_type_geohashes
|
||||
BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds
|
||||
BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats
|
||||
BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups
|
||||
BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities
|
||||
BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers
|
||||
BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet
|
||||
BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info
|
||||
BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets
|
||||
BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers
|
||||
BackupEventType.OTHER -> R.string.backup_type_other
|
||||
}
|
||||
|
||||
fun eventTypeExplainer(type: BackupEventType): Int =
|
||||
when (type) {
|
||||
BackupEventType.PROFILE -> R.string.backup_type_profile_explainer
|
||||
BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list_explainer
|
||||
BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list_explainer
|
||||
BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays_explainer
|
||||
BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays_explainer
|
||||
BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays_explainer
|
||||
BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays_explainer
|
||||
BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays_explainer
|
||||
BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds_explainer
|
||||
BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays_explainer
|
||||
BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays_explainer
|
||||
BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays_explainer
|
||||
BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings_explainer
|
||||
BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats_explainer
|
||||
BackupEventType.COMMUNITIES -> R.string.backup_type_communities_explainer
|
||||
BackupEventType.HASHTAGS -> R.string.backup_type_hashtags_explainer
|
||||
BackupEventType.GEOHASHES -> R.string.backup_type_geohashes_explainer
|
||||
BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds_explainer
|
||||
BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats_explainer
|
||||
BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups_explainer
|
||||
BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities_explainer
|
||||
BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers_explainer
|
||||
BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet_explainer
|
||||
BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info_explainer
|
||||
BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets_explainer
|
||||
BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers_explainer
|
||||
BackupEventType.OTHER -> R.string.backup_type_other_explainer
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
|
||||
|
||||
/** A row of the review list, flattened so hundreds of entries scroll lazily. */
|
||||
@Immutable
|
||||
internal sealed interface ReviewRow {
|
||||
class Section(
|
||||
val titleRes: Int,
|
||||
val count: Int,
|
||||
val tone: Tone,
|
||||
) : ReviewRow
|
||||
|
||||
class Note(
|
||||
val textRes: Int,
|
||||
) : ReviewRow
|
||||
|
||||
class Group(
|
||||
val labelRes: Int,
|
||||
) : ReviewRow
|
||||
|
||||
class Entry(
|
||||
val item: ReviewItem,
|
||||
) : ReviewRow
|
||||
}
|
||||
|
||||
internal enum class Tone { REMOVED, ADDED, CHANGED }
|
||||
|
||||
internal fun buildRows(
|
||||
presentation: DiffPresentation,
|
||||
skipGroups: Set<Int> = emptySet(),
|
||||
): List<ReviewRow> {
|
||||
val rows = mutableListOf<ReviewRow>()
|
||||
|
||||
fun section(
|
||||
titleRes: Int,
|
||||
count: Int,
|
||||
tone: Tone,
|
||||
contentNote: Int?,
|
||||
items: (DiffGroup) -> List<ReviewItem>,
|
||||
) {
|
||||
if (count == 0 && contentNote == null) return
|
||||
rows.add(ReviewRow.Section(titleRes, count, tone))
|
||||
contentNote?.let { rows.add(ReviewRow.Note(it)) }
|
||||
presentation.groups.forEach { group ->
|
||||
if (group.label in skipGroups) return@forEach
|
||||
val entries = items(group)
|
||||
if (entries.isNotEmpty()) {
|
||||
rows.add(ReviewRow.Group(group.label))
|
||||
entries.forEach { rows.add(ReviewRow.Entry(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val content = presentation.content
|
||||
section(R.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, R.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed }
|
||||
section(R.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, R.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added }
|
||||
section(R.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, R.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed }
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entry of a backup conflict, removed / added / changed, in a lazy list: people,
|
||||
* notes, communities, feeds, chats and relays are loaded from relays and open their own
|
||||
* screens when tapped, so the user can investigate the change before deciding. Opened from
|
||||
* the Home cards ([BackupConflictCards]); deciding resolves the conflict and removes its card.
|
||||
* Leaving without deciding keeps the card and the frozen backup.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupConflictReviewScreen(
|
||||
slot: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val settings = accountViewModel.account.settings
|
||||
val conflicts by settings.backupConflicts.collectAsStateWithLifecycle()
|
||||
val conflict = conflicts[slot]
|
||||
|
||||
// The only way out after a decision: resolving (from here or elsewhere) removes the
|
||||
// conflict, and this pops the screen exactly once. The buttons don't pop themselves,
|
||||
// which would pop a second screen, and a stale conflict (replaced by a newer one) is
|
||||
// simply shown in its place.
|
||||
val popped = remember { mutableStateOf(false) }
|
||||
if (conflict == null) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (!popped.value) {
|
||||
popped.value = true
|
||||
nav.popBack()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
BackupConflictReview(
|
||||
conflict = conflict,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
// A read-only account can't sign the saved version again, so it can only keep the new one.
|
||||
onRestore = if (accountViewModel.isWriteable()) ({ accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }) else null,
|
||||
onKeepNew = { accountViewModel.acceptExternalBackupVersion(conflict) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupConflictReview(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onRestore: (() -> Unit)?,
|
||||
onKeepNew: () -> Unit,
|
||||
) {
|
||||
val presentation = rememberPresentation(conflict.diff)
|
||||
val ui = remember(conflict.slot) { ReviewUiState() }
|
||||
val (keepLabel, restoreLabel) = actionLabels(conflict)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(eventTitle(conflict), nav)
|
||||
},
|
||||
bottomBar = { ReviewActions(keepLabel, restoreLabel, onRestore, onKeepNew) },
|
||||
) { padding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
LazyColumn(
|
||||
// Scaffold hands the content its insets (status bar, the bottom action bar and,
|
||||
// in landscape, a side 3-button navigation bar); consume them so the IME padding
|
||||
// below only adds what the keyboard covers beyond them.
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.consumeWindowInsets(padding)
|
||||
.imePaddingSafe(),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = padding.calculateStartPadding(layoutDirection),
|
||||
top = padding.calculateTopPadding(),
|
||||
end = padding.calculateEndPadding(layoutDirection),
|
||||
bottom = padding.calculateBottomPadding() + 16.dp,
|
||||
),
|
||||
) {
|
||||
item(key = "intro", contentType = "intro") {
|
||||
BackupConflictIntro(conflict, Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
}
|
||||
eventDiffItems(conflict, presentation, ui, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The default layout: removed / added / changed sections of labelled, typed entries. */
|
||||
internal fun LazyListScope.genericDiffItems(
|
||||
rows: List<ReviewRow>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(
|
||||
rows,
|
||||
contentType = { _, row ->
|
||||
when (row) {
|
||||
is ReviewRow.Section -> "section"
|
||||
is ReviewRow.Note -> "note"
|
||||
is ReviewRow.Group -> "group"
|
||||
is ReviewRow.Entry -> row.item::class.simpleName
|
||||
}
|
||||
},
|
||||
) { _, row ->
|
||||
when (row) {
|
||||
is ReviewRow.Section -> SectionRow(row)
|
||||
is ReviewRow.Note -> DetailText("• " + stringRes(row.textRes), Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
|
||||
is ReviewRow.Group -> GroupLabel(row.labelRes)
|
||||
is ReviewRow.Entry -> ReviewEntry(row.item, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GroupLabel(labelRes: Int) {
|
||||
Text(
|
||||
text = stringRes(labelRes),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SectionRow(row: ReviewRow.Section) {
|
||||
val color =
|
||||
when (row.tone) {
|
||||
Tone.REMOVED -> MaterialTheme.colorScheme.error
|
||||
Tone.ADDED -> MaterialTheme.colorScheme.primary
|
||||
Tone.CHANGED -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringRes(row.titleRes, row.count.toString()),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = color,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewActions(
|
||||
keepLabel: String,
|
||||
restoreLabel: String,
|
||||
onRestore: (() -> Unit)?,
|
||||
onKeepNew: () -> Unit,
|
||||
) {
|
||||
Surface(tonalElevation = 3.dp) {
|
||||
Row(
|
||||
// Scaffold only insets its content slot; a bottomBar handles its own. The padding
|
||||
// goes on the content, not the Surface, so the bar's background still reaches the
|
||||
// screen edge while the buttons sit above the 3-button navigation (and above the
|
||||
// keyboard, if one is up) instead of underneath it.
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.imePaddingSafe()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// Keeping the new version is the default: it's what is on relays already. Restoring
|
||||
// always reverts another app's change, so it is the red, secondary choice.
|
||||
if (onRestore != null) {
|
||||
OutlinedButton(
|
||||
onClick = onRestore,
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text(restoreLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Button(onClick = onKeepNew, modifier = Modifier.weight(1f).height(52.dp)) {
|
||||
Text(keepLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Follow list updated", "Mute list cleared"…: the event and what happened to it, as a title. */
|
||||
@Composable
|
||||
internal fun eventTitle(conflict: ReplaceableBackupConflict): String {
|
||||
val name = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() }
|
||||
// Says what happened to it, not just what it is: "Community list updated".
|
||||
val emptied = conflict.incoming.tags.isEmpty() && conflict.incoming.content.isEmpty()
|
||||
return stringRes(if (emptied) R.string.backup_review_title_cleared else R.string.backup_review_title_updated, name)
|
||||
}
|
||||
|
||||
/** The two choices, worded for what they do to this event: "Keep 120" / "Restore 523". */
|
||||
@Composable
|
||||
private fun actionLabels(conflict: ReplaceableBackupConflict): Pair<String, String> =
|
||||
when (val diff = conflict.diff) {
|
||||
is ContactListDiff -> {
|
||||
val (saved, new) = conflict.followCounts
|
||||
stringRes(R.string.backup_action_keep_count, new.toString()) to stringRes(R.string.backup_action_restore_count, saved.toString())
|
||||
}
|
||||
is MuteListDiff -> {
|
||||
val removed = diff.publicMutes.removed.size
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
if (removed > 0) stringRes(R.string.backup_action_remute, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
|
||||
}
|
||||
is SimpleGroupListDiff -> {
|
||||
val removed = diff.groups.removed.size
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
|
||||
}
|
||||
is MetadataDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_profile)
|
||||
is RelayListDiff ->
|
||||
if (conflict.eventType == BackupEventType.BLOCKED_RELAYS && diff.relays.removed.isNotEmpty()) {
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
stringRes(
|
||||
R.string.backup_action_block_again,
|
||||
diff.relays.removed.size
|
||||
.toString(),
|
||||
)
|
||||
} else {
|
||||
stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays)
|
||||
}
|
||||
is AdvertisedRelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays)
|
||||
is ChannelListDiff -> rejoinLabels(diff.channels.removed.size)
|
||||
is CommunityListDiff -> rejoinLabels(diff.communities.removed.size)
|
||||
is EphemeralChatListDiff -> rejoinLabels(diff.rooms.removed.size)
|
||||
is FavoriteAlgoFeedsListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_feeds)
|
||||
is HashtagListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics)
|
||||
// Geohashes are places, not topics: the screen counts "places gone" under "Followed
|
||||
// locations", so the button has to agree with the page it sits on.
|
||||
is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_places)
|
||||
is TrustProviderListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_providers)
|
||||
is CashuWalletDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_wallet)
|
||||
else -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_conflict_restore_mine)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rejoinLabels(removed: Int): Pair<String, String> =
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
|
||||
|
||||
/** When the other app changed it, and what the event is for. */
|
||||
@Composable
|
||||
private fun BackupConflictIntro(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = conflictTones().removed,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(eventTypeExplainer(conflict.eventType)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
|
||||
@Composable
|
||||
internal fun ReviewEntry(
|
||||
item: ReviewItem,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
when (item) {
|
||||
is ReviewItem.Person -> PersonEntry(item, accountViewModel, nav)
|
||||
is ReviewItem.Thread ->
|
||||
LoadNote(item.eventId, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
Column(EntryPadding) {
|
||||
NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav)
|
||||
item.detail?.let { DetailText(it) }
|
||||
}
|
||||
} else {
|
||||
TextEntry(item.eventId.toShortDisplay(), item.detail)
|
||||
}
|
||||
}
|
||||
is ReviewItem.Addressable ->
|
||||
LoadAddressableNote(item.address, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
Column(EntryPadding) {
|
||||
NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav)
|
||||
item.detail?.let { DetailText(it) }
|
||||
}
|
||||
} else {
|
||||
TextEntry(item.address.dTag.ifBlank { item.address.toValue() }, item.detail)
|
||||
}
|
||||
}
|
||||
is ReviewItem.PublicChat -> PublicChatEntry(item, accountViewModel, nav)
|
||||
is ReviewItem.ChatRoom ->
|
||||
TextEntry(item.roomId.id + " @ " + item.roomId.relayUrl.url, item.detail) { nav.nav(routeFor(item.roomId)) }
|
||||
is ReviewItem.Relay -> TextEntry(item.url.url, item.detail) { nav.nav(Route.RelayInfo(item.url.url)) }
|
||||
is ReviewItem.Text -> TextEntry(item.text, item.detail)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonEntry(
|
||||
item: ReviewItem.Person,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadUser(item.pubKey, accountViewModel) { user ->
|
||||
if (user == null) {
|
||||
TextEntry(item.pubKey.toShortDisplay(), item.detail)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { nav.nav(routeFor(user)) }.then(EntryPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UserPicture(user, Size35dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Column(Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
// Subscribes to the user's metadata on relays, so unknown people resolve.
|
||||
UsernameDisplay(user, accountViewModel = accountViewModel)
|
||||
item.detail?.let { DetailText(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PublicChatEntry(
|
||||
item: ReviewItem.PublicChat,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadPublicChatChannel(item.channelId, accountViewModel) { channel ->
|
||||
// Subscribes to the channel's metadata on relays and recomposes when it arrives.
|
||||
val state by observeChannel(channel, accountViewModel)
|
||||
val name = state?.channel?.toBestDisplayName() ?: channel.toBestDisplayName()
|
||||
TextEntry(name, item.detail) { nav.nav(routeFor(channel)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TextEntry(
|
||||
text: String,
|
||||
detail: String?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val modifier = if (onClick != null) Modifier.fillMaxWidth().clickable(onClick = onClick) else Modifier.fillMaxWidth()
|
||||
Column(modifier.then(EntryPadding)) {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (onClick != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
detail?.let { DetailText(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/** The three meanings every conflict visual uses: lost, gained, edited. Theme-aware. */
|
||||
@Immutable
|
||||
class ConflictTones(
|
||||
val removed: Color,
|
||||
val added: Color,
|
||||
val changed: Color,
|
||||
val kept: Color,
|
||||
) {
|
||||
fun removedContainer() = removed.copy(alpha = 0.14f)
|
||||
|
||||
fun addedContainer() = added.copy(alpha = 0.14f)
|
||||
|
||||
fun changedContainer() = changed.copy(alpha = 0.14f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun conflictTones(): ConflictTones {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
return ConflictTones(
|
||||
removed = scheme.error,
|
||||
added = scheme.allGoodColor,
|
||||
changed = scheme.warningColor,
|
||||
kept = scheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
/** "523 → 120": the saved size against the new one, the headline of list conflicts. */
|
||||
@Composable
|
||||
fun HeroCounts(
|
||||
savedLabel: String,
|
||||
saved: Int,
|
||||
newLabel: String,
|
||||
new: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
Row(modifier, verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Column {
|
||||
Text(savedLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
Text(saved.toString(), fontSize = 44.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp))
|
||||
Column {
|
||||
Text(newLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
Text(
|
||||
new.toString(),
|
||||
fontSize = 44.sp,
|
||||
lineHeight = 44.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (new < saved) tones.removed else tones.added,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One segment of a [SplitBar]. */
|
||||
@Immutable
|
||||
class BarSegment(
|
||||
val weight: Int,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
/** A single bar split into kept / dropped / new segments, proportional to their counts. */
|
||||
@Composable
|
||||
fun SplitBar(
|
||||
segments: List<BarSegment>,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 14.dp,
|
||||
) {
|
||||
val visible = segments.filter { it.weight > 0 }
|
||||
Row(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(height / 2))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
visible.forEach {
|
||||
Box(Modifier.weight(it.weight.toFloat()).fillMaxHeight().background(it.color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A colored dot and a label: the legend under a [SplitBar]. */
|
||||
@Composable
|
||||
fun LegendItem(
|
||||
color: Color,
|
||||
text: String,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Text(text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
|
||||
/** A big signed number over a caption, on a tinted tile. */
|
||||
@Composable
|
||||
fun StatTile(
|
||||
value: String,
|
||||
caption: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(color.copy(alpha = 0.12f))
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(value, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = color)
|
||||
Text(caption, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small uppercase tag: DROPPED, NEW, RENAMED… */
|
||||
@Composable
|
||||
fun StatusTag(
|
||||
text: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
modifier = modifier.clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.14f)).padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
color = color,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/** An uppercase section caption. */
|
||||
@Composable
|
||||
fun SectionCaption(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A 3×3 color fingerprint of a key, so two keys can be told apart at a glance without
|
||||
* reading hex. Deterministic: the same key always draws the same grid.
|
||||
*/
|
||||
@Composable
|
||||
fun KeyFingerprint(
|
||||
hex: String,
|
||||
borderColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bytes = runCatching { Hex.decode(hex) }.getOrNull() ?: ByteArray(0)
|
||||
val colors =
|
||||
List(9) { i ->
|
||||
val b = if (bytes.isEmpty()) 0 else bytes[i % bytes.size].toInt() and 0xFF
|
||||
val b2 = if (bytes.isEmpty()) 0 else bytes[(i + 9) % bytes.size].toInt() and 0xFF
|
||||
Color.hsl(hue = b / 255f * 360f, saturation = 0.55f + (b2 % 30) / 100f, lightness = 0.62f)
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(borderColor)
|
||||
.padding(3.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
colors.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
row.forEach { Box(Modifier.size(24.dp).clip(RoundedCornerShape(6.dp)).background(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tinted rounded panel, the base of every hero block. */
|
||||
@Composable
|
||||
fun TintedPanel(
|
||||
tint: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(tint)
|
||||
.padding(18.dp),
|
||||
) { content() }
|
||||
}
|
||||
+68
-1
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -36,6 +38,8 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -45,6 +49,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@@ -52,6 +57,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
@@ -73,6 +79,8 @@ import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
@@ -88,6 +96,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictCards
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.NewHashtagPostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
|
||||
@@ -395,7 +404,9 @@ fun HomeFeeds(
|
||||
nav = nav,
|
||||
routeForLastRead = routeForLastRead,
|
||||
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
|
||||
onEmpty = { HomeFeedEmpty(onRefresh) },
|
||||
onEmpty = { WithBackupConflicts(accountViewModel, nav) { HomeFeedEmpty(onRefresh) } },
|
||||
onError = { WithBackupConflicts(accountViewModel, nav) { FeedError(it, onRefresh) } },
|
||||
onLoading = { WithBackupConflicts(accountViewModel, nav) { LoadingFeed() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -428,11 +439,30 @@ fun FeedLoaded(
|
||||
nav: INav,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
val backupConflicts by accountViewModel.account.settings.backupConflicts
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// The list keeps its position anchored to the first visible post, so cards that appear
|
||||
// while the feed sits at the top would be inserted just above the screen. Only then (not
|
||||
// when the user has scrolled down) bring them into view.
|
||||
val hasConflicts = backupConflicts.isNotEmpty()
|
||||
val atTop by remember(listState) { derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } }
|
||||
val cardsAppearedAtTop = remember(hasConflicts) { hasConflicts && atTop }
|
||||
LaunchedEffect(cardsAppearedAtTop) {
|
||||
if (cardsAppearedAtTop) listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
// Only while a conflict is open, so the list's first item (and with it how the
|
||||
// scroll position anchors when new posts arrive) is unchanged the rest of the time.
|
||||
if (hasConflicts) {
|
||||
item(key = "backupConflicts", contentType = "backupConflicts") {
|
||||
BackupConflictCards(backupConflicts, nav, onAccept = accountViewModel::acceptExternalBackupVersion)
|
||||
}
|
||||
}
|
||||
if (liveSection != null) {
|
||||
item {
|
||||
DisplayLiveBubbles(liveSection, accountViewModel, nav)
|
||||
@@ -513,6 +543,43 @@ fun HomeFeedEmptyPreview() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The backup-conflict cards above a feed that has no posts to show yet, so an empty or
|
||||
* still-loading Home doesn't hide the question. They get at most half the screen and scroll
|
||||
* there if there are many.
|
||||
*/
|
||||
@Composable
|
||||
private fun WithBackupConflicts(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val backupConflicts by accountViewModel.account.settings.backupConflicts
|
||||
.collectAsStateWithLifecycle()
|
||||
if (backupConflicts.isEmpty()) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
val padding = rememberFeedContentPadding(FeedPadding)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
BackupConflictCards(
|
||||
backupConflicts,
|
||||
nav,
|
||||
onAccept = accountViewModel::acceptExternalBackupVersion,
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
start = padding.calculateStartPadding(layoutDirection),
|
||||
top = padding.calculateTopPadding(),
|
||||
end = padding.calculateEndPadding(layoutDirection),
|
||||
),
|
||||
)
|
||||
Box(Modifier.weight(1f)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeFeedEmpty(onRefresh: () -> Unit) {
|
||||
Column(
|
||||
|
||||
@@ -677,4 +677,322 @@
|
||||
<string name="mute_button">Sound on. Click to mute</string>
|
||||
<string name="play">Play</string>
|
||||
<string name="pause">Pause</string>
|
||||
<string name="backup_conflict_title">You changed your %1$s in another app</string>
|
||||
<string name="backup_conflict_removed">Removed (%1$s)</string>
|
||||
<string name="backup_conflict_added">Added (%1$s)</string>
|
||||
<string name="backup_conflict_changed">Changed (%1$s)</string>
|
||||
<string name="backup_conflict_private_cleared">You removed all private (encrypted) items.</string>
|
||||
<string name="backup_conflict_private_changed">You also changed private (encrypted) items. They can\'t be compared here.</string>
|
||||
<string name="backup_conflict_private_added">You added private (encrypted) items.</string>
|
||||
<string name="backup_conflict_restore_mine">Restore saved version</string>
|
||||
<string name="backup_review_saved">On this device</string>
|
||||
<string name="backup_review_new">New version</string>
|
||||
<string name="backup_review_not_set">Not set</string>
|
||||
<string name="backup_conflict_count_removed">%1$s removed</string>
|
||||
<string name="backup_conflict_count_added">%1$s added</string>
|
||||
<string name="backup_conflict_count_changed">%1$s changed</string>
|
||||
<plurals name="backup_card_follows_dropped">
|
||||
<item quantity="one">You unfollowed %1$d person</item>
|
||||
<item quantity="other">You unfollowed %1$d people</item>
|
||||
</plurals>
|
||||
<string name="backup_card_accept">Accept</string>
|
||||
<string name="backup_review_title_updated">%1$s updated</string>
|
||||
<string name="backup_review_title_cleared">%1$s cleared</string>
|
||||
<string name="backup_review_changed_by_other_app">Changed in another app · %1$s</string>
|
||||
<string name="backup_action_keep_count">Keep %1$s</string>
|
||||
<string name="backup_action_restore_count">Restore %1$s</string>
|
||||
<string name="backup_action_remute">Re-mute %1$s</string>
|
||||
<string name="backup_action_rejoin">Rejoin %1$s</string>
|
||||
<string name="backup_action_restore_profile">Restore profile</string>
|
||||
<string name="backup_action_restore_relays">Restore relays</string>
|
||||
<string name="backup_review_kept_count">%1$s kept</string>
|
||||
<string name="backup_review_dropped_count">%1$s dropped</string>
|
||||
<string name="backup_review_new_count">%1$s new</string>
|
||||
<string name="backup_review_tab_dropped">Dropped · %1$s</string>
|
||||
<string name="backup_review_tab_new">New · %1$s</string>
|
||||
<string name="backup_review_tab_edited">Edited · %1$s</string>
|
||||
<string name="backup_review_search_people">Search %1$s people</string>
|
||||
<string name="backup_review_nothing_here">Nothing here</string>
|
||||
<string name="backup_review_unmuted">%1$s unmuted</string>
|
||||
<string name="backup_review_unmuted_explainer">These can reach your feeds again</string>
|
||||
<string name="backup_review_newly_muted">%1$s newly muted</string>
|
||||
<string name="backup_review_words_and_hashtags">Words & hashtags</string>
|
||||
<string name="backup_review_names_and_more">%1$s and %2$s more</string>
|
||||
<string name="backup_review_private_wiped_title">You removed all private items</string>
|
||||
<string name="backup_review_private_wiped_body">They\'re encrypted, so only you can see what they were. Restoring brings them all back.</string>
|
||||
<string name="backup_review_private_added_title">You made items private</string>
|
||||
<string name="backup_review_private_added_body">They\'re encrypted now, which is likely where the public items above went.</string>
|
||||
<string name="backup_review_private_changed_title">You changed private items</string>
|
||||
<string name="backup_review_private_changed_body">They\'re encrypted, so they can\'t be compared here.</string>
|
||||
<string name="backup_review_gone">Gone</string>
|
||||
<string name="backup_review_new_tag">New</string>
|
||||
<string name="backup_review_dropped_tag">Dropped</string>
|
||||
<string name="backup_review_demoted_tag">Moved</string>
|
||||
<string name="backup_review_units_tag">Units</string>
|
||||
<string name="backup_review_left_tag">Left</string>
|
||||
<string name="backup_review_renamed_tag">Renamed</string>
|
||||
<string name="backup_review_outbox">Outbox</string>
|
||||
<string name="backup_review_outbox_explainer">where you publish</string>
|
||||
<string name="backup_review_inbox">Inbox</string>
|
||||
<string name="backup_review_inbox_explainer">where others reach you</string>
|
||||
<string name="backup_review_key_replaced">Receiving key replaced</string>
|
||||
<string name="backup_review_key_removed">Receiving key removed</string>
|
||||
<string name="backup_review_key_warning">Nutzaps will be locked to this new key. If this device doesn\'t have it, you may not be able to redeem them here.</string>
|
||||
<string name="backup_review_left">left</string>
|
||||
<string name="backup_review_joined">joined</string>
|
||||
<string name="backup_review_renamed">renamed</string>
|
||||
<string name="backup_review_was_named">was “%1$s”</string>
|
||||
<string name="backup_action_block_again">Block %1$s again</string>
|
||||
<string name="backup_action_restore_feeds">Restore feeds</string>
|
||||
<string name="backup_action_restore_topics">Restore topics</string>
|
||||
<string name="backup_action_restore_places">Restore places</string>
|
||||
<string name="backup_action_restore_providers">Restore providers</string>
|
||||
<string name="backup_action_restore_wallet">Restore wallet</string>
|
||||
<string name="backup_review_kept">kept</string>
|
||||
<string name="backup_review_receiving_tag">Receiving</string>
|
||||
<string name="backup_review_listed_tag">Listed</string>
|
||||
<string name="backup_review_searching_tag">Searching</string>
|
||||
<string name="backup_review_storing_tag">Storing</string>
|
||||
<string name="backup_review_trusted_tag">Trusted</string>
|
||||
<string name="backup_review_untrusted_tag">Untrusted</string>
|
||||
<string name="backup_review_unblocked">%1$s unblocked</string>
|
||||
<string name="backup_review_unblocked_explainer">Amethyst will connect to them again</string>
|
||||
<string name="backup_review_no_longer_blocked">No longer blocked</string>
|
||||
<string name="backup_review_newly_blocked">Newly blocked</string>
|
||||
<string name="backup_review_still_blocked">Still blocked</string>
|
||||
<string name="backup_review_allowed_tag">Allowed</string>
|
||||
<string name="backup_review_moved_tag">Moved</string>
|
||||
<string name="backup_review_feed">Feed</string>
|
||||
<string name="backup_review_topics_gone">topics gone</string>
|
||||
<string name="backup_review_places_gone">places gone</string>
|
||||
<string name="backup_review_new_count_caption">new</string>
|
||||
<string name="backup_review_hashtags_you_follow">Hashtags you follow</string>
|
||||
<string name="backup_review_places_you_follow">Places you follow</string>
|
||||
<string name="backup_review_services_changed">%1$s of %2$s services changed</string>
|
||||
<string name="backup_review_services_changed_explainer">They now come from someone else, or no one</string>
|
||||
<string name="backup_review_service">Service</string>
|
||||
<string name="backup_review_service_rank">Rank</string>
|
||||
<string name="backup_review_service_followers">Followers</string>
|
||||
<string name="backup_review_service_topics">Topics</string>
|
||||
<string name="backup_review_trust_warning">Ranks, follower counts and spam filtering you see come from these providers.</string>
|
||||
<plurals name="backup_review_payment_targets_gone">
|
||||
<item quantity="one">%1$d way to pay you removed</item>
|
||||
<item quantity="other">%1$d ways to pay you removed</item>
|
||||
</plurals>
|
||||
<plurals name="backup_review_offers_gone">
|
||||
<item quantity="one">%1$d offer removed</item>
|
||||
<item quantity="other">%1$d offers removed</item>
|
||||
</plurals>
|
||||
<string name="backup_review_payments_explainer">People paying you only see what\'s in this list.</string>
|
||||
<string name="backup_review_offer">BOLT12 offer</string>
|
||||
<string name="backup_review_encrypted_cleared">You emptied it</string>
|
||||
<string name="backup_review_encrypted_added">You created it</string>
|
||||
<string name="backup_review_encrypted_changed">You changed it</string>
|
||||
<string name="backup_review_wallet_body">Your wallet\'s mints and keys are encrypted to you, so they can\'t be compared item by item here. Restoring puts back exactly what this device saved.</string>
|
||||
<string name="backup_review_concord_body">Your Concord communities are encrypted to you, so they can\'t be compared here. Restoring puts back exactly what this device saved.</string>
|
||||
<plurals name="backup_card_mutes_visible">
|
||||
<item quantity="one">You unmuted %1$d thing</item>
|
||||
<item quantity="other">You unmuted %1$d things</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_profile_lost_fields">
|
||||
<item quantity="one">You removed %1$d profile field</item>
|
||||
<item quantity="other">You removed %1$d profile fields</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_nip65_lost">
|
||||
<item quantity="one">You removed %1$d public relay</item>
|
||||
<item quantity="other">You removed %1$d public relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_dm_relays_lost">
|
||||
<item quantity="one">You removed %1$d DM relay</item>
|
||||
<item quantity="other">You removed %1$d DM relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_key_package_relays_lost">
|
||||
<item quantity="one">You removed %1$d group-chat key relay</item>
|
||||
<item quantity="other">You removed %1$d group-chat key relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_search_relays_lost">
|
||||
<item quantity="one">You removed %1$d search relay</item>
|
||||
<item quantity="other">You removed %1$d search relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_indexer_relays_lost">
|
||||
<item quantity="one">You removed %1$d indexer relay</item>
|
||||
<item quantity="other">You removed %1$d indexer relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_relay_feeds_lost">
|
||||
<item quantity="one">You removed %1$d relay feed</item>
|
||||
<item quantity="other">You removed %1$d relay feeds</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_private_outbox_lost">
|
||||
<item quantity="one">You removed %1$d private relay</item>
|
||||
<item quantity="other">You removed %1$d private relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_trusted_lost">
|
||||
<item quantity="one">You stopped trusting %1$d relay</item>
|
||||
<item quantity="other">You stopped trusting %1$d relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_unblocked">
|
||||
<item quantity="one">You unblocked %1$d relay</item>
|
||||
<item quantity="other">You unblocked %1$d relays</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_leave_chats">
|
||||
<item quantity="one">You left %1$d public chat</item>
|
||||
<item quantity="other">You left %1$d public chats</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_leave_communities">
|
||||
<item quantity="one">You left %1$d community</item>
|
||||
<item quantity="other">You left %1$d communities</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_leave_rooms">
|
||||
<item quantity="one">You left %1$d chat room</item>
|
||||
<item quantity="other">You left %1$d chat rooms</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_leave_groups">
|
||||
<item quantity="one">You left %1$d group</item>
|
||||
<item quantity="other">You left %1$d groups</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_feeds_removed">
|
||||
<item quantity="one">You removed %1$d favorite feed</item>
|
||||
<item quantity="other">You removed %1$d favorite feeds</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_hashtags_unfollowed">
|
||||
<item quantity="one">You unfollowed %1$d hashtag</item>
|
||||
<item quantity="other">You unfollowed %1$d hashtags</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_places_unfollowed">
|
||||
<item quantity="one">You unfollowed %1$d place</item>
|
||||
<item quantity="other">You unfollowed %1$d places</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_trust_services_changed">
|
||||
<item quantity="one">You changed the provider of %1$d trust service</item>
|
||||
<item quantity="other">You changed the provider of %1$d trust services</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_nutzap_mints_removed">
|
||||
<item quantity="one">You removed %1$d nutzap mint</item>
|
||||
<item quantity="other">You removed %1$d nutzap mints</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_payment_targets_removed">
|
||||
<item quantity="one">You removed %1$d way to get paid</item>
|
||||
<item quantity="other">You removed %1$d ways to get paid</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_offers_removed">
|
||||
<item quantity="one">You removed %1$d BOLT12 offer</item>
|
||||
<item quantity="other">You removed %1$d BOLT12 offers</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_and_added">
|
||||
<item quantity="one">%1$s and added %2$d</item>
|
||||
<item quantity="other">%1$s and added %2$d</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_and_followed">
|
||||
<item quantity="one">%1$s and followed %2$d</item>
|
||||
<item quantity="other">%1$s and followed %2$d</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_and_muted">
|
||||
<item quantity="one">%1$s and muted %2$d</item>
|
||||
<item quantity="other">%1$s and muted %2$d</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_and_joined">
|
||||
<item quantity="one">%1$s and joined %2$d</item>
|
||||
<item quantity="other">%1$s and joined %2$d</item>
|
||||
</plurals>
|
||||
<plurals name="backup_card_and_blocked">
|
||||
<item quantity="one">%1$s and blocked %2$d</item>
|
||||
<item quantity="other">%1$s and blocked %2$d</item>
|
||||
</plurals>
|
||||
<string name="backup_card_app_settings">Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems.</string>
|
||||
<string name="backup_card_nutzap_key_replaced">You replaced your nutzap key</string>
|
||||
<string name="backup_card_nutzap_key_removed">You removed your nutzap key</string>
|
||||
<string name="backup_card_wallet_emptied">You emptied your Cashu wallet</string>
|
||||
<string name="backup_card_wallet_rewritten">You changed your Cashu wallet</string>
|
||||
<string name="backup_card_concord_emptied">You emptied your Concord list</string>
|
||||
<string name="backup_card_concord_rewritten">You changed your Concord list</string>
|
||||
<string name="backup_conflict_keep_new">Use new version</string>
|
||||
<string name="backup_conflict_relay_read_only">read only</string>
|
||||
<string name="backup_conflict_relay_write_only">write only</string>
|
||||
<string name="backup_conflict_relay_read_write">read and write</string>
|
||||
<string name="backup_type_profile">profile</string>
|
||||
<string name="backup_type_profile_explainer">Your name, picture, bio and payment addresses, as everyone sees them.</string>
|
||||
<string name="backup_type_follow_list">follow list</string>
|
||||
<string name="backup_type_follow_list_explainer">Decides whose posts appear in your home feed.</string>
|
||||
<string name="backup_type_mute_list">mute list</string>
|
||||
<string name="backup_type_mute_list_explainer">Hides people, words, hashtags and threads from everything you see.</string>
|
||||
<string name="backup_type_outbox_inbox_relays">public relay list</string>
|
||||
<string name="backup_type_outbox_inbox_relays_explainer">Tells everyone where to find your posts and where to send you replies and mentions.</string>
|
||||
<string name="backup_type_dm_relays">DM relay list</string>
|
||||
<string name="backup_type_dm_relays_explainer">Where others deliver your private messages.</string>
|
||||
<string name="backup_type_key_package_relays">group-chat key relays</string>
|
||||
<string name="backup_type_key_package_relays_explainer">Where others find the keys to add you to encrypted group chats.</string>
|
||||
<string name="backup_type_search_relays">search relay list</string>
|
||||
<string name="backup_type_search_relays_explainer">The relays used when you search.</string>
|
||||
<string name="backup_type_indexer_relays">indexer relay list</string>
|
||||
<string name="backup_type_indexer_relays_explainer">The relays used to find other people\'s profiles and relay lists.</string>
|
||||
<string name="backup_type_relay_feeds">favorite relay feeds</string>
|
||||
<string name="backup_type_relay_feeds_explainer">The relays you browse as feeds.</string>
|
||||
<string name="backup_type_blocked_relays">blocked relay list</string>
|
||||
<string name="backup_type_blocked_relays_explainer">Relays this app never connects to.</string>
|
||||
<string name="backup_type_trusted_relays">trusted relay list</string>
|
||||
<string name="backup_type_trusted_relays_explainer">Relays you trust to connect to without asking.</string>
|
||||
<string name="backup_type_private_outbox_relays">private relay list</string>
|
||||
<string name="backup_type_private_outbox_relays_explainer">Where your drafts and private data are stored.</string>
|
||||
<string name="backup_type_app_settings">synced app settings</string>
|
||||
<string name="backup_type_app_settings_explainer">Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared.</string>
|
||||
<string name="backup_type_public_chats">public chat list</string>
|
||||
<string name="backup_type_public_chats_explainer">The public chats you joined.</string>
|
||||
<string name="backup_type_communities">community list</string>
|
||||
<string name="backup_type_communities_explainer">The communities you joined.</string>
|
||||
<string name="backup_type_hashtags">followed hashtags</string>
|
||||
<string name="backup_type_hashtags_explainer">Hashtags whose posts appear in your feeds.</string>
|
||||
<string name="backup_type_geohashes">followed locations</string>
|
||||
<string name="backup_type_geohashes_explainer">Locations whose posts appear in your feeds.</string>
|
||||
<string name="backup_type_favorite_algo_feeds">favorite feeds list</string>
|
||||
<string name="backup_type_favorite_algo_feeds_explainer">The algorithmic feeds you saved.</string>
|
||||
<string name="backup_type_ephemeral_chats">ephemeral chat list</string>
|
||||
<string name="backup_type_ephemeral_chats_explainer">The ephemeral chat rooms you joined.</string>
|
||||
<string name="backup_type_relay_groups">group list</string>
|
||||
<string name="backup_type_relay_groups_explainer">The relay-based groups you joined.</string>
|
||||
<string name="backup_type_concord_communities">Concord community list</string>
|
||||
<string name="backup_type_concord_communities_explainer">The Concord communities you joined. They are encrypted, so only their presence can be compared.</string>
|
||||
<string name="backup_type_trust_providers">trust provider list</string>
|
||||
<string name="backup_type_trust_providers_explainer">The services you trust to rank and verify people for you.</string>
|
||||
<string name="backup_type_cashu_wallet">Cashu wallet</string>
|
||||
<string name="backup_type_cashu_wallet_explainer">Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared.</string>
|
||||
<string name="backup_type_nutzap_info">nutzap receiving settings</string>
|
||||
<string name="backup_type_nutzap_info_explainer">The mints and key others use to send you nutzaps.</string>
|
||||
<string name="backup_type_payment_targets">payment addresses</string>
|
||||
<string name="backup_type_payment_targets_explainer">The ways other people can pay you.</string>
|
||||
<string name="backup_type_bolt12_offers">BOLT12 offers</string>
|
||||
<string name="backup_type_bolt12_offers_explainer">The lightning offers others use to pay you.</string>
|
||||
<string name="backup_type_other">list</string>
|
||||
<string name="backup_type_other_explainer">A list saved on this device.</string>
|
||||
<string name="backup_entry_profile_field">Profile fields</string>
|
||||
<string name="backup_entry_person">People</string>
|
||||
<string name="backup_entry_hashtag">Hashtags</string>
|
||||
<string name="backup_entry_word">Words</string>
|
||||
<string name="backup_entry_thread">Threads</string>
|
||||
<string name="backup_entry_public_chat">Public chats</string>
|
||||
<string name="backup_entry_community">Communities</string>
|
||||
<string name="backup_entry_algo_feed">Feeds</string>
|
||||
<string name="backup_entry_location">Locations</string>
|
||||
<string name="backup_entry_relay">Relays</string>
|
||||
<string name="backup_entry_relay_group">Groups</string>
|
||||
<string name="backup_entry_chat_room">Chat rooms</string>
|
||||
<string name="backup_entry_trust_provider">Trust providers</string>
|
||||
<string name="backup_entry_mint">Mints</string>
|
||||
<string name="backup_entry_nutzap_key">Nutzap keys</string>
|
||||
<string name="backup_entry_payment_target">Payment addresses</string>
|
||||
<string name="backup_entry_offer">Offers</string>
|
||||
<string name="backup_entry_identity_claim">Linked identities</string>
|
||||
<string name="backup_entry_other">Other entries</string>
|
||||
<string name="backup_profile_field_name">Username</string>
|
||||
<string name="backup_profile_field_display_name">Display name</string>
|
||||
<string name="backup_profile_field_about">About</string>
|
||||
<string name="backup_profile_field_picture">Picture</string>
|
||||
<string name="backup_profile_field_banner">Banner</string>
|
||||
<string name="backup_profile_field_website">Website</string>
|
||||
<string name="backup_profile_field_nip05">Nostr address</string>
|
||||
<string name="backup_profile_field_lud16">Lightning address</string>
|
||||
<string name="backup_profile_field_lud06">LNURL</string>
|
||||
<string name="backup_profile_field_pronouns">Pronouns</string>
|
||||
<string name="backup_profile_field_birthday">Birthday</string>
|
||||
<string name="backup_profile_field_clink_offer">CLINK offer</string>
|
||||
<string name="backup_profile_field_bot">Bot flag</string>
|
||||
</resources>
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.backups
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* An undecided conflict has to survive the process that raised it. Before this, it lived only in
|
||||
* a `MutableStateFlow`, so a restart answered the user's question for them by dropping it.
|
||||
*/
|
||||
class BackupConflictStorageTest {
|
||||
private val signer = NostrSignerSync(KeyPair())
|
||||
|
||||
private fun sign(
|
||||
kind: Int,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String = "",
|
||||
): Event = signer.sign<Event>(createdAt, kind, tags, content)
|
||||
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
private val carol = "c".repeat(64)
|
||||
|
||||
private fun savedAndIncoming(): Pair<Event, Event> =
|
||||
sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) to
|
||||
sign(3, 200, arrayOf(arrayOf("p", carol)))
|
||||
|
||||
@Test
|
||||
fun survivesARoundTrip() {
|
||||
val (saved, incoming) = savedAndIncoming()
|
||||
|
||||
val restored = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, incoming, incoming))))
|
||||
|
||||
assertEquals(1, restored.size)
|
||||
val (restoredSaved, restoredIncoming, restoredCause) = restored.first()
|
||||
assertEquals(saved.id, restoredSaved.id)
|
||||
assertEquals(incoming.id, restoredIncoming.id)
|
||||
assertEquals(incoming.id, restoredCause.id)
|
||||
// The events must come back whole, not as ids: the review screen diffs their contents.
|
||||
assertEquals(2, restoredSaved.tags.count { it.firstOrNull() == "p" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRestoredPairStillDescribesTheSameLoss() {
|
||||
val (saved, incoming) = savedAndIncoming()
|
||||
|
||||
val (restoredSaved, restoredIncoming, _) = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, incoming, incoming)))).first()
|
||||
|
||||
// The diff is recomputed rather than stored, so it has to still be derivable.
|
||||
val diff = ReplaceableBackupDiff.detectLoss(restoredSaved, restoredIncoming)
|
||||
assertNotNull(diff)
|
||||
assertTrue(diff!!.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVersionThatNoLongerRemovesAnythingStopsBeingAConflict() {
|
||||
val saved = sign(3, 100, arrayOf(arrayOf("p", alice)))
|
||||
val putBack = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob)))
|
||||
|
||||
val (restoredSaved, restoredIncoming, _) = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, putBack, putBack)))).first()
|
||||
|
||||
assertNull(ReplaceableBackupDiff.detectLoss(restoredSaved, restoredIncoming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun severalSlotsAreKeptApart() {
|
||||
val (saved, incoming) = savedAndIncoming()
|
||||
val mutesSaved = sign(10000, 100, arrayOf(arrayOf("p", alice)))
|
||||
val mutesIncoming = sign(10000, 200, arrayOf())
|
||||
|
||||
val restored =
|
||||
BackupConflictStorage.decode(
|
||||
BackupConflictStorage.encode(
|
||||
listOf(Triple(saved, incoming, incoming), Triple(mutesSaved, mutesIncoming, mutesIncoming)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(2, restored.size)
|
||||
assertEquals(listOf(3, 10000), restored.map { it.second.kind })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nothingStoredMeansNoConflicts() {
|
||||
assertTrue(BackupConflictStorage.decode(null).isEmpty())
|
||||
assertTrue(BackupConflictStorage.decode("").isEmpty())
|
||||
assertTrue(BackupConflictStorage.encode(emptyList()).let { BackupConflictStorage.decode(it) }.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneUnreadableEntryDoesNotTakeTheOthersWithIt() {
|
||||
val (saved, incoming) = savedAndIncoming()
|
||||
// A row whose events are not parseable, next to a good one.
|
||||
val mixed =
|
||||
JsonMapper.toJson(
|
||||
listOf(
|
||||
listOf("not an event", "not an event", "not an event"),
|
||||
listOf(
|
||||
OptimizedJsonMapper.toJson(saved),
|
||||
OptimizedJsonMapper.toJson(incoming),
|
||||
OptimizedJsonMapper.toJson(incoming),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val restored = BackupConflictStorage.decode(mixed)
|
||||
|
||||
assertEquals(1, restored.size)
|
||||
assertEquals(incoming.id, restored.first().second.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageIsNotAConflict() {
|
||||
assertTrue(BackupConflictStorage.decode("not json at all").isEmpty())
|
||||
}
|
||||
}
|
||||
+75
@@ -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))
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.backups
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Stands in front of an account's replaceable-event backups and decides, for each newer
|
||||
* version of a backed-up event, whether it may overwrite the saved copy or must be held back
|
||||
* and questioned as a [ReplaceableBackupConflict].
|
||||
*
|
||||
* Headless and platform-free so the rules can be unit-tested; the account's settings own one
|
||||
* and call [accept] from every backup update.
|
||||
*
|
||||
* [isLocallySigned] tells versions this app produced from ones that came from relays; it
|
||||
* defaults to [LocallySignedEvents]. [onChange] runs whenever the set of open conflicts
|
||||
* changes, so the owner can persist it ([open] / [restore]). [reapply] hands an event back to
|
||||
* the update that owns its slot: a conflict restored from storage has no retry closure until
|
||||
* its version arrives again, and keeping it must work regardless.
|
||||
*/
|
||||
class BackupConflictGuard(
|
||||
private val isLocallySigned: (HexKey) -> Boolean = LocallySignedEvents::contains,
|
||||
private val onChange: () -> Unit = {},
|
||||
private val reapply: (Event) -> Unit = {},
|
||||
) {
|
||||
private val _conflicts = MutableStateFlow<Map<String, ReplaceableBackupConflict>>(emptyMap())
|
||||
|
||||
/**
|
||||
* Newer versions of backed-up events that came from another client and dropped data the
|
||||
* backup still has, keyed by [ReplaceableBackupConflict.slot]. While a slot is in here its
|
||||
* backup is frozen on the saved version, so the user can still restore it. They outlive
|
||||
* the process through [open] / [restore]: an undecided question must still be there on the
|
||||
* next launch.
|
||||
*/
|
||||
val conflicts: StateFlow<Map<String, ReplaceableBackupConflict>> = _conflicts
|
||||
|
||||
// Everything below is only touched under the lock: the backup collectors run on IO
|
||||
// threads while the user resolves conflicts from the UI thread.
|
||||
private val lock = KmpLock()
|
||||
|
||||
// Re-runs the update that raised each open conflict, once the user keeps the new version.
|
||||
private val retries = HashMap<String, () -> Unit>()
|
||||
|
||||
// Versions whose conflict is being restored. They must not raise the same conflict again
|
||||
// while the saved version is re-signed.
|
||||
private val resolving = HashSet<HexKey>()
|
||||
|
||||
// Externally signed versions the user explicitly chose to keep over the backup.
|
||||
private val accepted = HashSet<HexKey>()
|
||||
|
||||
// Slots with a restore in flight, and those whose backup moved on meanwhile: a failed
|
||||
// restore must not reopen a conflict about a version that is no longer the newest.
|
||||
private val restoringSlots = HashSet<String>()
|
||||
private val movedOnWhileRestoring = HashSet<String>()
|
||||
|
||||
/**
|
||||
* Decides whether [incoming] may overwrite the [saved] backup. Versions signed by this app
|
||||
* always may. A newer version signed elsewhere that removed items from the saved one is
|
||||
* held back and raised as a [ReplaceableBackupConflict]; [retry] re-runs the caller's
|
||||
* update once the user keeps it.
|
||||
*
|
||||
* An [isEmpty] version (a wiped list) always reaches the diff, so wiping everything is
|
||||
* questioned like any other loss; it only becomes the backup when this app signed it or
|
||||
* the user chose to keep it.
|
||||
*/
|
||||
fun <T : Event> accept(
|
||||
saved: T?,
|
||||
incoming: T,
|
||||
isEmpty: Boolean = false,
|
||||
retry: () -> Unit,
|
||||
): Boolean =
|
||||
lock.withLock {
|
||||
if (saved?.id == incoming.id) return@withLock false
|
||||
if (incoming.id in resolving) return@withLock false
|
||||
// Never roll the backup back, e.g. by the retry of a conflict whose version has
|
||||
// been overtaken since it was raised.
|
||||
if (saved != null && incoming.createdAt < saved.createdAt) return@withLock false
|
||||
|
||||
val slot = backupSlot(incoming)
|
||||
val pending = _conflicts.value[slot]
|
||||
|
||||
if (pending != null) {
|
||||
// The same version re-emitted by the cache: the open conflict already describes it.
|
||||
// Its retry is refreshed too: a conflict restored from storage carries none
|
||||
// until the version it is about comes round again.
|
||||
if (pending.incoming.id == incoming.id) {
|
||||
retries[slot] = retry
|
||||
return@withLock false
|
||||
}
|
||||
// An older version (e.g. the retry of a just-accepted conflict that a newer
|
||||
// external change overtook) must never replace or clear the newer question.
|
||||
if (incoming.createdAt < pending.incoming.createdAt) return@withLock false
|
||||
}
|
||||
|
||||
val isLocal = isLocallySigned(incoming.id)
|
||||
val wasAccepted = incoming.id in accepted
|
||||
|
||||
// While a conflict is open, even edits made here are built on top of the external
|
||||
// version, so they are still compared with the version the user may want to restore.
|
||||
val reference = pending?.saved ?: saved?.takeUnless { isLocal }
|
||||
|
||||
if (reference != null && !wasAccepted) {
|
||||
val diff = ReplaceableBackupDiff.detectLoss(reference, incoming)
|
||||
if (diff != null) {
|
||||
// A local edit keeps the external change as the conflict's cause.
|
||||
val cause = if (isLocal && pending != null) pending.cause else incoming
|
||||
retries[slot] = retry
|
||||
_conflicts.update { it + (slot to ReplaceableBackupConflict(reference, incoming, cause, diff)) }
|
||||
onChange()
|
||||
return@withLock false
|
||||
}
|
||||
}
|
||||
|
||||
dropLocked(slot)
|
||||
// A wipe from elsewhere that lost nothing (e.g. the backup was empty too) doesn't
|
||||
// become the backup; one signed here or explicitly kept does.
|
||||
val stored = !isEmpty || isLocal || wasAccepted
|
||||
if (stored) {
|
||||
accepted.remove(incoming.id)
|
||||
if (slot in restoringSlots) movedOnWhileRestoring.add(slot)
|
||||
}
|
||||
stored
|
||||
}
|
||||
|
||||
/** Forgets the open conflict of [slot], e.g. because its event was deleted. */
|
||||
fun drop(slot: String) {
|
||||
lock.withLock { dropLocked(slot) }
|
||||
}
|
||||
|
||||
private fun dropLocked(slot: String) {
|
||||
if (_conflicts.value.containsKey(slot)) {
|
||||
_conflicts.update { it - slot }
|
||||
onChange()
|
||||
}
|
||||
retries.remove(slot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seeds the open conflicts read back from storage, as (saved, incoming, cause).
|
||||
*
|
||||
* Their diff is recomputed rather than stored, so a version that no longer removes
|
||||
* anything (the other app put it back) quietly stops being a conflict.
|
||||
*/
|
||||
fun restore(saved: List<Triple<Event, Event, Event>>) {
|
||||
if (saved.isEmpty()) return
|
||||
val restored =
|
||||
saved.mapNotNull { (savedEvent, incoming, cause) ->
|
||||
val diff = ReplaceableBackupDiff.detectLoss(savedEvent, incoming) ?: return@mapNotNull null
|
||||
val conflict = ReplaceableBackupConflict(savedEvent, incoming, cause, diff)
|
||||
conflict.slot to conflict
|
||||
}
|
||||
if (restored.isEmpty()) return
|
||||
lock.withLock { _conflicts.update { it + restored } }
|
||||
}
|
||||
|
||||
/** The open conflicts, as the three events storage needs to rebuild each one. */
|
||||
fun open(): List<Triple<Event, Event, Event>> = _conflicts.value.values.map { Triple(it.saved, it.incoming, it.cause) }
|
||||
|
||||
/**
|
||||
* Removes [conflict] if it is still the open conflict of its slot and returns its retry.
|
||||
* Returns null when it was already resolved or replaced by a newer one, so callers never
|
||||
* act on a stale conflict.
|
||||
*/
|
||||
private fun claimLocked(conflict: ReplaceableBackupConflict): (() -> Unit)? {
|
||||
if (_conflicts.value[conflict.slot] !== conflict) return null
|
||||
_conflicts.update { it - conflict.slot }
|
||||
onChange()
|
||||
// A conflict restored from storage has no closure to re-run: rebuild it from the event.
|
||||
// Falling back to {} instead would clear the card and quietly keep the old backup.
|
||||
return retries.remove(conflict.slot) ?: { reapply(conflict.incoming) }
|
||||
}
|
||||
|
||||
/** Keeps the new version: the backup moves forward to [ReplaceableBackupConflict.incoming]. */
|
||||
fun keepIncoming(conflict: ReplaceableBackupConflict) {
|
||||
val retry =
|
||||
lock.withLock {
|
||||
val claimed = claimLocked(conflict) ?: return
|
||||
accepted.add(conflict.incoming.id)
|
||||
claimed
|
||||
}
|
||||
// Outside the lock: the retry re-enters accept.
|
||||
retry()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts restoring the saved version: claims [conflict] and stops its incoming version
|
||||
* from raising it again while the saved one is re-signed. Returns null when the conflict
|
||||
* is stale (already resolved, replaced or dropped); otherwise a token to hand back to
|
||||
* [finishRestoring].
|
||||
*/
|
||||
fun startRestoring(conflict: ReplaceableBackupConflict): (() -> Unit)? =
|
||||
lock.withLock {
|
||||
val retry = claimLocked(conflict) ?: return@withLock null
|
||||
resolving.add(conflict.incoming.id)
|
||||
restoringSlots.add(conflict.slot)
|
||||
retry
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a restore. On failure the conflict is reopened so the user can try again, unless
|
||||
* something else (a newer change, a deletion) took the slot meanwhile.
|
||||
*/
|
||||
fun finishRestoring(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
token: () -> Unit,
|
||||
restored: Boolean,
|
||||
) {
|
||||
lock.withLock {
|
||||
resolving.remove(conflict.incoming.id)
|
||||
restoringSlots.remove(conflict.slot)
|
||||
val movedOn = movedOnWhileRestoring.remove(conflict.slot)
|
||||
if (restored || movedOn || _conflicts.value.containsKey(conflict.slot)) return@withLock
|
||||
_conflicts.update { it + (conflict.slot to conflict) }
|
||||
retries[conflict.slot] = token
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.backups
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
|
||||
|
||||
/**
|
||||
* Ids of the replaceable/addressable events this app instance produced (signed here, or
|
||||
* restored from its own on-disk backup) since the process started.
|
||||
*
|
||||
* The backup guard uses it to tell "the user edited this list in Amethyst" from "a newer
|
||||
* version came in from a relay", i.e. from another client. Only the latter can silently
|
||||
* wipe data another app did not know about, so only the latter is ever questioned.
|
||||
*
|
||||
* Fed from the cache's `justConsumeMyOwnEvent` choke point, which every local publish goes
|
||||
* through. Only replaceable and addressable kinds are tracked, and the ids live in a bounded
|
||||
* LRU: only the latest few versions of each list matter, since an older version is already
|
||||
* superseded by the time the backup guard sees a newer one.
|
||||
*/
|
||||
object LocallySignedEvents {
|
||||
private const val MAX_TRACKED = 500
|
||||
|
||||
// androidx LruCache synchronizes internally, so the relay and UI threads can share it.
|
||||
private val ids = LruCache<HexKey, Boolean>(MAX_TRACKED)
|
||||
|
||||
fun mark(event: Event) {
|
||||
if (event.kind.isReplaceable() || event.kind.isAddressable()) {
|
||||
ids.put(event.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun contains(eventId: HexKey): Boolean = ids[eventId] != null
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.backups
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
|
||||
/** Which of the account's backed-up events a conflict is about. Drives the dialog's wording. */
|
||||
enum class BackupEventType {
|
||||
PROFILE,
|
||||
FOLLOW_LIST,
|
||||
MUTE_LIST,
|
||||
OUTBOX_INBOX_RELAYS,
|
||||
DM_RELAYS,
|
||||
KEY_PACKAGE_RELAYS,
|
||||
SEARCH_RELAYS,
|
||||
INDEXER_RELAYS,
|
||||
RELAY_FEEDS,
|
||||
BLOCKED_RELAYS,
|
||||
TRUSTED_RELAYS,
|
||||
PRIVATE_OUTBOX_RELAYS,
|
||||
APP_SETTINGS,
|
||||
PUBLIC_CHATS,
|
||||
COMMUNITIES,
|
||||
HASHTAGS,
|
||||
GEOHASHES,
|
||||
FAVORITE_ALGO_FEEDS,
|
||||
EPHEMERAL_CHATS,
|
||||
RELAY_GROUPS,
|
||||
CONCORD_COMMUNITIES,
|
||||
TRUST_PROVIDERS,
|
||||
CASHU_WALLET,
|
||||
NUTZAP_INFO,
|
||||
PAYMENT_TARGETS,
|
||||
BOLT12_OFFERS,
|
||||
OTHER,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun of(kind: Int): BackupEventType =
|
||||
when (kind) {
|
||||
MetadataEvent.KIND -> PROFILE
|
||||
ContactListEvent.KIND -> FOLLOW_LIST
|
||||
MuteListEvent.KIND -> MUTE_LIST
|
||||
AdvertisedRelayListEvent.KIND -> OUTBOX_INBOX_RELAYS
|
||||
ChatMessageRelayListEvent.KIND -> DM_RELAYS
|
||||
KeyPackageRelayListEvent.KIND -> KEY_PACKAGE_RELAYS
|
||||
SearchRelayListEvent.KIND -> SEARCH_RELAYS
|
||||
IndexerRelayListEvent.KIND -> INDEXER_RELAYS
|
||||
RelayFeedsListEvent.KIND -> RELAY_FEEDS
|
||||
BlockedRelayListEvent.KIND -> BLOCKED_RELAYS
|
||||
TrustedRelayListEvent.KIND -> TRUSTED_RELAYS
|
||||
PrivateOutboxRelayListEvent.KIND -> PRIVATE_OUTBOX_RELAYS
|
||||
AppSpecificDataEvent.KIND -> APP_SETTINGS
|
||||
ChannelListEvent.KIND -> PUBLIC_CHATS
|
||||
CommunityListEvent.KIND -> COMMUNITIES
|
||||
HashtagListEvent.KIND -> HASHTAGS
|
||||
GeohashListEvent.KIND -> GEOHASHES
|
||||
FavoriteAlgoFeedsListEvent.KIND -> FAVORITE_ALGO_FEEDS
|
||||
EphemeralChatListEvent.KIND -> EPHEMERAL_CHATS
|
||||
SimpleGroupListEvent.KIND -> RELAY_GROUPS
|
||||
ConcordCommunityListEvent.KIND -> CONCORD_COMMUNITIES
|
||||
TrustProviderListEvent.KIND -> TRUST_PROVIDERS
|
||||
CashuWalletEvent.KIND -> CASHU_WALLET
|
||||
NutzapInfoEvent.KIND -> NUTZAP_INFO
|
||||
PaymentTargetsEvent.KIND -> PAYMENT_TARGETS
|
||||
Bolt12OfferListEvent.KIND -> BOLT12_OFFERS
|
||||
else -> OTHER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A newer version of one of the account's backed-up replaceable events arrived from outside
|
||||
* this app and dropped data the saved version had. The backup keeps [saved] until the user
|
||||
* either accepts [incoming] or re-signs [saved] on top of it.
|
||||
*
|
||||
* @property incoming the current version: what "use new version" keeps. Edits made in this
|
||||
* app while the conflict is open are built on top of the external version, so they replace
|
||||
* [incoming] but keep the conflict open.
|
||||
* @property cause the version from the other app that raised the conflict. It stays the same
|
||||
* while local edits move [incoming] forward, so the dialog keeps describing the external change.
|
||||
* @property diff what [incoming] lost compared with [saved].
|
||||
*/
|
||||
@Immutable
|
||||
class ReplaceableBackupConflict(
|
||||
val saved: Event,
|
||||
val incoming: Event,
|
||||
val cause: Event,
|
||||
val diff: EventDiff,
|
||||
) {
|
||||
/** One conflict per replaceable slot: kind for replaceables, kind + d-tag for addressables. */
|
||||
val slot: String = backupSlot(incoming)
|
||||
|
||||
val eventType: BackupEventType get() = BackupEventType.of(incoming.kind)
|
||||
|
||||
/**
|
||||
* Distinct people followed by the saved and the incoming version, for follow-list
|
||||
* conflicts (0 otherwise). Parsed once: a follow list can have thousands of entries, and
|
||||
* the Home card, the review screen and its buttons all show these counts.
|
||||
*/
|
||||
val followCounts: Pair<Int, Int> by lazy { uniqueFollows(saved) to uniqueFollows(incoming) }
|
||||
|
||||
private fun uniqueFollows(event: Event) = (event as? ContactListEvent)?.follows()?.mapTo(HashSet()) { it.pubKey }?.size ?: 0
|
||||
}
|
||||
|
||||
fun backupSlot(event: Event): String = backupSlotOf(event.kind, event.dTag())
|
||||
|
||||
fun backupSlotOf(
|
||||
kind: Int,
|
||||
dTag: String = "",
|
||||
): String = "$kind:$dTag"
|
||||
|
||||
object ReplaceableBackupDiff {
|
||||
/**
|
||||
* What a newer [incoming] version of a backed-up event removed from the [saved] one, as
|
||||
* computed by the event itself ([DiffableEvent.diffFrom]), or null when it removed
|
||||
* nothing: it only added or edited entries, which means the other app did read the
|
||||
* previous version. Events that can't diff themselves never raise a conflict.
|
||||
*/
|
||||
fun detectLoss(
|
||||
saved: Event,
|
||||
incoming: Event,
|
||||
): EventDiff? {
|
||||
if (saved.id == incoming.id) return null
|
||||
// Older or same-age versions never replace the backup in LocalCache anyway.
|
||||
if (incoming.createdAt <= saved.createdAt) return null
|
||||
val diffable = incoming as? DiffableEvent<*> ?: return null
|
||||
return diffable.diffFrom(saved)?.takeIf { it.removesData() }
|
||||
}
|
||||
}
|
||||
|
||||
object BackupRestore {
|
||||
/**
|
||||
* The tags to re-sign when restoring [saved] over a newer version. Metadata that only
|
||||
* described the old signing is dropped, not the user's data:
|
||||
* - `client`: the app that signs now adds its own (it won't when one is already there);
|
||||
* - `nonce`: NIP-13 proof of work is tied to the old id and would be a false claim;
|
||||
* - `expiration` already in the past: relays would reject or drop the restored version.
|
||||
*/
|
||||
fun tagsToResign(
|
||||
saved: Event,
|
||||
now: Long,
|
||||
): Array<Array<String>> =
|
||||
saved.tags
|
||||
.filterNot { tag ->
|
||||
when (tag.getOrNull(0)) {
|
||||
ClientTag.TAG_NAME, PoWTag.TAG_NAME -> true
|
||||
ExpirationTag.TAG_NAME -> ExpirationTag.parse(tag)?.let { it <= now } ?: true
|
||||
else -> false
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
+5
-1
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.RelayGroupTargetCandidate
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.UserContext
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
|
||||
@@ -3383,7 +3384,10 @@ open class EventCache :
|
||||
justConsume(event, null, false)
|
||||
}
|
||||
|
||||
override fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true)
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean {
|
||||
LocallySignedEvents.mark(event)
|
||||
return justConsumeAndUpdateIndexes(event, null, true)
|
||||
}
|
||||
|
||||
fun justConsume(
|
||||
event: Event,
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.backups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The rules that decide whether a newer version of a backed-up event may overwrite the
|
||||
* backup, and how the user's answers resolve the conflicts it raises.
|
||||
*/
|
||||
class BackupConflictGuardTest {
|
||||
private val signer = NostrSignerSync(KeyPair())
|
||||
private val local = HashSet<HexKey>()
|
||||
private var changes = 0
|
||||
private val reapplied = ArrayList<Event>()
|
||||
private val guard =
|
||||
BackupConflictGuard(
|
||||
isLocallySigned = { it in local },
|
||||
onChange = { changes++ },
|
||||
reapply = { reapplied.add(it) },
|
||||
)
|
||||
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
private val carol = "c".repeat(64)
|
||||
|
||||
private fun follows(
|
||||
createdAt: Long,
|
||||
vararg pubKeys: String,
|
||||
): Event = signer.sign(createdAt, 3, pubKeys.map { arrayOf("p", it) }.toTypedArray(), "")
|
||||
|
||||
private fun signedHere(event: Event) = event.also { local.add(it.id) }
|
||||
|
||||
private fun openConflict() =
|
||||
guard.conflicts.value.values
|
||||
.single()
|
||||
|
||||
/** A backup slot the way AccountSettings drives it: the guard in front of a stored copy. */
|
||||
private inner class Backup(
|
||||
var saved: Event?,
|
||||
) {
|
||||
var saves = 0
|
||||
|
||||
fun update(incoming: Event) {
|
||||
if (guard.accept(saved, incoming, isEmpty = incoming.tags.isEmpty()) { update(incoming) }) {
|
||||
saved = incoming
|
||||
saves++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionsSignedHereAlwaysReplaceTheBackup() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
val edit = signedHere(follows(200, bob))
|
||||
backup.update(edit)
|
||||
assertSame(edit, backup.saved)
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalVersionThatOnlyAddsReplacesTheBackup() {
|
||||
val backup = Backup(follows(100, alice))
|
||||
val external = follows(200, alice, bob)
|
||||
backup.update(external)
|
||||
assertSame(external, backup.saved)
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstVersionWithNothingSavedIsAccepted() {
|
||||
val backup = Backup(null)
|
||||
val external = follows(200, alice)
|
||||
backup.update(external)
|
||||
assertSame(external, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalLossyVersionFreezesTheBackupAndRaisesAConflict() {
|
||||
val saved = follows(100, alice, bob)
|
||||
val backup = Backup(saved)
|
||||
val external = follows(200, bob)
|
||||
backup.update(external)
|
||||
|
||||
assertSame(saved, backup.saved)
|
||||
val conflict = openConflict()
|
||||
assertSame(saved, conflict.saved)
|
||||
assertSame(external, conflict.incoming)
|
||||
assertSame(external, conflict.cause)
|
||||
assertEquals(backupSlot(external), conflict.slot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSameVersionReEmittedKeepsTheSameConflict() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
val external = follows(200, bob)
|
||||
backup.update(external)
|
||||
val first = openConflict()
|
||||
backup.update(external)
|
||||
assertSame(first, openConflict())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOlderVersionNeverReplacesAnOpenConflict() {
|
||||
val backup = Backup(follows(100, alice, bob, carol))
|
||||
val newer = follows(300, carol)
|
||||
backup.update(newer)
|
||||
backup.update(follows(200, bob, carol))
|
||||
assertSame(newer, openConflict().incoming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewerExternalVersionIsStillComparedWithTheFrozenBackup() {
|
||||
val saved = follows(100, alice, bob)
|
||||
val backup = Backup(saved)
|
||||
backup.update(follows(200, bob))
|
||||
// Adds to the lossy version, but still lacks alice from the saved one.
|
||||
val newer = follows(300, bob, carol)
|
||||
backup.update(newer)
|
||||
|
||||
val conflict = openConflict()
|
||||
assertSame(saved, conflict.saved)
|
||||
assertSame(newer, conflict.incoming)
|
||||
assertSame(saved, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLocalEditOnTopOfTheLossyVersionKeepsTheConflictAndItsCause() {
|
||||
val saved = follows(100, alice, bob)
|
||||
val backup = Backup(saved)
|
||||
val external = follows(200, bob)
|
||||
backup.update(external)
|
||||
|
||||
val edit = signedHere(follows(300, bob, carol))
|
||||
backup.update(edit)
|
||||
|
||||
val conflict = openConflict()
|
||||
assertSame(edit, conflict.incoming)
|
||||
assertSame(external, conflict.cause)
|
||||
assertSame(saved, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLocalEditThatBringsEverythingBackClearsTheConflict() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
backup.update(follows(200, bob))
|
||||
|
||||
val edit = signedHere(follows(300, alice, bob))
|
||||
backup.update(edit)
|
||||
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
assertSame(edit, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepingTheNewVersionMovesTheBackupForward() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
val external = follows(200, bob)
|
||||
backup.update(external)
|
||||
|
||||
guard.keepIncoming(openConflict())
|
||||
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
assertSame(external, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleOrRepeatedAnswerDoesNothing() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
backup.update(follows(200, bob))
|
||||
val stale = openConflict()
|
||||
val newer = follows(300)
|
||||
backup.update(newer)
|
||||
|
||||
guard.keepIncoming(stale)
|
||||
assertSame(newer, openConflict().incoming)
|
||||
assertNull(guard.startRestoring(stale))
|
||||
|
||||
val current = openConflict()
|
||||
guard.keepIncoming(current)
|
||||
guard.keepIncoming(current)
|
||||
assertEquals(1, backup.saves)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRetryOfAnOvertakenAcceptCannotClearTheNewerConflict() {
|
||||
val backup = Backup(follows(100, alice, bob, carol))
|
||||
val first = follows(200, bob, carol)
|
||||
backup.update(first)
|
||||
val claimed = openConflict()
|
||||
|
||||
// A newer lossy change arrives, then the retry of the (stale) first one runs.
|
||||
val newer = follows(300, carol)
|
||||
backup.update(newer)
|
||||
guard.keepIncoming(claimed)
|
||||
backup.update(first)
|
||||
|
||||
assertSame(newer, openConflict().incoming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whileRestoringTheLossyVersionCannotReRaiseTheConflict() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
val external = follows(200, bob)
|
||||
backup.update(external)
|
||||
val conflict = openConflict()
|
||||
|
||||
val token = assertNotNull(guard.startRestoring(conflict))
|
||||
backup.update(external)
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
|
||||
guard.finishRestoring(conflict, token, restored = true)
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRestoreReopensTheConflict() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
val external = follows(200, bob)
|
||||
backup.update(external)
|
||||
val conflict = openConflict()
|
||||
|
||||
val token = assertNotNull(guard.startRestoring(conflict))
|
||||
guard.finishRestoring(conflict, token, restored = false)
|
||||
assertSame(conflict, openConflict())
|
||||
|
||||
// The reopened conflict can still be answered, and its retry still works.
|
||||
guard.keepIncoming(conflict)
|
||||
assertSame(external, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRestoreDoesNotOverwriteANewerConflict() {
|
||||
val backup = Backup(follows(100, alice, bob, carol))
|
||||
backup.update(follows(200, bob, carol))
|
||||
val conflict = openConflict()
|
||||
|
||||
val token = assertNotNull(guard.startRestoring(conflict))
|
||||
val newer = follows(300, carol)
|
||||
backup.update(newer)
|
||||
guard.finishRestoring(conflict, token, restored = false)
|
||||
|
||||
assertSame(newer, openConflict().incoming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExternalWipeIsQuestioned() {
|
||||
val saved = follows(100, alice)
|
||||
val backup = Backup(saved)
|
||||
backup.update(follows(200))
|
||||
assertSame(saved, backup.saved)
|
||||
assertNotNull(openConflict())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExternalWipeOfAnEmptyBackupIsNotStored() {
|
||||
val saved = follows(100)
|
||||
val backup = Backup(saved)
|
||||
backup.update(follows(200))
|
||||
assertSame(saved, backup.saved)
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWipeSignedHereOrKeptByTheUserIsStored() {
|
||||
val backup = Backup(follows(100, alice))
|
||||
val wipe = signedHere(follows(200))
|
||||
backup.update(wipe)
|
||||
assertSame(wipe, backup.saved)
|
||||
|
||||
val other = Backup(follows(100, alice))
|
||||
// A different timestamp, or it would be the very event signed above.
|
||||
val externalWipe = follows(201)
|
||||
other.update(externalWipe)
|
||||
guard.keepIncoming(openConflict())
|
||||
assertSame(externalWipe, other.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun droppingASlotForgetsItsConflict() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
backup.update(follows(200, bob))
|
||||
val conflict = openConflict()
|
||||
|
||||
guard.drop(conflict.slot)
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
guard.keepIncoming(conflict)
|
||||
assertEquals(0, backup.saves)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachEventKindHasItsOwnSlot() {
|
||||
val followsBackup = Backup(follows(100, alice, bob))
|
||||
val mutes = Backup(signer.sign<Event>(100, 10000, arrayOf(arrayOf("p", alice)), ""))
|
||||
followsBackup.update(follows(200, bob))
|
||||
mutes.update(signer.sign<Event>(200, 10000, arrayOf(), ""))
|
||||
assertEquals(2, guard.conflicts.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openConflictsRoundTripThroughStorage() {
|
||||
val saved = follows(100, alice, bob)
|
||||
val external = follows(200, bob)
|
||||
Backup(saved).update(external)
|
||||
|
||||
val afterRestart = BackupConflictGuard(isLocallySigned = { false })
|
||||
afterRestart.restore(guard.open())
|
||||
|
||||
val conflict =
|
||||
afterRestart.conflicts.value.values
|
||||
.single()
|
||||
assertEquals(saved.id, conflict.saved.id)
|
||||
assertEquals(external.id, conflict.incoming.id)
|
||||
assertEquals(external.id, conflict.cause.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStoredConflictThatNoLongerLosesDataIsNotRestored() {
|
||||
val saved = follows(100, alice)
|
||||
val putBack = follows(200, alice, bob)
|
||||
guard.restore(listOf(Triple(saved, putBack, putBack)))
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepingARestoredConflictReappliesItsVersion() {
|
||||
val external = follows(200, bob)
|
||||
guard.restore(listOf(Triple(follows(100, alice, bob), external, external)))
|
||||
|
||||
guard.keepIncoming(openConflict())
|
||||
|
||||
assertEquals(listOf(external), reapplied)
|
||||
// The reapplied update is now accepted instead of raising the conflict again.
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
backup.update(external)
|
||||
assertSame(external, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReEmittedVersionGivesARestoredConflictItsRetryBack() {
|
||||
val saved = follows(100, alice, bob)
|
||||
val external = follows(200, bob)
|
||||
guard.restore(listOf(Triple(saved, external, external)))
|
||||
|
||||
val backup = Backup(saved)
|
||||
backup.update(external)
|
||||
guard.keepIncoming(openConflict())
|
||||
|
||||
assertTrue(reapplied.isEmpty())
|
||||
assertSame(external, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyChangeToTheOpenConflictsIsReported() {
|
||||
val backup = Backup(follows(100, alice, bob))
|
||||
val external = follows(200, bob)
|
||||
backup.update(external)
|
||||
assertEquals(1, changes)
|
||||
|
||||
// A re-emit changes nothing to store.
|
||||
backup.update(external)
|
||||
assertEquals(1, changes)
|
||||
|
||||
val conflict = openConflict()
|
||||
val token = assertNotNull(guard.startRestoring(conflict))
|
||||
assertEquals(2, changes)
|
||||
guard.finishRestoring(conflict, token, restored = false)
|
||||
assertEquals(3, changes)
|
||||
|
||||
guard.drop(conflict.slot)
|
||||
assertEquals(4, changes)
|
||||
guard.drop(conflict.slot)
|
||||
assertEquals(4, changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOlderVersionNeverRollsTheBackupBack() {
|
||||
val saved = follows(200, alice)
|
||||
val backup = Backup(saved)
|
||||
backup.update(signedHere(follows(100, alice, bob)))
|
||||
assertSame(saved, backup.saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRestoreDoesNotReopenAConflictTheBackupMovedPast() {
|
||||
val saved = follows(100, alice, bob)
|
||||
val backup = Backup(saved)
|
||||
backup.update(follows(200, bob))
|
||||
val conflict = openConflict()
|
||||
val token = assertNotNull(guard.startRestoring(conflict))
|
||||
|
||||
// While the signer is still asking, another app puts everything back and more.
|
||||
val fixed = follows(300, alice, bob, carol)
|
||||
backup.update(fixed)
|
||||
guard.finishRestoring(conflict, token, restored = false)
|
||||
|
||||
assertTrue(guard.conflicts.value.isEmpty())
|
||||
assertSame(fixed, backup.saved)
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.backups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** The backup guard's use of the events' own diffs; the diffs themselves are tested in quartz. */
|
||||
class ReplaceableBackupDiffTest {
|
||||
private val signer = NostrSignerSync(KeyPair())
|
||||
|
||||
private fun sign(
|
||||
kind: Int,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String = "",
|
||||
): Event = signer.sign<Event>(createdAt, kind, tags, content)
|
||||
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
|
||||
@Test
|
||||
fun lossyRewriteIsDetected() {
|
||||
val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob)))
|
||||
val incoming = sign(3, 200, arrayOf(arrayOf("p", bob)))
|
||||
val diff = assertIs<ContactListDiff>(ReplaceableBackupDiff.detectLoss(saved, incoming))
|
||||
assertEquals(listOf(alice), diff.follows.removed.map { it.pubKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun additionsOnlyAreNotALoss() {
|
||||
val saved = sign(3, 100, arrayOf(arrayOf("p", alice)))
|
||||
val incoming = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob)))
|
||||
assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearedPrivateItemsAreALoss() {
|
||||
val saved = sign(10000, 100, arrayOf(), "encrypted")
|
||||
val incoming = sign(10000, 200, arrayOf(), "")
|
||||
assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun olderVersionIsNeverALoss() {
|
||||
val saved = sign(3, 200, arrayOf(arrayOf("p", alice)))
|
||||
val incoming = sign(3, 100, arrayOf())
|
||||
assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locallySignedEventsTrackOnlyReplaceables() {
|
||||
val list = sign(10000, 100, arrayOf())
|
||||
val note = sign(1, 100, arrayOf())
|
||||
LocallySignedEvents.mark(list)
|
||||
LocallySignedEvents.mark(note)
|
||||
assertTrue(LocallySignedEvents.contains(list.id))
|
||||
assertFalse(LocallySignedEvents.contains(note.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locallySignedEventsForgetTheOldestBeyondCapacity() {
|
||||
val first = sign(10000, 1, arrayOf())
|
||||
LocallySignedEvents.mark(first)
|
||||
repeat(600) { LocallySignedEvents.mark(sign(10000, 2L + it, arrayOf())) }
|
||||
assertFalse(LocallySignedEvents.contains(first.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreDropsClientPowAndPastExpiration() {
|
||||
val saved =
|
||||
sign(
|
||||
10000,
|
||||
100,
|
||||
arrayOf(
|
||||
arrayOf("p", alice),
|
||||
arrayOf("client", "OtherApp"),
|
||||
arrayOf("nonce", "123", "20"),
|
||||
arrayOf("expiration", "500"),
|
||||
arrayOf("word", "spam"),
|
||||
),
|
||||
"private",
|
||||
)
|
||||
val tags = BackupRestore.tagsToResign(saved, now = 1000)
|
||||
assertEquals(listOf(listOf("p", alice), listOf("word", "spam")), tags.map { it.toList() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreKeepsAFutureExpiration() {
|
||||
val saved = sign(10000, 100, arrayOf(arrayOf("p", alice), arrayOf("expiration", "5000")))
|
||||
val tags = BackupRestore.tagsToResign(saved, now = 1000)
|
||||
assertEquals(listOf(listOf("p", alice), listOf("expiration", "5000")), tags.map { it.toList() })
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -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) {
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord02Community
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
|
||||
/** Changes to the joined Concord communities. The list is encrypted, so it can only be compared as a whole. */
|
||||
@Immutable
|
||||
class ConcordCommunityListDiff(
|
||||
val communities: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = communities.isRemoval()
|
||||
}
|
||||
+10
-1
@@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.concord.cord02Community
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -47,7 +50,13 @@ class ConcordCommunityListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<ConcordCommunityListDiff> {
|
||||
override fun diffFrom(older: Event): ConcordCommunityListDiff? {
|
||||
if (older !is ConcordCommunityListEvent || older.pubKey != pubKey) return null
|
||||
return ConcordCommunityListDiff(ContentChange.between(older.content, content))
|
||||
}
|
||||
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
/** Decrypts this list's entries with [signer], or empty on failure / wrong key. */
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.ephemChat.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
|
||||
/** Changes to the joined ephemeral chat rooms: public ones, and the private ones as a whole. */
|
||||
@Immutable
|
||||
class EphemeralChatListDiff(
|
||||
val rooms: ListDiff<RoomId>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(rooms.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+13
-1
@@ -24,9 +24,12 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
@@ -43,7 +46,16 @@ class EphemeralChatListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<EphemeralChatListDiff> {
|
||||
override fun diffFrom(older: Event): EphemeralChatListDiff? {
|
||||
if (older !is EphemeralChatListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return EphemeralChatListDiff(
|
||||
ListDiff.of(older.publicRooms(), publicRooms(), { it }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
fun publicRooms() = tags.rooms()
|
||||
|
||||
fun publicRoomSet() = tags.roomSet()
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.nipA3
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
|
||||
/** Changes to the NIP-A3 payment targets (the ways others can pay the user). */
|
||||
@Immutable
|
||||
class PaymentTargetsDiff(
|
||||
val targets: ListDiff<PaymentTarget>,
|
||||
) : EventDiff {
|
||||
override fun removesData() = targets.hasRemovals()
|
||||
}
|
||||
+10
-1
@@ -22,7 +22,10 @@ package com.vitorpamplona.quartz.experimental.nipA3
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -33,7 +36,13 @@ class PaymentTargetsEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<PaymentTargetsDiff> {
|
||||
override fun diffFrom(older: Event): PaymentTargetsDiff? {
|
||||
if (older !is PaymentTargetsEvent || older.pubKey != pubKey) return null
|
||||
return PaymentTargetsDiff(ListDiff.of(older.paymentTargets(), paymentTargets(), { it }))
|
||||
}
|
||||
|
||||
fun paymentTargets(): List<PaymentTarget> = tags.mapNotNull { PaymentTargetTag.parse(it) }
|
||||
|
||||
companion object {
|
||||
|
||||
+12
-1
@@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
@@ -48,7 +53,13 @@ class KeyPackageRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is KeyPackageRelayListEvent || older.pubKey != pubKey) return null
|
||||
return RelayListDiff(ListDiff.of(older.relays(), relays(), { it }), ContentChange.NONE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays from this list, with local-network entries dropped.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.diff
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
/**
|
||||
* What changed between two versions of the same replaceable/addressable event. Each event
|
||||
* kind defines its own diff class holding its own parsed objects (a follow list diffs
|
||||
* `ContactTag`s, a nutzap info diffs `NutzapMintTag`s and its P2PK key…), built with the
|
||||
* [ListDiff], [ValueChange] and [ContentChange] containers.
|
||||
*/
|
||||
interface EventDiff {
|
||||
/** The newer version dropped something the older one had. */
|
||||
fun removesData(): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* An event that knows how to compare itself with an older version of itself.
|
||||
*
|
||||
* [diffFrom] returns null when [older] isn't a version of the same event: a different kind,
|
||||
* author or, for addressables, d-tag.
|
||||
*/
|
||||
interface DiffableEvent<D : EventDiff> {
|
||||
fun diffFrom(older: Event): D?
|
||||
}
|
||||
|
||||
/** An item present in both versions whose details differ. */
|
||||
@Immutable
|
||||
class ItemChange<T>(
|
||||
val before: T,
|
||||
val after: T,
|
||||
)
|
||||
|
||||
/**
|
||||
* The difference between two lists of parsed items. Items are matched by an identity key;
|
||||
* a matched pair whose details differ is a change, not a removal plus an addition.
|
||||
*/
|
||||
@Immutable
|
||||
class ListDiff<T>(
|
||||
val removed: List<T>,
|
||||
val added: List<T>,
|
||||
val changed: List<ItemChange<T>>,
|
||||
) {
|
||||
fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty()
|
||||
|
||||
fun hasRemovals() = removed.isNotEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* @param key the identity of an item inside the list.
|
||||
* @param same whether two items with the same key carry the same details.
|
||||
*/
|
||||
fun <T, K> of(
|
||||
older: List<T>,
|
||||
newer: List<T>,
|
||||
key: (T) -> K,
|
||||
same: (T, T) -> Boolean = { a, b -> a == b },
|
||||
): ListDiff<T> {
|
||||
val olderByKey = LinkedHashMap<K, T>(older.size)
|
||||
older.forEach { olderByKey.getOrPut(key(it)) { it } }
|
||||
val newerByKey = LinkedHashMap<K, T>(newer.size)
|
||||
newer.forEach { newerByKey.getOrPut(key(it)) { it } }
|
||||
|
||||
val removed = mutableListOf<T>()
|
||||
val changed = mutableListOf<ItemChange<T>>()
|
||||
olderByKey.forEach { (k, before) ->
|
||||
val after = newerByKey[k]
|
||||
if (after == null) {
|
||||
removed.add(before)
|
||||
} else if (!same(before, after)) {
|
||||
changed.add(ItemChange(before, after))
|
||||
}
|
||||
}
|
||||
val added = newerByKey.filterKeys { it !in olderByKey }.values.toList()
|
||||
|
||||
return ListDiff(removed, added, changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single value that differs between two versions; null on a side means it wasn't set. */
|
||||
@Immutable
|
||||
class ValueChange<T>(
|
||||
val before: T?,
|
||||
val after: T?,
|
||||
) {
|
||||
fun isRemoval() = before != null && after == null
|
||||
|
||||
companion object {
|
||||
/** Null when both sides are the same. */
|
||||
fun <T> of(
|
||||
before: T?,
|
||||
after: T?,
|
||||
same: (T, T) -> Boolean = { a, b -> a == b },
|
||||
): ValueChange<T>? =
|
||||
when {
|
||||
before == null && after == null -> null
|
||||
before != null && after != null && same(before, after) -> null
|
||||
else -> ValueChange(before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How content that can't be read without decrypting (NIP-44 private list items, encrypted
|
||||
* wallets and settings) changed as a whole.
|
||||
*/
|
||||
enum class ContentChange {
|
||||
NONE,
|
||||
ADDED,
|
||||
CHANGED,
|
||||
CLEARED,
|
||||
;
|
||||
|
||||
fun isRemoval() = this == CLEARED
|
||||
|
||||
/**
|
||||
* Whether public items that disappeared alongside this change are really gone. When the
|
||||
* private section only just appeared, they were most likely moved into it (made
|
||||
* private), which loses nothing. A rewritten private section can't vouch for them: NIP-44
|
||||
* re-encrypts on every save, so it looks changed even when it holds the same items.
|
||||
*/
|
||||
fun publicRemovalsAreLoss(hasPublicRemovals: Boolean) = hasPublicRemovals && this != ADDED
|
||||
|
||||
companion object {
|
||||
fun between(
|
||||
older: String,
|
||||
newer: String,
|
||||
): ContentChange =
|
||||
when {
|
||||
older == newer -> NONE
|
||||
older.isBlank() -> ADDED
|
||||
newer.isBlank() -> CLEARED
|
||||
else -> CHANGED
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.metadata
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
|
||||
|
||||
/**
|
||||
* Changes to a kind:0 profile, field by field as parsed by [UserMetadata]. A field that
|
||||
* went blank counts as removed. JSON fields [UserMetadata] doesn't model are kept as raw
|
||||
* `name → value` pairs in [otherFields], and NIP-39 identity claims are diffed as tags.
|
||||
*/
|
||||
@Immutable
|
||||
class MetadataDiff(
|
||||
val name: ValueChange<String>?,
|
||||
val displayName: ValueChange<String>?,
|
||||
val picture: ValueChange<String>?,
|
||||
val banner: ValueChange<String>?,
|
||||
val website: ValueChange<String>?,
|
||||
val about: ValueChange<String>?,
|
||||
val pronouns: ValueChange<String>?,
|
||||
val nip05: ValueChange<String>?,
|
||||
val lud06: ValueChange<String>?,
|
||||
val lud16: ValueChange<String>?,
|
||||
val clinkOffer: ValueChange<String>?,
|
||||
val bot: ValueChange<Boolean>?,
|
||||
val birthday: ValueChange<Birthday>?,
|
||||
val otherFields: ListDiff<Pair<String, String>>,
|
||||
val identityClaims: ListDiff<IdentityClaimTag>,
|
||||
) : EventDiff {
|
||||
private fun fieldChanges(): List<ValueChange<*>> = listOfNotNull(name, displayName, picture, banner, website, about, pronouns, nip05, lud06, lud16, clinkOffer, bot, birthday)
|
||||
|
||||
// Dropping the deprecated NIP-24 aliases (displayName, username) is a cleanup, not a loss:
|
||||
// clients that follow NIP-24 remove them and keep display_name / name.
|
||||
override fun removesData() =
|
||||
fieldChanges().any { it.isRemoval() } ||
|
||||
otherFields.removed.any { it.first !in DEPRECATED_ALIASES } ||
|
||||
identityClaims.hasRemovals()
|
||||
|
||||
companion object {
|
||||
/** JSON keys [UserMetadata] parses into typed fields above. */
|
||||
val DEPRECATED_ALIASES = setOf("displayName", "username")
|
||||
|
||||
val MODELED_FIELDS = setOf("name", "display_name", "picture", "banner", "website", "about", "pronouns", "nip05", "lud06", "lud16", "clink_offer", "bot", "birthday")
|
||||
}
|
||||
}
|
||||
+52
@@ -22,10 +22,14 @@ package com.vitorpamplona.quartz.nip01Core.metadata
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.builder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag
|
||||
@@ -66,7 +70,55 @@ class MetadataEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<MetadataDiff>,
|
||||
SearchableEvent {
|
||||
override fun diffFrom(older: Event): MetadataDiff? {
|
||||
if (older !is MetadataEvent || older.pubKey != pubKey) return null
|
||||
val before = older.contactMetaData() ?: UserMetadata()
|
||||
val after = contactMetaData() ?: UserMetadata()
|
||||
|
||||
fun text(
|
||||
b: String?,
|
||||
a: String?,
|
||||
) = ValueChange.of(b?.ifBlank { null }, a?.ifBlank { null })
|
||||
|
||||
return MetadataDiff(
|
||||
name = text(before.name, after.name),
|
||||
displayName = text(before.displayName, after.displayName),
|
||||
picture = text(before.picture, after.picture),
|
||||
banner = text(before.banner, after.banner),
|
||||
website = text(before.website, after.website),
|
||||
about = text(before.about, after.about),
|
||||
pronouns = text(before.pronouns, after.pronouns),
|
||||
nip05 = text(before.nip05, after.nip05),
|
||||
lud06 = text(before.lud06, after.lud06),
|
||||
lud16 = text(before.lud16, after.lud16),
|
||||
clinkOffer = text(before.clinkOffer, after.clinkOffer),
|
||||
// "bot": false is the default: dropping it loses nothing, so only `true` counts.
|
||||
bot = ValueChange.of(before.bot?.takeIf { it }, after.bot?.takeIf { it }),
|
||||
birthday =
|
||||
ValueChange.of(before.birthday, after.birthday) { b, a ->
|
||||
b.year == a.year && b.month == a.month && b.day == a.day
|
||||
},
|
||||
otherFields = ListDiff.of(older.unmodeledFields(), unmodeledFields(), { it.first }),
|
||||
identityClaims =
|
||||
ListDiff.of(
|
||||
older.tags.mapNotNull(IdentityClaimTag::parse),
|
||||
tags.mapNotNull(IdentityClaimTag::parse),
|
||||
{ it.platformIdentity() },
|
||||
{ b, a -> b.proof == a.proof },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Filled JSON fields [UserMetadata] has no typed property for, as raw pairs. */
|
||||
private fun unmodeledFields(): List<Pair<String, String>> =
|
||||
contactMetadataJson()?.mapNotNull { (key, value) ->
|
||||
if (key in MetadataDiff.MODELED_FIELDS) return@mapNotNull null
|
||||
val text = if (value is JsonPrimitive) value.content else value.toString()
|
||||
if (text.isBlank() || text == "null") null else key to text
|
||||
} ?: emptyList()
|
||||
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
// Profile content is JSON, so we parse it and index only the
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip02FollowList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
|
||||
/** Changes to the follow list: people followed and unfollowed, and new relay hints or petnames. The deprecated relay map in the content is ignored. */
|
||||
@Immutable
|
||||
class ContactListDiff(
|
||||
val follows: ListDiff<ContactTag>,
|
||||
) : EventDiff {
|
||||
override fun removesData() = follows.hasRemovals()
|
||||
}
|
||||
+11
@@ -23,8 +23,11 @@ package com.vitorpamplona.quartz.nip02FollowList
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.any
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -44,7 +47,15 @@ class ContactListEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<ContactListDiff>,
|
||||
PubKeyHintProvider {
|
||||
override fun diffFrom(older: Event): ContactListDiff? {
|
||||
if (older !is ContactListEvent || older.pubKey != pubKey) return null
|
||||
return ContactListDiff(
|
||||
ListDiff.of(older.follows(), follows(), { it.pubKey }, { a, b -> a.relayUri == b.relayUri && a.petname == b.petname }),
|
||||
)
|
||||
}
|
||||
|
||||
override fun pubKeyHints() = tags.mapNotNull(ContactTag::parseAsHint)
|
||||
|
||||
override fun linkedPubKeys() = tags.mapNotNull(ContactTag::parseKey)
|
||||
|
||||
+12
-1
@@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.nip17Dm.settings
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
@@ -39,7 +44,13 @@ class ChatMessageRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is ChatMessageRelayListEvent || older.pubKey != pubKey) return null
|
||||
return RelayListDiff(ListDiff.of(older.relays(), relays(), { it }), ContentChange.NONE)
|
||||
}
|
||||
|
||||
fun relays(): List<NormalizedRelayUrl> = tags.mapNotNull(RelayTag::parse)
|
||||
|
||||
/** Every relay in this list, local ones included. For reading back our OWN list. */
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip28PublicChat.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
|
||||
|
||||
/** Changes to the joined public chats: public channels as parsed tags, private ones as a whole. */
|
||||
@Immutable
|
||||
class ChannelListDiff(
|
||||
val channels: ListDiff<ChannelTag>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(channels.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+12
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip28PublicChat.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -47,7 +50,16 @@ class ChannelListEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<ChannelListDiff>,
|
||||
EventHintProvider {
|
||||
override fun diffFrom(older: Event): ChannelListDiff? {
|
||||
if (older !is ChannelListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return ChannelListDiff(
|
||||
ListDiff.of(older.tags.channels(), tags.channels(), { it.eventId }, { a, b -> a.relay == b.relay }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
override fun eventHints() = tags.mapNotNull(ChannelTag::parseAsHint)
|
||||
|
||||
override fun linkedEventIds() = tags.mapNotNull(ChannelTag::parseId)
|
||||
|
||||
+11
-1
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip37Drafts.privateOutbox
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
|
||||
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
|
||||
@@ -48,7 +52,13 @@ class PrivateOutboxRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is PrivateOutboxRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
|
||||
}
|
||||
|
||||
fun publicRelays() = tags.mapNotNull(RelayTag::parse)
|
||||
|
||||
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.mapNotNull(RelayTag::parse)
|
||||
|
||||
+11
-1
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip50Search
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
|
||||
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.searchRelays
|
||||
@@ -48,7 +52,13 @@ class SearchRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is SearchRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
|
||||
}
|
||||
|
||||
fun publicRelays() = tags.relays()
|
||||
|
||||
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
|
||||
|
||||
+7
@@ -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()
|
||||
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
|
||||
/** Changes to the favorite algorithmic feeds: public ones as parsed bookmarks, private ones as a whole. */
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedsListDiff(
|
||||
val feeds: ListDiff<AddressBookmark>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(feeds.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+13
-1
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
@@ -42,7 +45,16 @@ class FavoriteAlgoFeedsListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<FavoriteAlgoFeedsListDiff> {
|
||||
override fun diffFrom(older: Event): FavoriteAlgoFeedsListDiff? {
|
||||
if (older !is FavoriteAlgoFeedsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return FavoriteAlgoFeedsListDiff(
|
||||
ListDiff.of(older.publicFavoriteAlgoFeeds(), publicFavoriteAlgoFeeds(), { it.address }, { a, b -> a.relayHint == b.relayHint }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
fun publicFavoriteAlgoFeeds(): List<AddressBookmark> = tags.mapNotNull(AddressBookmark::parse)
|
||||
|
||||
suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List<AddressBookmark>? = privateTags(signer)?.mapNotNull(AddressBookmark::parse)
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip51Lists.geohashList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
|
||||
/** Changes to the followed locations (geohashes): public ones, and the private ones as a whole. */
|
||||
@Immutable
|
||||
class GeohashListDiff(
|
||||
val geohashes: ListDiff<String>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(geohashes.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+13
-1
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.geohashList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
@@ -45,7 +48,16 @@ class GeohashListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<GeohashListDiff> {
|
||||
override fun diffFrom(older: Event): GeohashListDiff? {
|
||||
if (older !is GeohashListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return GeohashListDiff(
|
||||
ListDiff.of(older.publicGeohashes(), publicGeohashes(), { it.lowercase() }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
fun publicGeohashes() = tags.geohashList()
|
||||
|
||||
suspend fun decryptPrivateGeohashes(signer: NostrSigner) = privateTags(signer)?.geohashList()
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip51Lists.hashtagList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
|
||||
/** Changes to the followed hashtags: public ones, and the private ones as a whole. */
|
||||
@Immutable
|
||||
class HashtagListDiff(
|
||||
val hashtags: ListDiff<String>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(hashtags.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+13
-1
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.hashtagList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
@@ -45,7 +48,16 @@ class HashtagListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<HashtagListDiff> {
|
||||
override fun diffFrom(older: Event): HashtagListDiff? {
|
||||
if (older !is HashtagListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return HashtagListDiff(
|
||||
ListDiff.of(older.publicHashtags(), publicHashtags(), { it.lowercase() }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
fun publicHashtags() = tags.mapNotNull(HashtagTag::parse)
|
||||
|
||||
companion object {
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip51Lists.muteList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
|
||||
|
||||
/** Changes to the mute list: public mutes (people, words, hashtags, threads) as parsed tags, and the encrypted private mutes as a whole. */
|
||||
@Immutable
|
||||
class MuteListDiff(
|
||||
val publicMutes: ListDiff<MuteTag>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(publicMutes.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+12
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.muteList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
@@ -46,7 +49,16 @@ class MuteListEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<MuteListDiff>,
|
||||
PubKeyHintProvider {
|
||||
override fun diffFrom(older: Event): MuteListDiff? {
|
||||
if (older !is MuteListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return MuteListDiff(
|
||||
ListDiff.of(older.publicMutes(), publicMutes(), { it.toTagIdOnly().toList() }, { a, b -> a.toTagArray().contentEquals(b.toTagArray()) }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint)
|
||||
|
||||
override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey)
|
||||
|
||||
+10
-1
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -48,7 +51,13 @@ class BlockedRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is BlockedRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
|
||||
}
|
||||
|
||||
fun publicRelays() = tags.relays()
|
||||
|
||||
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
|
||||
|
||||
+10
-1
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -48,7 +51,13 @@ class IndexerRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is IndexerRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
|
||||
}
|
||||
|
||||
fun publicRelays() = tags.relays()
|
||||
|
||||
suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
|
||||
|
||||
+10
-1
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -48,7 +51,13 @@ class RelayFeedsListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is RelayFeedsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
|
||||
}
|
||||
|
||||
fun publicRelays() = tags.relays()
|
||||
|
||||
suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip51Lists.relayLists
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/** Changes to a list of relays (DM inbox, search, indexer, blocked, trusted, private outbox…): the public relays, and the encrypted private ones as a whole. */
|
||||
@Immutable
|
||||
class RelayListDiff(
|
||||
val relays: ListDiff<NormalizedRelayUrl>,
|
||||
val privateRelays: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateRelays.publicRemovalsAreLoss(relays.hasRemovals()) || privateRelays.isRemoval()
|
||||
}
|
||||
+10
-1
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -48,7 +51,13 @@ class TrustedRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<RelayListDiff> {
|
||||
override fun diffFrom(older: Event): RelayListDiff? {
|
||||
if (older !is TrustedRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older))
|
||||
}
|
||||
|
||||
fun publicRelays() = tags.relays()
|
||||
|
||||
suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip51Lists.simpleGroupList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
|
||||
/** Changes to the joined NIP-29 groups: public ones as parsed group tags (renames included), private ones as a whole. */
|
||||
@Immutable
|
||||
class SimpleGroupListDiff(
|
||||
val groups: ListDiff<GroupTag>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(groups.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+14
-1
@@ -22,8 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.simpleGroupList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
|
||||
@@ -39,7 +43,16 @@ class SimpleGroupListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<SimpleGroupListDiff> {
|
||||
override fun diffFrom(older: Event): SimpleGroupListDiff? {
|
||||
if (older !is SimpleGroupListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return SimpleGroupListDiff(
|
||||
ListDiff.of(older.publicGroups(), publicGroups(), { it.groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.url ?: it.relayUrl) }, { a, b -> a.name == b.name }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
fun publicGroups() = tags.mapNotNull(GroupTag::parse)
|
||||
|
||||
suspend fun privateGroups(signer: NostrSigner) = privateTags(signer)?.mapNotNull(GroupTag::parse)
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip60Cashu.wallet
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
|
||||
/** Changes to the NIP-60 wallet. Its mints and keys are encrypted, so it can only be compared as a whole. */
|
||||
@Immutable
|
||||
class CashuWalletDiff(
|
||||
val wallet: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = wallet.isRemoval()
|
||||
}
|
||||
+10
-1
@@ -23,8 +23,11 @@ package com.vitorpamplona.quartz.nip60Cashu.wallet
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
@@ -47,7 +50,13 @@ class CashuWalletEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<CashuWalletDiff> {
|
||||
override fun diffFrom(older: Event): CashuWalletDiff? {
|
||||
if (older !is CashuWalletEvent || older.pubKey != pubKey) return null
|
||||
return CashuWalletDiff(ContentChange.between(older.content, content))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the content to get the wallet's private tags (mints and privkey).
|
||||
*/
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip61Nutzaps.info
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
|
||||
|
||||
/** Changes to the NIP-61 nutzap receiving settings: accepted mints (and their units), relays, and the P2PK key nutzaps are locked to. */
|
||||
@Immutable
|
||||
class NutzapInfoDiff(
|
||||
val mints: ListDiff<NutzapMintTag>,
|
||||
val relays: ListDiff<NormalizedRelayUrl>,
|
||||
val p2pkPubkey: ValueChange<HexKey>?,
|
||||
) : EventDiff {
|
||||
// A mint that stops accepting a unit (sat, usd…) loses that unit's nutzaps.
|
||||
override fun removesData() =
|
||||
mints.hasRemovals() ||
|
||||
mints.changed.any { !it.after.units.containsAll(it.before.units) } ||
|
||||
relays.hasRemovals() ||
|
||||
p2pkPubkey?.isRemoval() == true
|
||||
}
|
||||
+15
-1
@@ -23,8 +23,12 @@ package com.vitorpamplona.quartz.nip61Nutzaps.info
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
|
||||
@@ -38,7 +42,17 @@ class NutzapInfoEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<NutzapInfoDiff> {
|
||||
override fun diffFrom(older: Event): NutzapInfoDiff? {
|
||||
if (older !is NutzapInfoEvent || older.pubKey != pubKey) return null
|
||||
return NutzapInfoDiff(
|
||||
ListDiff.of(older.mints(), mints(), { it.mintUrl.trimEnd('/').lowercase() }, { a, b -> a.units.toSet() == b.units.toSet() }),
|
||||
ListDiff.of(older.relays(), relays(), { it }),
|
||||
ValueChange.of(older.p2pkPubkey(), p2pkPubkey()),
|
||||
)
|
||||
}
|
||||
|
||||
fun mints() = tags.mints()
|
||||
|
||||
fun relays() = tags.relays()
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip65RelayList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
|
||||
/** Changes to the NIP-65 relay list: relays added, removed, or switched between read, write and both. */
|
||||
@Immutable
|
||||
class AdvertisedRelayListDiff(
|
||||
val relays: ListDiff<AdvertisedRelayInfo>,
|
||||
) : EventDiff {
|
||||
// A relay that stops being read (inbox) or write (outbox) loses that role even though it
|
||||
// is still listed: going from read+write to write-only drops the user's inbox there.
|
||||
override fun removesData() =
|
||||
relays.hasRemovals() ||
|
||||
relays.changed.any { (it.before.type.isRead() && !it.after.type.isRead()) || (it.before.type.isWrite() && !it.after.type.isWrite()) }
|
||||
}
|
||||
+23
-1
@@ -23,11 +23,16 @@ package com.vitorpamplona.quartz.nip65RelayList
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
@@ -38,7 +43,24 @@ class AdvertisedRelayListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<AdvertisedRelayListDiff> {
|
||||
override fun diffFrom(older: Event): AdvertisedRelayListDiff? {
|
||||
if (older !is AdvertisedRelayListEvent || older.pubKey != pubKey) return null
|
||||
return AdvertisedRelayListDiff(ListDiff.of(older.relaysByUrl(), relaysByUrl(), { it.relayUrl }, { a, b -> a.type == b.type }))
|
||||
}
|
||||
|
||||
/** One entry per relay: a separate read tag and write tag for the same URL mean both. */
|
||||
private fun relaysByUrl(): List<AdvertisedRelayInfo> {
|
||||
val byUrl = LinkedHashMap<NormalizedRelayUrl, AdvertisedRelayType>()
|
||||
tags.forEach { tag ->
|
||||
val info = AdvertisedRelayInfo.parse(tag) ?: return@forEach
|
||||
val current = byUrl[info.relayUrl]
|
||||
byUrl[info.relayUrl] = if (current == null || current == info.type) info.type else AdvertisedRelayType.BOTH
|
||||
}
|
||||
return byUrl.map { (url, type) -> AdvertisedRelayInfo(url, type) }
|
||||
}
|
||||
|
||||
fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse)
|
||||
|
||||
fun relaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseNorm)
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip72ModCommunities.follow
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag
|
||||
|
||||
/** Changes to the joined communities: public ones as parsed tags, private ones as a whole. */
|
||||
@Immutable
|
||||
class CommunityListDiff(
|
||||
val communities: ListDiff<CommunityTag>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(communities.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+12
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip72ModCommunities.follow
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
@@ -45,7 +48,16 @@ class CommunityListEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<CommunityListDiff>,
|
||||
AddressHintProvider {
|
||||
override fun diffFrom(older: Event): CommunityListDiff? {
|
||||
if (older !is CommunityListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return CommunityListDiff(
|
||||
ListDiff.of(older.publicCommunities(), publicCommunities(), { it.address }, { a, b -> a.relayHint == b.relayHint }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(CommunityTag::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds() = tags.mapNotNull(CommunityTag::parseAddressId)
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip78AppData
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
|
||||
/** Changes to an app's NIP-78 data. It is usually encrypted, so it is compared as a whole. */
|
||||
@Immutable
|
||||
class AppSpecificDataDiff(
|
||||
val data: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = data.isRemoval()
|
||||
}
|
||||
+10
-1
@@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip78AppData
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
@@ -36,7 +39,13 @@ class AppSpecificDataEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<AppSpecificDataDiff> {
|
||||
override fun diffFrom(older: Event): AppSpecificDataDiff? {
|
||||
if (older !is AppSpecificDataEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return AppSpecificDataDiff(ContentChange.between(older.content, content))
|
||||
}
|
||||
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
companion object {
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip85TrustedAssertions.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
|
||||
/** Changes to the NIP-85 trusted service providers: who computes each service, and from which relay. */
|
||||
@Immutable
|
||||
class TrustProviderListDiff(
|
||||
val providers: ListDiff<ServiceProviderTag>,
|
||||
val privateItems: ContentChange,
|
||||
) : EventDiff {
|
||||
override fun removesData() = privateItems.publicRemovalsAreLoss(providers.hasRemovals()) || privateItems.isRemoval()
|
||||
}
|
||||
+13
-1
@@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip85TrustedAssertions.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
@@ -42,7 +45,16 @@ class TrustProviderListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<TrustProviderListDiff> {
|
||||
override fun diffFrom(older: Event): TrustProviderListDiff? {
|
||||
if (older !is TrustProviderListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return TrustProviderListDiff(
|
||||
ListDiff.of(older.serviceProviders(), serviceProviders(), { it.service to it.pubkey }, { a, b -> a.relayUrl == b.relayUrl }),
|
||||
privateItemsChangeFrom(older),
|
||||
)
|
||||
}
|
||||
|
||||
fun serviceProviders() = tags.serviceProviders()
|
||||
|
||||
companion object {
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
|
||||
/** Changes to the published BOLT12 offers. */
|
||||
@Immutable
|
||||
class Bolt12OfferListDiff(
|
||||
val offers: ListDiff<String>,
|
||||
) : EventDiff {
|
||||
override fun removesData() = offers.hasRemovals()
|
||||
}
|
||||
+10
-1
@@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
@@ -50,7 +53,13 @@ class Bolt12OfferListEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<Bolt12OfferListDiff> {
|
||||
override fun diffFrom(older: Event): Bolt12OfferListDiff? {
|
||||
if (older !is Bolt12OfferListEvent || older.pubKey != pubKey) return null
|
||||
return Bolt12OfferListDiff(ListDiff.of(older.offers(), offers(), { it }))
|
||||
}
|
||||
|
||||
/** All canonical raw BOLT12 offers (`lno1...`) this user publishes, in tag order. */
|
||||
fun offers(): List<String> = tags.mapNotNull(OfferTag::parse)
|
||||
|
||||
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.diff
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class EventDiffTest {
|
||||
private val signer = NostrSignerSync(KeyPair())
|
||||
|
||||
private inline fun <reified T : Event> sign(
|
||||
kind: Int,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String = "",
|
||||
): T = assertIs<T>(signer.sign<Event>(createdAt, kind, tags, content))
|
||||
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
private val carol = "c".repeat(64)
|
||||
|
||||
@Test
|
||||
fun followListDiffsContactTags() {
|
||||
val older = sign<ContactListEvent>(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob, "wss://a.com/", "bobby")))
|
||||
val newer = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", bob, "wss://b.com/"), arrayOf("p", carol)), "")
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals(listOf(alice), diff.follows.removed.map { it.pubKey })
|
||||
assertEquals(listOf(carol), diff.follows.added.map { it.pubKey })
|
||||
val change = diff.follows.changed.single()
|
||||
assertEquals("bobby", change.before.petname)
|
||||
assertEquals("wss://b.com/", change.after.relayUri?.url)
|
||||
assertTrue(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun followListOnlyAddingDoesNotRemove() {
|
||||
val older = sign<ContactListEvent>(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}")
|
||||
val newer = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob)), "")
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertFalse(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun muteListDiffsMuteTagsAndPrivateItems() {
|
||||
val older =
|
||||
sign<MuteListEvent>(
|
||||
MuteListEvent.KIND,
|
||||
100,
|
||||
arrayOf(arrayOf("p", alice), arrayOf("t", "spam"), arrayOf("word", "crypto"), arrayOf("e", bob)),
|
||||
"encrypted-private-items",
|
||||
)
|
||||
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(arrayOf("word", "crypto")), "")
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
val removed = diff.publicMutes.removed
|
||||
assertEquals(3, removed.size)
|
||||
assertEquals(alice, assertIs<UserTag>(removed[0]).pubKey)
|
||||
assertEquals("spam", assertIs<HashtagTag>(removed[1]).hashtag)
|
||||
assertEquals(bob, assertIs<EventTag>(removed[2]).eventId)
|
||||
assertTrue(diff.publicMutes.added.none { it is WordTag })
|
||||
assertEquals(ContentChange.CLEARED, diff.privateItems)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rewrittenPrivateItemsAreChangedNotRemoved() {
|
||||
val older = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf(), "cipher-a")
|
||||
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(), "cipher-b")
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals(ContentChange.CHANGED, diff.privateItems)
|
||||
assertFalse(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun makingPublicItemsPrivateIsNotALoss() {
|
||||
val older = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("word", "crypto")), "")
|
||||
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(), "encrypted-now-holding-both")
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals(2, diff.publicMutes.removed.size)
|
||||
assertEquals(ContentChange.ADDED, diff.privateItems)
|
||||
assertFalse(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun droppingPublicItemsWhileRewritingPrivateOnesIsALoss() {
|
||||
val older = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "cipher-a")
|
||||
val newer = sign<MuteListEvent>(MuteListEvent.KIND, 200, arrayOf(), "cipher-b")
|
||||
assertTrue(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip65DiffsRelayTypes() {
|
||||
val older =
|
||||
sign<AdvertisedRelayListEvent>(
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
100,
|
||||
arrayOf(arrayOf("r", "wss://a.com/"), arrayOf("r", "wss://b.com/", "read"), arrayOf("r", "wss://c.com/")),
|
||||
)
|
||||
val newer =
|
||||
sign<AdvertisedRelayListEvent>(
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
200,
|
||||
arrayOf(arrayOf("r", "wss://a.com/", "write"), arrayOf("r", "wss://b.com/", "read"), arrayOf("client", "x")),
|
||||
)
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals(listOf("wss://c.com/"), diff.relays.removed.map { it.relayUrl.url })
|
||||
val change = diff.relays.changed.single()
|
||||
assertEquals(AdvertisedRelayType.BOTH, change.before.type)
|
||||
assertEquals(AdvertisedRelayType.WRITE, change.after.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayListsDiffNormalizedUrls() {
|
||||
val older = sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 100, arrayOf(arrayOf("relay", "wss://spam.com/")))
|
||||
val newer = sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 200, arrayOf())
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals(listOf(NormalizedRelayUrl("wss://spam.com/")), diff.relays.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileDiffsTypedFields() {
|
||||
val older = sign<MetadataEvent>(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":"","twitter":"vp"}""")
|
||||
val newer = sign<MetadataEvent>(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""")
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals("vitor", diff.name?.before)
|
||||
assertEquals("vitor2", diff.name?.after)
|
||||
assertNull(diff.about)
|
||||
assertTrue(assertNotNull(diff.lud16).isRemoval())
|
||||
assertEquals("https://b", diff.banner?.after)
|
||||
assertNull(diff.banner?.before)
|
||||
assertEquals(listOf("twitter" to "vp"), diff.otherFields.removed)
|
||||
assertTrue(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun droppingTheDefaultBotFlagIsNotALoss() {
|
||||
val older = sign<MetadataEvent>(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","bot":false}""")
|
||||
val newer = sign<MetadataEvent>(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor"}""")
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertNull(diff.bot)
|
||||
assertFalse(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupsDiffGroupTagsIncludingRenames() {
|
||||
val older = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("group", "abc", "wss://g.com", "Friends"), arrayOf("group", "xyz", "wss://g.com")))
|
||||
val newer = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 200, arrayOf(arrayOf("group", "abc", "wss://g.com", "Family")))
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals(listOf("xyz"), diff.groups.removed.map { it.groupId })
|
||||
assertEquals(
|
||||
"Family",
|
||||
diff.groups.changed
|
||||
.single()
|
||||
.after.name,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustProvidersDiffServiceProviderTags() {
|
||||
val older = sign<TrustProviderListEvent>(TrustProviderListEvent.KIND, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com")))
|
||||
val newer = sign<TrustProviderListEvent>(TrustProviderListEvent.KIND, 200, arrayOf())
|
||||
val removed = assertNotNull(newer.diffFrom(older)).providers.removed.single()
|
||||
assertEquals("rank", removed.service.type)
|
||||
assertEquals(alice, removed.pubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nutzapInfoDiffsMintsRelaysAndKey() {
|
||||
val older = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice)))
|
||||
val newer = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com", "sat", "usd"), arrayOf("pubkey", bob)))
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertEquals(
|
||||
listOf("sat", "usd"),
|
||||
diff.mints.changed
|
||||
.single()
|
||||
.after.units,
|
||||
)
|
||||
assertEquals(alice, diff.p2pkPubkey?.before)
|
||||
assertEquals(bob, diff.p2pkPubkey?.after)
|
||||
assertFalse(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherEventsAreNotComparable() {
|
||||
val mutes = sign<MuteListEvent>(MuteListEvent.KIND, 100, arrayOf())
|
||||
val follows = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf())
|
||||
val someoneElse = assertIs<ContactListEvent>(NostrSignerSync(KeyPair()).sign<Event>(300, ContactListEvent.KIND, arrayOf(), ""))
|
||||
assertNull(follows.diffFrom(mutes))
|
||||
assertNull(someoneElse.diffFrom(follows))
|
||||
}
|
||||
|
||||
// Bookkeeping tags other clients rewrite on every save: never user data.
|
||||
private val olderBookkeeping = arrayOf(arrayOf("client", "Amethyst"), arrayOf("nonce", "123", "20"), arrayOf("expiration", "1000"))
|
||||
private val newerBookkeeping = arrayOf(arrayOf("client", "OtherApp", "31990:abc:def"), arrayOf("nonce", "999", "16"), arrayOf("expiration", "5000"))
|
||||
|
||||
@Test
|
||||
fun clientPowAndExpirationTagsAreIgnored() {
|
||||
val follows = arrayOf(arrayOf("p", alice))
|
||||
val contacts =
|
||||
sign<ContactListEvent>(ContactListEvent.KIND, 200, follows + newerBookkeeping)
|
||||
.diffFrom(sign<ContactListEvent>(ContactListEvent.KIND, 100, follows + olderBookkeeping))
|
||||
assertTrue(assertNotNull(contacts).follows.isEmpty())
|
||||
|
||||
val mutes = arrayOf(arrayOf("word", "spam"))
|
||||
val muteDiff =
|
||||
assertNotNull(
|
||||
sign<MuteListEvent>(MuteListEvent.KIND, 200, mutes + newerBookkeeping)
|
||||
.diffFrom(sign<MuteListEvent>(MuteListEvent.KIND, 100, mutes + olderBookkeeping)),
|
||||
)
|
||||
assertTrue(muteDiff.publicMutes.isEmpty())
|
||||
assertEquals(ContentChange.NONE, muteDiff.privateItems)
|
||||
|
||||
val relays = arrayOf(arrayOf("r", "wss://a.com/"))
|
||||
val nip65 =
|
||||
sign<AdvertisedRelayListEvent>(AdvertisedRelayListEvent.KIND, 200, relays + newerBookkeeping)
|
||||
.diffFrom(sign<AdvertisedRelayListEvent>(AdvertisedRelayListEvent.KIND, 100, relays + olderBookkeeping))
|
||||
assertTrue(assertNotNull(nip65).relays.isEmpty())
|
||||
|
||||
val blocked = arrayOf(arrayOf("relay", "wss://spam.com/"))
|
||||
val blockedDiff =
|
||||
sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 200, blocked + newerBookkeeping)
|
||||
.diffFrom(sign<BlockedRelayListEvent>(BlockedRelayListEvent.KIND, 100, blocked + olderBookkeeping))
|
||||
assertTrue(assertNotNull(blockedDiff).relays.isEmpty())
|
||||
|
||||
val mints = arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice))
|
||||
val nutzap =
|
||||
assertNotNull(
|
||||
sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, mints + newerBookkeeping)
|
||||
.diffFrom(sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, mints + olderBookkeeping)),
|
||||
)
|
||||
assertTrue(nutzap.mints.isEmpty() && nutzap.relays.isEmpty())
|
||||
assertNull(nutzap.p2pkPubkey)
|
||||
|
||||
val profile = """{"name":"vitor"}"""
|
||||
val metadata =
|
||||
assertNotNull(
|
||||
sign<MetadataEvent>(MetadataEvent.KIND, 200, newerBookkeeping, profile)
|
||||
.diffFrom(sign<MetadataEvent>(MetadataEvent.KIND, 100, olderBookkeeping, profile)),
|
||||
)
|
||||
assertFalse(metadata.removesData())
|
||||
assertTrue(metadata.otherFields.isEmpty() && metadata.identityClaims.isEmpty())
|
||||
assertNull(metadata.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun droppingOnlyBookkeepingTagsIsNotALoss() {
|
||||
val older = sign<ContactListEvent>(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)) + olderBookkeeping)
|
||||
val newer = sign<ContactListEvent>(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice)))
|
||||
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listDiffMatchesByKey() {
|
||||
val diff = ListDiff.of(listOf(1 to "a", 2 to "b"), listOf(2 to "B", 3 to "c"), { it.first })
|
||||
assertEquals(listOf(1 to "a"), diff.removed)
|
||||
assertEquals(listOf(3 to "c"), diff.added)
|
||||
assertEquals(2 to "B", diff.changed.single().after)
|
||||
}
|
||||
|
||||
private fun nip65(
|
||||
createdAt: Long,
|
||||
vararg tags: Array<String>,
|
||||
) = sign<AdvertisedRelayListEvent>(AdvertisedRelayListEvent.KIND, createdAt, arrayOf(*tags))
|
||||
|
||||
@Test
|
||||
fun nip65RelayThatStopsBeingAnInboxIsALoss() {
|
||||
val older = nip65(100, arrayOf("r", "wss://a.com/"))
|
||||
val newer = nip65(200, arrayOf("r", "wss://a.com/", "write"))
|
||||
assertTrue(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip65RelayThatGainsARoleIsNotALoss() {
|
||||
val older = nip65(100, arrayOf("r", "wss://a.com/", "write"))
|
||||
val newer = nip65(200, arrayOf("r", "wss://a.com/"))
|
||||
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip65SplitReadAndWriteTagsMeanBoth() {
|
||||
val older = nip65(100, arrayOf("r", "wss://a.com/", "read"), arrayOf("r", "wss://a.com/", "write"))
|
||||
val newer = nip65(200, arrayOf("r", "wss://a.com/"))
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertTrue(diff.relays.isEmpty())
|
||||
assertFalse(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nutzapMintThatDropsAUnitIsALoss() {
|
||||
val older = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat", "usd")))
|
||||
val newer = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com", "sat")))
|
||||
assertTrue(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nutzapMintUrlSpellingAndUnitOrderAreNotChanges() {
|
||||
val older = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://Mint.com", "sat", "usd")))
|
||||
val newer = sign<NutzapInfoEvent>(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com/", "usd", "sat")))
|
||||
val diff = assertNotNull(newer.diffFrom(older))
|
||||
assertTrue(diff.mints.isEmpty())
|
||||
assertFalse(diff.removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupRelaySpellingIsNotARemoval() {
|
||||
val older = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("d", ""), arrayOf("group", "g1", "wss://groups.com")))
|
||||
val newer = sign<SimpleGroupListEvent>(SimpleGroupListEvent.KIND, 200, arrayOf(arrayOf("d", ""), arrayOf("group", "g1", "wss://groups.com/")))
|
||||
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun geohashCaseIsNotARemoval() {
|
||||
val older = sign<GeohashListEvent>(GeohashListEvent.KIND, 100, arrayOf(arrayOf("g", "9Q8")))
|
||||
val newer = sign<GeohashListEvent>(GeohashListEvent.KIND, 200, arrayOf(arrayOf("g", "9q8")))
|
||||
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun droppingDeprecatedProfileAliasesIsNotALoss() {
|
||||
val older = sign<MetadataEvent>(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","display_name":"Vitor","displayName":"Vitor","username":"vitor"}""")
|
||||
val newer = sign<MetadataEvent>(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor","display_name":"Vitor"}""")
|
||||
assertFalse(assertNotNull(newer.diffFrom(older)).removesData())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user