mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/sharp-keller-7itny0
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
# Replaceable-event backup conflicts
|
||||
|
||||
## Problem
|
||||
|
||||
`AccountSettings` keeps an on-disk backup of ~26 of the account's replaceable events
|
||||
(profile, follow list, mute list, relay lists, NIP-51 lists, wallet events, app settings).
|
||||
Each `*State` collector copies whatever is newest in `LocalCache` into that backup. When
|
||||
another client rewrites one of these events without reading the previous version (a fresh
|
||||
follow list, a mute list without the private items, a profile missing fields), the newer
|
||||
event wins on relays, lands in `LocalCache`, and the backup is overwritten with it: the
|
||||
only surviving copy of the user's data is gone.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Know what we signed.** `LocallySignedEvents` (commons, `model/backups/`) records the
|
||||
ids of replaceable/addressable events passing through `justConsumeMyOwnEvent`, the
|
||||
choke point for every local publish and for the backup restore at startup. It is a
|
||||
bounded LRU (500 ids): only the latest versions of each list matter.
|
||||
2. **Events diff themselves.** Every backed-up event implements
|
||||
`DiffableEvent<D>` (quartz, `nip01Core/diff/`): `diffFrom(older)` returns its own diff
|
||||
class built from its own parsed objects, e.g. `ContactListDiff(follows:
|
||||
ListDiff<ContactTag>)`, `MuteListDiff(publicMutes: ListDiff<MuteTag>, privateItems)`,
|
||||
`AdvertisedRelayListDiff(relays: ListDiff<AdvertisedRelayInfo>)`, `NutzapInfoDiff(mints:
|
||||
ListDiff<NutzapMintTag>, relays, p2pkPubkey: ValueChange<HexKey>)`, `MetadataDiff` with
|
||||
one `ValueChange` per `UserMetadata` field plus NIP-39 claims and unmodeled JSON fields.
|
||||
The relay-tag lists (DM, key package, search, indexer, feeds, blocked, trusted, private
|
||||
outbox) share `RelayListDiff`; encrypted-only events (Cashu wallet, Concord list, NIP-78
|
||||
data) diff their content as a whole.
|
||||
|
||||
Tags an event's parser doesn't read never reach its diff, so bookkeeping other clients
|
||||
rewrite on every save (`client`, NIP-13 `nonce`, NIP-40 `expiration`) is ignored by
|
||||
construction; `EventDiffTest.clientPowAndExpirationTagsAreIgnored` pins it.
|
||||
|
||||
`nip01Core/diff/` only holds the containers: `EventDiff` (`removesData()`),
|
||||
`ListDiff<T>` (items matched by an identity key; a matched pair with different details
|
||||
is a change, not a removal plus an addition), `ValueChange<T>`, and `ContentChange` for
|
||||
NIP-44 private items, which can't be compared item by item without decrypting.
|
||||
3. **Only question lossy external rewrites.** `BackupConflictGuard` (commons,
|
||||
`model/backups/`, tested in `BackupConflictGuardTest`) guards every `update*` backup
|
||||
method of `AccountSettings`, which owns one as `backupGuard`. A newer version that was not signed here goes through
|
||||
`ReplaceableBackupDiff.detectLoss` (commons), which asks the event for its diff and
|
||||
keeps it only when `removesData()`.
|
||||
|
||||
If nothing was dropped, the other app evidently built on the previous version, and the
|
||||
backup is updated silently as before.
|
||||
|
||||
Wiped versions (no tags, empty app data, a mints-less nutzap info) reach the guard too,
|
||||
so wiping everything is questioned like any other loss. An empty version only becomes
|
||||
the backup when this app signed it or the user kept it. The guard runs under a lock
|
||||
shared with the resolutions, and a version older than an open conflict's incoming can
|
||||
never replace or clear it (e.g. the retry of an accept that a newer change overtook).
|
||||
4. **Freeze and ask.** On a loss, the backup keeps the saved version and a
|
||||
`ReplaceableBackupConflict` is published on `BackupConflictGuard.conflicts`.
|
||||
Home shows a card per open conflict as the first item of each tab's feed
|
||||
(`BackupConflictCards`), so they scroll away with the posts instead of covering them;
|
||||
an empty, loading or failed feed shows them above its placeholder: "You changed your mute list in another app" with a
|
||||
"12 removed · 3 added" summary. Cards can't be dismissed; they stay until the user
|
||||
decides. The most recent conflict gets a lead card with an event-specific headline
|
||||
("You unblocked 3 relays", "You unfollowed 12 people" with its bar); the others collapse
|
||||
into slim pills. List headlines lead with what was removed and add
|
||||
what was added to the same list, worded per list ("You unfollowed 12 people and
|
||||
followed 40", "You left 2 groups and joined 1", "You unblocked 3 relays and blocked 1").
|
||||
All copy assumes the change was on purpose: the user did it in another client, and
|
||||
Amethyst is only confirming before its backup moves on. So it says what "you" did, in
|
||||
the past tense ("You left 1 public chat", not "You'd leave"), and never calls it a loss.
|
||||
Tapping one opens `Route.BackupConflictReview(slot)`
|
||||
(`BackupConflictReviewScreen`): a `LazyColumn` of every entry, so lists with hundreds of
|
||||
items scroll lazily. Entries are typed (`ReviewItem`, built per diff class in
|
||||
`BackupConflictPresentation.kt`) and rendered with the app's loaders, which subscribe to
|
||||
relays and recompose when data arrives: people (`LoadUser` + `UsernameDisplay`, tap →
|
||||
profile), muted threads (`LoadNote` + `NoteCompose`), communities and feeds
|
||||
(`LoadAddressableNote` + `NoteCompose`), public chats (`observeChannel`, tap → chat),
|
||||
ephemeral rooms (tap → room) and relays (tap → relay info). Leaving the screen without
|
||||
deciding keeps the card and the frozen backup.
|
||||
|
||||
The list is laid out per event (`BackupConflictEventViews.kt`, `eventDiffItems`), each
|
||||
leading with one picture of the change (`BackupConflictVisuals.kt`; red lost, green
|
||||
gained, amber changed, all from the theme): the follow list shows saved → new counts, a
|
||||
kept/dropped/new split bar, Dropped/New/Edited tabs and a searchable grid of faces; the
|
||||
mute list a shield with the unmuted count, per-kind tiles, a face pile, struck-through
|
||||
words and the private-items card; the profile both versions side by side as mini
|
||||
cards, then inline field diffs with images as thumbnails; NIP-65 outbox and inbox
|
||||
lanes; nutzap info a key swap
|
||||
drawn as color fingerprints with a warning, then mints; groups a tile per group, faded
|
||||
when you left it. The other lists (`BackupConflictListViews.kt`) get their own too:
|
||||
each relay list as per-relay rows tagged by what the list is for (DM, key-package,
|
||||
search, indexer, relay-feed, private-outbox and trusted relays; no "N of M" summary card,
|
||||
since the change is the tagged rows, not a reach number); blocked relays
|
||||
as an "N unblocked" warning with allowed / newly blocked / still blocked), public
|
||||
chats, communities, favorite feeds and ephemeral rooms as tiles loaded from relays and
|
||||
faded when you left them, hashtags and places as a pill cloud (places as "City · #geohash" through `LoadCityName`, each pill opening its feed), trust providers as a
|
||||
per-service before/after table, payment targets and BOLT12 offers as cards, and the
|
||||
encrypted-only Cashu wallet and Concord list as one explained panel. Only NIP-78 app
|
||||
settings still uses the generic removed / added / changed sections.
|
||||
Each card has an Accept button that keeps the new version right from Home (the change
|
||||
was made on purpose; reverting needs the review screen, a tap on the card away). The
|
||||
review screen is titled with the event and what happened to it ("Community list
|
||||
updated", "Mute list cleared").
|
||||
Buttons are worded per event ("Keep 120" / "Restore 523", "Re-mute 37", "Rejoin 3").
|
||||
Keeping the new version is the default (the filled button, on the right); restoring
|
||||
always reverts another app's change, so it is an outlined red button on every screen.
|
||||
The screen offers:
|
||||
- **Restore saved version** — `Account.restoreBackupOver` re-signs the saved kind, tags
|
||||
and content (NIP-44 self-encrypted items stay valid) with
|
||||
`created_at = max(now, incoming + 1)` (capped at 15 minutes ahead; a far-future
|
||||
external version is answered at "now"), dropping the old `client` tag (so the signer
|
||||
adds the current one), the `nonce` (its PoW was for the old id) and an already-past
|
||||
`expiration` (`BackupRestore.tagsToResign`), and publishes it through
|
||||
`EventBroadcaster.sendRestoredVersion`: where a normal save of that kind goes, and for
|
||||
the user's own relay lists (NIP-65, DM, key package) and nutzap info also to every
|
||||
relay the restored list names, since the lossy version may have dropped them from the
|
||||
outbox set; profiles, like their normal saves, go everywhere.
|
||||
- **Use new version** — `Account.acceptExternalVersion` whitelists that id and re-runs
|
||||
the original update, so the backup moves forward.
|
||||
|
||||
Both resolutions first *claim* the conflict: they act only if it is still the open
|
||||
conflict of its slot, so a stale screen, a double tap, or a conflict dropped because the
|
||||
wallet/nutzap info was deleted does nothing. While a restore is signing, its incoming
|
||||
version can't re-raise the conflict; a failed restore reopens it. The same incoming
|
||||
version re-emitted by the cache is ignored without re-diffing.
|
||||
|
||||
Public items removed while the private section went from empty to filled are treated as
|
||||
made private, not lost (`ContentChange.publicRemovalsAreLoss`).
|
||||
|
||||
While a conflict is open, edits made in Amethyst are compared against the frozen
|
||||
saved version too (they are built on top of the external one), so they cannot
|
||||
silently clear the conflict. They move the conflict's `incoming` forward but keep the
|
||||
external version as its `cause` (the screen shows that date).
|
||||
|
||||
Open conflicts are persisted with the backups (`BackupConflictStorage`, as the saved,
|
||||
incoming and cause events) and re-seeded on load through `BackupConflictGuard.restore`. The
|
||||
diff is recomputed, so a version the other app has since fixed stops being a conflict. A
|
||||
restored conflict has no retry closure until its version arrives again; keeping it rebuilds
|
||||
the update from the event's type (`AccountSettings.reapplyBackup`), except NIP-78, which
|
||||
lands on the next delivery.
|
||||
|
||||
## Known limits / follow-ups
|
||||
|
||||
- Private (encrypted) list items are only compared as "all gone"; a rewrite that drops
|
||||
*some* private items isn't detected. Decrypting both sides would fix it but needs the
|
||||
signer (an Amber round-trip), so it's left out of the passive path.
|
||||
- The ids of locally signed events are in memory only. If the app dies inside the 1s
|
||||
settings-save debounce right after a local removal, the next launch may ask once about
|
||||
our own event. Harmless (either answer keeps the data the user chose).
|
||||
- Edits from the same user on another Amethyst install count as "external" when they
|
||||
remove items. That's intended: the device can't tell them apart from a careless app.
|
||||
- Desktop marks its own events too but has no backup store or conflict UI yet.
|
||||
- Moving public items into a private section that already existed is still questioned: the
|
||||
private section re-encrypts on every save, so it only reads as "changed", and telling
|
||||
"made private" from "deleted" would need decrypting it.
|
||||
- A version dated more than 15 minutes in the future can't be outranked safely, so Restore
|
||||
refuses it (the conflict reopens) and only keeping it works.
|
||||
- Read-only (npub) accounts can't re-sign the saved version, so their review offers only
|
||||
keeping the new one.
|
||||
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
|
||||
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
|
||||
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
|
||||
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
@@ -458,13 +459,14 @@ class AppModules(
|
||||
scope = applicationIOScope,
|
||||
dns = surgeDns,
|
||||
// Transparently rewrites sha256-keyed HTTP requests to the local
|
||||
// Blossom cache when the master toggle is on, the profile-pictures-only
|
||||
// restriction is off, and the probe sees 127.0.0.1:24242 as available.
|
||||
shouldBridgeBlossomCache = {
|
||||
// Blossom cache when the master toggle is on, the probe sees
|
||||
// 127.0.0.1:24242 as available, and either the profile-pictures-only
|
||||
// restriction is off or the request is a profile picture.
|
||||
shouldBridgeBlossomCache = { profilePicture ->
|
||||
val settings = sessionManager.loggedInAccount()?.settings
|
||||
val master = settings?.useLocalBlossomCache?.value ?: false
|
||||
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
|
||||
master && !profileOnly && localBlossomCacheProbe.available.value
|
||||
master && (profilePicture || !profileOnly) && localBlossomCacheProbe.available.value
|
||||
},
|
||||
onionCache = onionLocationCache,
|
||||
usageInterceptor = httpUsageInterceptor,
|
||||
@@ -477,6 +479,7 @@ class AppModules(
|
||||
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
|
||||
onAuthRequired = blossomReadAuthTokens::warm,
|
||||
),
|
||||
onLocalBlossomCacheUnreachable = { localBlossomCacheProbe.markUnavailable() },
|
||||
)
|
||||
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
@@ -1035,14 +1038,16 @@ class AppModules(
|
||||
}
|
||||
},
|
||||
httpClientBuilder = roleBasedHttpClientBuilder,
|
||||
// Same gate as the interceptor: the profile-pictures-only restriction keeps note
|
||||
// media (including native `blossom:` URIs) off the local cache.
|
||||
useLocalBlossomCache = {
|
||||
sessionManager
|
||||
.loggedInAccount()
|
||||
?.settings
|
||||
?.useLocalBlossomCache
|
||||
?.value ?: false
|
||||
val settings = sessionManager.loggedInAccount()?.settings
|
||||
val master = settings?.useLocalBlossomCache?.value ?: false
|
||||
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
|
||||
master && !profileOnly
|
||||
},
|
||||
localCacheProbe = localBlossomCacheProbe,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1148,7 +1153,8 @@ class AppModules(
|
||||
blossomServerResolver = { blossomResolver },
|
||||
// Through the role builder (not raw getHttpClient) so Coil's image
|
||||
// traffic carries the "image" ledger tag. Same Tor decision inside.
|
||||
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
|
||||
// Marked as media so the local Blossom cache may serve these downloads.
|
||||
callFactory = { LocalBlossomMediaCallFactory(roleBasedHttpClientBuilder.okHttpClientForImage(it)) },
|
||||
thumbnailCache = thumbnailDiskCache,
|
||||
backgroundScope = applicationIOScope,
|
||||
readAuth = blossomReadAuthTokens,
|
||||
@@ -1341,8 +1347,7 @@ class AppModules(
|
||||
state.account.settings.localBlossomCacheProfilePicturesOnly
|
||||
.drop(1),
|
||||
).collect {
|
||||
blossomResolver.uriToUrlCache.evictAll()
|
||||
blossomResolver.blossomHitCache.cache.evictAll()
|
||||
blossomResolver.clearCaches()
|
||||
localBlossomCacheProbe.invalidate()
|
||||
// Re-probe immediately so enabling the feature activates it
|
||||
// this session. Otherwise `available` only advances when a
|
||||
@@ -1358,8 +1363,14 @@ class AppModules(
|
||||
}
|
||||
applicationIOScope.launch {
|
||||
localBlossomCacheProbe.available.drop(1).collect {
|
||||
blossomResolver.uriToUrlCache.evictAll()
|
||||
blossomResolver.blossomHitCache.cache.evictAll()
|
||||
blossomResolver.clearCaches()
|
||||
}
|
||||
}
|
||||
// A resolution to the local cache made while a media type was not Tor-routed must not
|
||||
// survive the user switching it to Tor: the cache would keep fetching it outside Tor.
|
||||
applicationIOScope.launch {
|
||||
torPrefs.value.propertyWatchFlow.drop(1).collect {
|
||||
blossomResolver.clearCaches()
|
||||
}
|
||||
}
|
||||
// Warm the local-cache probe so the very first image load doesn't pay
|
||||
@@ -1367,6 +1378,22 @@ class AppModules(
|
||||
applicationIOScope.launch {
|
||||
localBlossomCacheProbe.isAvailable()
|
||||
}
|
||||
// Re-checks the local cache about once a minute while the feature is on, so the bridge
|
||||
// comes back when the cache app is restarted and turns off when it is closed, even while
|
||||
// no `blossom:` URI is being resolved. A loopback HEAD never wakes the radio.
|
||||
applicationIOScope.launch {
|
||||
while (true) {
|
||||
delay(LOCAL_BLOSSOM_CACHE_RECHECK_MS)
|
||||
if (sessionManager
|
||||
.loggedInAccount()
|
||||
?.settings
|
||||
?.useLocalBlossomCache
|
||||
?.value == true
|
||||
) {
|
||||
localBlossomCacheProbe.isAvailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
|
||||
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
|
||||
@@ -1505,5 +1532,8 @@ class AppModules(
|
||||
* check and burn CPU on a heap that has nothing left to give.
|
||||
*/
|
||||
private const val MIN_RECLAIM_INTERVAL_MS = 120_000L
|
||||
|
||||
/** How often the local Blossom cache is re-probed while the feature is enabled. */
|
||||
private const val LOCAL_BLOSSOM_CACHE_RECHECK_MS = 60_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
@@ -159,6 +160,7 @@ private object PrefKeys {
|
||||
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
|
||||
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
|
||||
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
|
||||
const val OPEN_BACKUP_CONFLICTS = "openBackupConflicts"
|
||||
const val LATEST_USER_METADATA = "latestUserMetadata"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
|
||||
@@ -582,6 +584,16 @@ object LocalPreferences {
|
||||
|
||||
putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
|
||||
|
||||
// The undecided conflicts themselves, not just the backups they hold back.
|
||||
// Without these the card vanishes on the next launch and the user never
|
||||
// answers the question the backup is still waiting on.
|
||||
val openConflicts = settings.openBackupConflicts()
|
||||
if (openConflicts.isEmpty()) {
|
||||
remove(PrefKeys.OPEN_BACKUP_CONFLICTS)
|
||||
} else {
|
||||
putString(PrefKeys.OPEN_BACKUP_CONFLICTS, BackupConflictStorage.encode(openConflicts))
|
||||
}
|
||||
|
||||
putOrRemove(PrefKeys.LATEST_USER_METADATA, settings.backupUserMetadata)
|
||||
putOrRemove(PrefKeys.LATEST_DM_RELAY_LIST, settings.backupDMRelayList)
|
||||
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
|
||||
@@ -808,6 +820,7 @@ object LocalPreferences {
|
||||
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
|
||||
|
||||
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
|
||||
val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null)
|
||||
val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null)
|
||||
val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null)
|
||||
val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null)
|
||||
@@ -1064,7 +1077,7 @@ object LocalPreferences {
|
||||
backupCashuWallet = latestCashuWalletResolved,
|
||||
backupNutzapInfo = latestNutzapInfoResolved,
|
||||
callsEnabled = MutableStateFlow(callsEnabled),
|
||||
)
|
||||
).also { it.restoreBackupConflicts(BackupConflictStorage.decode(openBackupConflictsStr)) }
|
||||
}
|
||||
}
|
||||
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
|
||||
|
||||
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupRestore
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
@@ -1889,6 +1891,40 @@ class Account(
|
||||
|
||||
fun sendLiterallyEverywhere(event: Event) = broadcaster.sendLiterallyEverywhere(event)
|
||||
|
||||
/**
|
||||
* Resolves a [ReplaceableBackupConflict] in favor of this device: re-signs the saved
|
||||
* version (same kind, tags and content, so NIP-44 private items stay readable, minus the
|
||||
* old client, proof-of-work and past expiration tags; see [BackupRestore.tagsToResign])
|
||||
* with a timestamp newer than the current one, and publishes it so it replaces that version.
|
||||
*
|
||||
* Does nothing if the conflict is stale (already resolved, replaced by a newer one, or
|
||||
* dropped because the event was deleted), which also makes a double tap harmless.
|
||||
*/
|
||||
suspend fun restoreBackupOver(conflict: ReplaceableBackupConflict) {
|
||||
val token = settings.backupGuard.startRestoring(conflict) ?: return
|
||||
var restored = false
|
||||
try {
|
||||
val saved = conflict.saved
|
||||
val now = TimeUtils.now()
|
||||
// Newer than the version it replaces, but never far in the future: relays reject
|
||||
// those, and later edits signed at "now" would lose to it. A far-future external
|
||||
// version can't be outranked safely, and a restore signed at "now" would lose to it
|
||||
// everywhere, so it isn't sent: the conflict reopens and only keeping it works.
|
||||
val createdAt = (conflict.incoming.createdAt + 1).coerceAtLeast(now)
|
||||
if (createdAt > now + maxRestoreFutureSeconds) return
|
||||
val resigned = signer.sign<Event>(createdAt, saved.kind, BackupRestore.tagsToResign(saved, now), saved.content)
|
||||
broadcaster.sendRestoredVersion(resigned)
|
||||
restored = true
|
||||
} finally {
|
||||
settings.backupGuard.finishRestoring(conflict, token, restored)
|
||||
}
|
||||
}
|
||||
|
||||
private val maxRestoreFutureSeconds = 15 * 60L
|
||||
|
||||
/** Resolves a [ReplaceableBackupConflict] in favor of the current version. No-op when stale. */
|
||||
fun acceptExternalVersion(conflict: ReplaceableBackupConflict) = settings.backupGuard.keepIncoming(conflict)
|
||||
|
||||
suspend fun <T : Event> signAndSendPrivately(
|
||||
template: EventTemplate<T>,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
|
||||
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
|
||||
import com.vitorpamplona.amethyst.commons.cashu.UnavailableCashuKeysetCounterStore
|
||||
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.BackupConflictGuard
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.backupSlotOf
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
|
||||
@@ -51,6 +53,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEve
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -1041,6 +1044,69 @@ class AccountSettings(
|
||||
// Backup Lists
|
||||
// ----
|
||||
|
||||
/**
|
||||
* Holds back external rewrites that dropped data from a backup, see [BackupConflictGuard].
|
||||
* Home shows a card for each open conflict until the user keeps the new version or
|
||||
* restores the saved one. Open conflicts are saved with the backups they hold back.
|
||||
*/
|
||||
val backupGuard =
|
||||
BackupConflictGuard(
|
||||
onChange = { saveAccountSettings() },
|
||||
reapply = { reapplyBackup(it) },
|
||||
)
|
||||
|
||||
val backupConflicts get() = backupGuard.conflicts
|
||||
|
||||
/** Re-seeds the open conflicts read back from storage, see [BackupConflictGuard.restore]. */
|
||||
fun restoreBackupConflicts(saved: List<Triple<Event, Event, Event>>) = backupGuard.restore(saved)
|
||||
|
||||
/** The open conflicts, as the three events storage needs to rebuild each one. */
|
||||
fun openBackupConflicts(): List<Triple<Event, Event, Event>> = backupGuard.open()
|
||||
|
||||
/**
|
||||
* Re-runs the update that a restored conflict no longer carries.
|
||||
*
|
||||
* The guard's retries are closures, so they cannot be written to disk; a conflict read
|
||||
* back after a restart has none until its version happens to arrive again. Keeping the new
|
||||
* version must work regardless, and every update is the same shape — hand the event to the
|
||||
* function that owns its slot — so the slot can be recovered from the event's own type.
|
||||
*/
|
||||
private fun reapplyBackup(incoming: Event) {
|
||||
when (incoming) {
|
||||
is MetadataEvent -> updateUserMetadata(incoming)
|
||||
is ContactListEvent -> updateContactListTo(incoming)
|
||||
is ChatMessageRelayListEvent -> updateDMRelayList(incoming)
|
||||
is KeyPackageRelayListEvent -> updateKeyPackageRelayList(incoming)
|
||||
is AdvertisedRelayListEvent -> updateNIP65RelayList(incoming)
|
||||
is CashuWalletEvent -> updateCashuWallet(incoming)
|
||||
is NutzapInfoEvent -> updateNutzapInfo(incoming)
|
||||
is PaymentTargetsEvent -> updateNIPA3PaymentTargets(incoming)
|
||||
is Bolt12OfferListEvent -> updateBolt12Offers(incoming)
|
||||
is SearchRelayListEvent -> updateSearchRelayList(incoming)
|
||||
is IndexerRelayListEvent -> updateIndexRelayList(incoming)
|
||||
is RelayFeedsListEvent -> updateRelayFeedList(incoming)
|
||||
is BlockedRelayListEvent -> updateBlockedRelayList(incoming)
|
||||
is TrustedRelayListEvent -> updateTrustedRelayList(incoming)
|
||||
is PrivateOutboxRelayListEvent -> updatePrivateHomeRelayList(incoming)
|
||||
is ChannelListEvent -> updateChannelListTo(incoming)
|
||||
is GeohashListEvent -> updateGeohashListTo(incoming)
|
||||
is HashtagListEvent -> updateHashtagListTo(incoming)
|
||||
is FavoriteAlgoFeedsListEvent -> updateFavoriteAlgoFeedsListTo(incoming)
|
||||
is CommunityListEvent -> updateCommunityListTo(incoming)
|
||||
is EphemeralChatListEvent -> updateEphemeralChatListTo(incoming)
|
||||
is SimpleGroupListEvent -> updateRelayGroupListTo(incoming)
|
||||
is ConcordCommunityListEvent -> updateConcordListTo(incoming)
|
||||
is TrustProviderListEvent -> updateTrustProviderListTo(incoming)
|
||||
is MuteListEvent -> updateMuteList(incoming)
|
||||
// NIP-78 only raises a conflict when its blob was emptied, and an empty blob is
|
||||
// stored without merging the settings handed with it, so defaults are safe here.
|
||||
// A non-empty one needs the decrypted settings only the caller has; it lands when
|
||||
// the event next arrives, already recorded as accepted.
|
||||
is AppSpecificDataEvent -> if (incoming.content.isBlank()) updateAppSpecificData(incoming, AccountSyncedSettingsInternal())
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLocalRelayServers(servers: Set<String>) {
|
||||
if (localRelayServers.value != servers) {
|
||||
localRelayServers.update { servers }
|
||||
@@ -1059,48 +1125,43 @@ class AccountSettings(
|
||||
fun updateUserMetadata(newMetadata: MetadataEvent?) {
|
||||
if (newMetadata == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupUserMetadata?.id != newMetadata.id) {
|
||||
if (backupGuard.accept(backupUserMetadata, newMetadata) { updateUserMetadata(newMetadata) }) {
|
||||
backupUserMetadata = newMetadata
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList == null || newContactList.tags.isEmpty()) return
|
||||
if (newContactList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList.id) {
|
||||
if (backupGuard.accept(backupContactList, newContactList, isEmpty = newContactList.tags.isEmpty()) { updateContactListTo(newContactList) }) {
|
||||
backupContactList = newContactList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
|
||||
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
|
||||
if (newDMRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupDMRelayList?.id != newDMRelayList.id) {
|
||||
if (backupGuard.accept(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) {
|
||||
backupDMRelayList = newDMRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateKeyPackageRelayList(newKeyPackageRelayList: KeyPackageRelayListEvent?) {
|
||||
if (newKeyPackageRelayList == null || newKeyPackageRelayList.tags.isEmpty()) return
|
||||
if (newKeyPackageRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupKeyPackageRelayList?.id != newKeyPackageRelayList.id) {
|
||||
if (backupGuard.accept(backupKeyPackageRelayList, newKeyPackageRelayList, isEmpty = newKeyPackageRelayList.tags.isEmpty()) { updateKeyPackageRelayList(newKeyPackageRelayList) }) {
|
||||
backupKeyPackageRelayList = newKeyPackageRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
|
||||
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
|
||||
if (newNIP65RelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
|
||||
if (backupGuard.accept(backupNIP65RelayList, newNIP65RelayList, isEmpty = newNIP65RelayList.tags.isEmpty()) { updateNIP65RelayList(newNIP65RelayList) }) {
|
||||
backupNIP65RelayList = newNIP65RelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1109,14 +1170,17 @@ class AccountSettings(
|
||||
fun updateCashuWallet(newWallet: CashuWalletEvent?) {
|
||||
if (newWallet == null) return
|
||||
// Replaceable: keep the latest by id (id changes on each re-sign).
|
||||
if (backupCashuWallet?.id != newWallet.id) {
|
||||
if (backupGuard.accept(backupCashuWallet, newWallet) { updateCashuWallet(newWallet) }) {
|
||||
backupCashuWallet = newWallet
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) {
|
||||
if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return
|
||||
if (newNutzapInfo == null) return
|
||||
// The guard runs first, so another app wiping the nutzap info is questioned like any
|
||||
// other lossy rewrite instead of silently clearing the backup.
|
||||
if (!backupGuard.accept(backupNutzapInfo, newNutzapInfo) { updateNutzapInfo(newNutzapInfo) }) return
|
||||
// A mints-less kind:10019 is the "stop receiving nutzaps" tombstone
|
||||
// (an empty replacement carrying only an `alt` tag). Don't restore it
|
||||
// on next launch — backing it up would undo clearNutzapInfo() once the
|
||||
@@ -1125,10 +1189,8 @@ class AccountSettings(
|
||||
clearNutzapInfo()
|
||||
return
|
||||
}
|
||||
if (backupNutzapInfo?.id != newNutzapInfo.id) {
|
||||
backupNutzapInfo = newNutzapInfo
|
||||
saveAccountSettings()
|
||||
}
|
||||
backupNutzapInfo = newNutzapInfo
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1138,6 +1200,8 @@ class AccountSettings(
|
||||
* LocalCache on next launch and resurrect the deleted wallet.
|
||||
*/
|
||||
fun clearCashuWallet() {
|
||||
// A deleted wallet must not be restorable from an open conflict.
|
||||
backupGuard.drop(backupSlotOf(CashuWalletEvent.KIND))
|
||||
if (backupCashuWallet != null) {
|
||||
backupCashuWallet = null
|
||||
saveAccountSettings()
|
||||
@@ -1146,6 +1210,7 @@ class AccountSettings(
|
||||
|
||||
/** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */
|
||||
fun clearNutzapInfo() {
|
||||
backupGuard.drop(backupSlotOf(NutzapInfoEvent.KIND))
|
||||
if (backupNutzapInfo != null) {
|
||||
backupNutzapInfo = null
|
||||
saveAccountSettings()
|
||||
@@ -1184,80 +1249,72 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
|
||||
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
|
||||
if (newNIPA3PaymentTargets == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) {
|
||||
if (backupGuard.accept(backupNipA3PaymentTargets, newNIPA3PaymentTargets, isEmpty = newNIPA3PaymentTargets.tags.isEmpty()) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) {
|
||||
backupNipA3PaymentTargets = newNIPA3PaymentTargets
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
|
||||
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
|
||||
if (newBolt12Offers == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupBolt12Offers?.id != newBolt12Offers.id) {
|
||||
if (backupGuard.accept(backupBolt12Offers, newBolt12Offers, isEmpty = newBolt12Offers.tags.isEmpty()) { updateBolt12Offers(newBolt12Offers) }) {
|
||||
backupBolt12Offers = newBolt12Offers
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
if (newSearchRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupSearchRelayList?.id != newSearchRelayList.id) {
|
||||
if (backupGuard.accept(backupSearchRelayList, newSearchRelayList, isEmpty = newSearchRelayList.tags.isEmpty()) { updateSearchRelayList(newSearchRelayList) }) {
|
||||
backupSearchRelayList = newSearchRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateIndexRelayList(newIndexRelayList: IndexerRelayListEvent?) {
|
||||
if (newIndexRelayList == null || newIndexRelayList.tags.isEmpty()) return
|
||||
if (newIndexRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupIndexRelayList?.id != newIndexRelayList.id) {
|
||||
if (backupGuard.accept(backupIndexRelayList, newIndexRelayList, isEmpty = newIndexRelayList.tags.isEmpty()) { updateIndexRelayList(newIndexRelayList) }) {
|
||||
backupIndexRelayList = newIndexRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) {
|
||||
if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return
|
||||
if (newRelayFeedList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupRelayFeedsList?.id != newRelayFeedList.id) {
|
||||
if (backupGuard.accept(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) {
|
||||
backupRelayFeedsList = newRelayFeedList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
|
||||
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
|
||||
if (newBlockedRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupBlockedRelayList?.id != newBlockedRelayList.id) {
|
||||
if (backupGuard.accept(backupBlockedRelayList, newBlockedRelayList, isEmpty = newBlockedRelayList.tags.isEmpty()) { updateBlockedRelayList(newBlockedRelayList) }) {
|
||||
backupBlockedRelayList = newBlockedRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) {
|
||||
if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return
|
||||
if (newTrustedRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupTrustedRelayList?.id != newTrustedRelayList.id) {
|
||||
if (backupGuard.accept(backupTrustedRelayList, newTrustedRelayList, isEmpty = newTrustedRelayList.tags.isEmpty()) { updateTrustedRelayList(newTrustedRelayList) }) {
|
||||
backupTrustedRelayList = newTrustedRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
|
||||
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
|
||||
if (newPrivateHomeRelayList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
|
||||
if (backupGuard.accept(backupPrivateHomeRelayList, newPrivateHomeRelayList, isEmpty = newPrivateHomeRelayList.tags.isEmpty()) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) {
|
||||
backupPrivateHomeRelayList = newPrivateHomeRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1266,50 +1323,45 @@ class AccountSettings(
|
||||
override fun channelList() = backupChannelList
|
||||
|
||||
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
|
||||
if (newChannelList == null || newChannelList.tags.isEmpty()) return
|
||||
if (newChannelList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupChannelList?.id != newChannelList.id) {
|
||||
if (backupGuard.accept(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) {
|
||||
backupChannelList = newChannelList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGeohashListTo(newGeohashList: GeohashListEvent?) {
|
||||
if (newGeohashList == null || newGeohashList.tags.isEmpty()) return
|
||||
if (newGeohashList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupGeohashList?.id != newGeohashList.id) {
|
||||
if (backupGuard.accept(backupGeohashList, newGeohashList, isEmpty = newGeohashList.tags.isEmpty()) { updateGeohashListTo(newGeohashList) }) {
|
||||
backupGeohashList = newGeohashList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateHashtagListTo(newHashtagList: HashtagListEvent?) {
|
||||
if (newHashtagList == null || newHashtagList.tags.isEmpty()) return
|
||||
if (newHashtagList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupHashtagList?.id != newHashtagList.id) {
|
||||
if (backupGuard.accept(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) {
|
||||
backupHashtagList = newHashtagList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) {
|
||||
if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return
|
||||
if (newFavoriteDvmList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) {
|
||||
if (backupGuard.accept(backupFavoriteAlgoFeedsList, newFavoriteDvmList, isEmpty = newFavoriteDvmList.tags.isEmpty()) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) {
|
||||
backupFavoriteAlgoFeedsList = newFavoriteDvmList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
|
||||
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
|
||||
if (newCommunityList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupCommunityList?.id != newCommunityList.id) {
|
||||
if (backupGuard.accept(backupCommunityList, newCommunityList, isEmpty = newCommunityList.tags.isEmpty()) { updateCommunityListTo(newCommunityList) }) {
|
||||
backupCommunityList = newCommunityList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1318,10 +1370,9 @@ class AccountSettings(
|
||||
override fun ephemeralChatList() = backupEphemeralChatList
|
||||
|
||||
override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
|
||||
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
|
||||
if (newEphemeralChatList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupEphemeralChatList?.id != newEphemeralChatList.id) {
|
||||
if (backupGuard.accept(backupEphemeralChatList, newEphemeralChatList, isEmpty = newEphemeralChatList.tags.isEmpty()) { updateEphemeralChatListTo(newEphemeralChatList) }) {
|
||||
backupEphemeralChatList = newEphemeralChatList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1334,8 +1385,7 @@ class AccountSettings(
|
||||
// so an empty `tags` is NOT an empty list — guard only on null.
|
||||
if (newRelayGroupList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupRelayGroupList?.id != newRelayGroupList.id) {
|
||||
if (backupGuard.accept(backupRelayGroupList, newRelayGroupList) { updateRelayGroupListTo(newRelayGroupList) }) {
|
||||
backupRelayGroupList = newRelayGroupList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1348,27 +1398,25 @@ class AccountSettings(
|
||||
// so an empty `tags` is NOT an empty list — guard only on null.
|
||||
if (newConcordList == null) return
|
||||
|
||||
if (backupConcordList?.id != newConcordList.id) {
|
||||
if (backupGuard.accept(backupConcordList, newConcordList) { updateConcordListTo(newConcordList) }) {
|
||||
backupConcordList = newConcordList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
|
||||
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
|
||||
if (trustProviderList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupTrustProviderList?.id != trustProviderList.id) {
|
||||
if (backupGuard.accept(backupTrustProviderList, trustProviderList, isEmpty = trustProviderList.tags.isEmpty()) { updateTrustProviderListTo(trustProviderList) }) {
|
||||
backupTrustProviderList = trustProviderList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMuteList(newMuteList: MuteListEvent?) {
|
||||
if (newMuteList == null || newMuteList.tags.isEmpty()) return
|
||||
if (newMuteList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupMuteList?.id != newMuteList.id) {
|
||||
if (backupGuard.accept(backupMuteList, newMuteList, isEmpty = newMuteList.tags.isEmpty()) { updateMuteList(newMuteList) }) {
|
||||
backupMuteList = newMuteList
|
||||
saveAccountSettings()
|
||||
}
|
||||
@@ -1378,11 +1426,15 @@ class AccountSettings(
|
||||
appSettings: AppSpecificDataEvent?,
|
||||
newSyncedSettings: AccountSyncedSettingsInternal,
|
||||
) {
|
||||
if (appSettings == null || appSettings.content.isEmpty()) return
|
||||
if (appSettings == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupAppSpecificData?.id != appSettings.id) {
|
||||
if (backupGuard.accept(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isBlank()) { updateAppSpecificData(appSettings, newSyncedSettings) }) {
|
||||
backupAppSpecificData = appSettings
|
||||
// An emptied blob (signed here or accepted by the user) has no settings to merge.
|
||||
if (appSettings.content.isBlank()) {
|
||||
saveAccountSettings()
|
||||
return
|
||||
}
|
||||
syncedSettings.updateFrom(newSyncedSettings)
|
||||
|
||||
// Null means an older client rewrote the blob without this key — leave the
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filter
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
@@ -49,6 +51,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
@@ -369,6 +373,35 @@ class EventBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a re-signed backup that replaces a lossy version from another app. It goes
|
||||
* where a normal save of that event goes; lists of the user's own relays (NIP-65, DM,
|
||||
* key package) and the wallet go everywhere, and also to every relay the restored list
|
||||
* names, since the lossy version may have removed them from the outbox set. Profiles and
|
||||
* nutzap info go everywhere too, like their normal saves.
|
||||
*/
|
||||
fun sendRestoredVersion(event: Event) {
|
||||
when (event) {
|
||||
is AdvertisedRelayListEvent -> sendEverywhereAnd(event, event.relays().mapTo(mutableSetOf()) { it.relayUrl })
|
||||
is ChatMessageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
is KeyPackageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
is CashuWalletEvent -> sendLiterallyEverywhere(event)
|
||||
// Profiles and nutzap info are normally saved everywhere so others can find them;
|
||||
// the restore must reach the same relays or they keep serving the lossy version.
|
||||
is MetadataEvent -> sendLiterallyEverywhere(event)
|
||||
is NutzapInfoEvent -> sendEverywhereAnd(event, event.relays().toSet())
|
||||
else -> sendMyPublicAndPrivateOutbox(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendEverywhereAnd(
|
||||
event: Event,
|
||||
extraRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + extraRelays)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
fun sendLiterallyEverywhere(event: Event) {
|
||||
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
|
||||
+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)
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
|
||||
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
|
||||
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
|
||||
import okhttp3.Call
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -44,18 +45,49 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
class BlossomFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val blossomServerResolver: () -> BlossomServerResolver,
|
||||
private val networkFetcher: (url: String) -> Fetcher,
|
||||
private val networkFetcher: (url: String, diskCacheKey: String) -> Fetcher,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? =
|
||||
try {
|
||||
val urlResult = blossomServerResolver().findServers(data.toString())
|
||||
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
|
||||
val uri = data.toString()
|
||||
val key = options.diskCacheKey ?: diskCacheKey(uri)
|
||||
|
||||
// A blob already on disk is served without resolving: resolution may HEAD
|
||||
// servers and wait on relays, and fails outright offline, where the bytes are
|
||||
// still sitting in the cache. NetworkFetcher reads the snapshot under [key]
|
||||
// before it ever looks at the url.
|
||||
val onDisk = options.diskCachePolicy.readEnabled && imageLoader.diskCache?.openSnapshot(key)?.use { true } ?: false
|
||||
val fromDisk =
|
||||
if (onDisk) {
|
||||
// Evicted between the check and the read: the fetcher would try the
|
||||
// network with the unresolvable `blossom:` uri, so resolve after all.
|
||||
try {
|
||||
networkFetcher(uri, key).fetch()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fromDisk ?: networkFetcher(blossomServerResolver().findServers(uri)?.serverUrl ?: uri, key).fetch()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Blossom blobs are content-addressed, so the sha256 names the bytes wherever they
|
||||
* were served from. Keying on it (instead of the resolved server URL) finds the
|
||||
* blob again after a restart, a probe flip or a different server answering.
|
||||
*/
|
||||
fun diskCacheKey(uri: String): String = BlossomUri.parse(uri)?.let { "blossom:${it.sha256}" } ?: uri
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
class Factory(
|
||||
val blossomServerResolver: () -> BlossomServerResolver,
|
||||
@@ -78,20 +110,19 @@ class BlossomFetcher(
|
||||
if (!isApplicable(data)) return null
|
||||
// Wrapped per resolved url (not per Factory) because the server the
|
||||
// blob actually lives on is only known once the resolver has run.
|
||||
return BlossomFetcher(options, data, blossomServerResolver) { url ->
|
||||
return BlossomFetcher(options, data, imageLoader, blossomServerResolver) { url, diskCacheKey ->
|
||||
val keyedOptions = options.copy(diskCacheKey = diskCacheKey)
|
||||
readAuthAware(url, readAuth) { authHeader ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
options = keyedOptions.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = concurrentRequestStrategyLazy,
|
||||
)
|
||||
// Keyed on the resolved server url, which is what the NetworkFetcher above
|
||||
// caches under -- not on the `blossom:` uri the request came in as.
|
||||
}.onSystemFileSystem(options.diskCacheKey ?: url)
|
||||
}.onSystemFileSystem(diskCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -87,9 +87,10 @@ class ImageLoaderSetup {
|
||||
// coordinates through a map of in-flight fetches that it owns, so it only
|
||||
// works when every fetcher shares the same instance -- a fresh one per
|
||||
// request can never see anybody else's fetch and the de-dupe silently
|
||||
// no-ops. Shared across all three network-backed factories so a feed
|
||||
// image and the same blob reached through `blossom:` (or a profile
|
||||
// picture) still collapse onto one download.
|
||||
// no-ops. Shared across all three network-backed factories so the same
|
||||
// key requested through any of them (e.g. a feed image and a profile
|
||||
// picture with the same URL) collapses onto one download. `blossom:`
|
||||
// blobs are keyed by their sha256 instead (see BlossomFetcher.diskCacheKey).
|
||||
val concurrentRequests = DeDupeConcurrentRequestStrategy()
|
||||
|
||||
SingletonImageLoader.setUnsafe(
|
||||
|
||||
+4
-1
@@ -36,6 +36,8 @@ import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvider
|
||||
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor.Companion.PROFILE_PICTURE
|
||||
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
|
||||
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
|
||||
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
|
||||
@@ -121,7 +123,8 @@ class ProfilePictureFetcher(
|
||||
NetworkFetcher(
|
||||
url = data.url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(data.url).asNetworkClient() },
|
||||
// Marked so the local Blossom cache bridge can honour "profile pictures only".
|
||||
networkClient = lazy { LocalBlossomMediaCallFactory(networkClient(data.url), PROFILE_PICTURE).asNetworkClient() },
|
||||
diskCache = diskCacheLazy,
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
|
||||
+6
-1
@@ -39,6 +39,7 @@ import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DynamicCallFactory
|
||||
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
|
||||
@@ -59,7 +60,11 @@ class PlaybackService : MediaSessionService() {
|
||||
okHttpClient: DynamicCallFactory,
|
||||
blossomServerResolver: BlossomServerResolver,
|
||||
): MediaSessionPool {
|
||||
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
|
||||
// Marked as media so the local Blossom cache may serve these downloads.
|
||||
val dataSourceFactory =
|
||||
OkHttpDataSource
|
||||
.Factory(okHttpClient)
|
||||
.setDefaultRequestProperties(mapOf(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER to LocalBlossomCacheRedirectInterceptor.MEDIA))
|
||||
|
||||
val resolvingDataSourceFactory: DataSource.Factory =
|
||||
ResolvingDataSource.Factory(
|
||||
|
||||
+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() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-13
@@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service.resourceusage
|
||||
import android.os.SystemClock
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@@ -97,24 +99,29 @@ class UsageCountingInterceptor(
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
// Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache
|
||||
// hits to 127.0.0.1) never touches the radio: counting it would inflate
|
||||
// the network numbers — and could trip the background-data alert — for
|
||||
// exactly the users who cache aggressively to SAVE data.
|
||||
// Loopback traffic never touches the radio: counting it would inflate the
|
||||
// network numbers — and could trip the background-data alert — for exactly
|
||||
// the users who cache aggressively to SAVE data. This interceptor is the
|
||||
// outermost one, so it sees URLs BEFORE LocalBlossomCacheRedirectInterceptor
|
||||
// rewrites them to 127.0.0.1; the host that was actually contacted is only
|
||||
// known from the response's request.
|
||||
if (isLoopback(request.url.host)) return chain.proceed(request)
|
||||
|
||||
val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole
|
||||
|
||||
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
|
||||
bursts?.onHttpActivity()
|
||||
|
||||
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
|
||||
if (requestBytes > 0) {
|
||||
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
|
||||
}
|
||||
|
||||
val startedAtMs = nowMs()
|
||||
val response = chain.proceed(request)
|
||||
val response =
|
||||
try {
|
||||
chain.proceed(request)
|
||||
} catch (e: IOException) {
|
||||
countRequest(request, role)
|
||||
throw e
|
||||
}
|
||||
|
||||
if (isLoopback(response.request.url.host)) return response
|
||||
|
||||
countRequest(request, role)
|
||||
|
||||
return response
|
||||
.newBuilder()
|
||||
.body(
|
||||
@@ -131,6 +138,19 @@ class UsageCountingInterceptor(
|
||||
).build()
|
||||
}
|
||||
|
||||
private fun countRequest(
|
||||
request: Request,
|
||||
role: String,
|
||||
) {
|
||||
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
|
||||
bursts?.onHttpActivity()
|
||||
|
||||
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
|
||||
if (requestBytes > 0) {
|
||||
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */
|
||||
fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]"
|
||||
|
||||
+78
-21
@@ -28,17 +28,23 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class BlossomServerResolver(
|
||||
val loggedInUsers: () -> List<HexKey>,
|
||||
@@ -46,10 +52,20 @@ class BlossomServerResolver(
|
||||
val httpClientBuilder: IRoleBasedHttpClientBuilder,
|
||||
val useLocalBlossomCache: () -> Boolean = { false },
|
||||
val localCacheProbe: LocalBlossomCacheProbe? = null,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
val blossomHitCache: ServerHeadCache = ServerHeadCache()
|
||||
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
|
||||
|
||||
// Unresolvable URIs, with when they failed. Without this every retry — each ExoPlayer
|
||||
// load attempt blocks a loader thread on it — paid the full resolution timeout again.
|
||||
private val missCache = LruCache<String, Long>(200)
|
||||
|
||||
// One resolution per URI at a time: the feed preview, Coil and the player asking for the
|
||||
// same `blossom:` URI together share it instead of each firing its own HEADs and relay
|
||||
// subscriptions. Runs in [scope] so a caller that goes away doesn't cancel it for the rest.
|
||||
private val inFlight = ConcurrentHashMap<String, Deferred<BlossomUriServer?>>()
|
||||
|
||||
class BlossomUriServer(
|
||||
val uri: BlossomUri,
|
||||
val serverUrl: String,
|
||||
@@ -57,41 +73,71 @@ class BlossomServerResolver(
|
||||
|
||||
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
|
||||
|
||||
// Bumped by [clearCaches]. A resolution that started before a clear (e.g. it picked the
|
||||
// local cache just before the cache went down) must not write its stale answer back.
|
||||
private val generation = AtomicInteger(0)
|
||||
|
||||
/** Forgets every resolution, e.g. when the local cache comes up or goes down. */
|
||||
fun clearCaches() {
|
||||
generation.incrementAndGet()
|
||||
uriToUrlCache.evictAll()
|
||||
missCache.evictAll()
|
||||
blossomHitCache.cache.evictAll()
|
||||
}
|
||||
|
||||
suspend fun findServers(uriStr: String): BlossomUriServer? {
|
||||
uriToUrlCache[uriStr]?.let { return it }
|
||||
missCache[uriStr]?.let { failedAt ->
|
||||
if (TimeUtils.nowMillis() - failedAt < MISS_TTL_MS) return null
|
||||
}
|
||||
|
||||
// Confined to Dispatchers.IO: this is reached from Compose
|
||||
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
|
||||
// which run on the main dispatcher. The pre-suspension work here —
|
||||
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
|
||||
// build, and the server-list flow setup — must stay off the UI thread.
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeoutOrNull(10000) {
|
||||
findServersInner(uriStr)
|
||||
val resolution =
|
||||
inFlight.computeIfAbsent(uriStr) {
|
||||
// Confined to Dispatchers.IO: this is reached from Compose
|
||||
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
|
||||
// which run on the main dispatcher. The pre-suspension work here —
|
||||
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
|
||||
// build, and the server-list flow setup — must stay off the UI thread.
|
||||
scope.async(Dispatchers.IO) {
|
||||
val startedAt = generation.get()
|
||||
try {
|
||||
val result = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { findServersInner(uriStr) }
|
||||
// Cleared while resolving: answer the waiting callers, remember nothing.
|
||||
if (startedAt == generation.get()) {
|
||||
if (result != null) {
|
||||
uriToUrlCache.put(uriStr, result)
|
||||
} else {
|
||||
missCache.put(uriStr, TimeUtils.nowMillis())
|
||||
}
|
||||
}
|
||||
result
|
||||
} finally {
|
||||
inFlight.remove(uriStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
uriToUrlCache.put(uriStr, result)
|
||||
}
|
||||
|
||||
return result
|
||||
return resolution.await()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
|
||||
val uri = BlossomUri.parse(uriStr) ?: return null
|
||||
|
||||
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
|
||||
val expectedMimeType = mimeTypeMap[uri.extension]
|
||||
|
||||
// Same rule as the HTTP-level bridge, which Tor-routed clients don't carry: media the
|
||||
// user sends through Tor must not be handed to the local cache, which would fetch it
|
||||
// from the origin outside Tor.
|
||||
if (useLocalBlossomCache() && !isTorRouted(uri, expectedMimeType) && localCacheProbe?.isAvailable() == true) {
|
||||
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
|
||||
}
|
||||
|
||||
val expectedMimeType = mimeTypeMap[uri.extension]
|
||||
val filename = uri.filename()
|
||||
|
||||
if (uri.servers.isNotEmpty()) {
|
||||
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
|
||||
// Bounded well under RESOLVE_TIMEOUT_MS so a hint server that drops packets
|
||||
// leaves time for the author's own server list below.
|
||||
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size, XS_TIMEOUT_MS)
|
||||
if (workingUrl != null) {
|
||||
return BlossomUriServer(uri, workingUrl)
|
||||
}
|
||||
@@ -135,13 +181,20 @@ class BlossomServerResolver(
|
||||
filename: String,
|
||||
expectedMimeType: String?,
|
||||
expectedSize: Long?,
|
||||
timeoutMs: Long = RESOLVE_TIMEOUT_MS,
|
||||
): String? =
|
||||
firstNotNullOrNullAsync(servers, 10000) {
|
||||
firstNotNullOrNullAsync(servers, timeoutMs) {
|
||||
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
|
||||
client(url, expectedMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this blob's media type would be fetched through Tor from a regular (clearnet) server. */
|
||||
private fun isTorRouted(
|
||||
uri: BlossomUri,
|
||||
mimeType: String?,
|
||||
): Boolean = client(uri.toServerUrl() ?: "https://blossom.invalid/${uri.filename()}", mimeType).proxy != null
|
||||
|
||||
fun client(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
@@ -153,9 +206,13 @@ class BlossomServerResolver(
|
||||
else -> httpClientBuilder.okHttpClientForPreview(url)
|
||||
}
|
||||
|
||||
fun canResolve(scheme: String) = scheme == SCHEME
|
||||
fun canResolve(scheme: String) = scheme.equals(SCHEME, ignoreCase = true)
|
||||
|
||||
companion object {
|
||||
const val SCHEME = "blossom"
|
||||
|
||||
private const val RESOLVE_TIMEOUT_MS = 10_000L
|
||||
private const val XS_TIMEOUT_MS = 4_000L
|
||||
private const val MISS_TTL_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -32,6 +32,7 @@ import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Discovers a local Blossom cache running on `http://127.0.0.1:24242` per
|
||||
@@ -48,6 +49,9 @@ class LocalBlossomCacheProbe(
|
||||
@Volatile
|
||||
private var cachedAtMs: Long = 0L
|
||||
|
||||
// Bumped by [markUnavailable]; lets a probe that raced it discard its stale positive result.
|
||||
private val unavailableMarks = AtomicInteger(0)
|
||||
|
||||
private val _available = MutableStateFlow(false)
|
||||
val available: StateFlow<Boolean> = _available
|
||||
|
||||
@@ -66,7 +70,11 @@ class LocalBlossomCacheProbe(
|
||||
return@withLock _available.value
|
||||
}
|
||||
|
||||
val startedAt = unavailableMarks.get()
|
||||
val newResult = probe()
|
||||
// A refused connection reported while this probe was in flight is newer evidence
|
||||
// than a HEAD that may have succeeded just before the cache went away.
|
||||
if (newResult && startedAt != unavailableMarks.get()) return@withLock false
|
||||
_available.value = newResult
|
||||
cachedAtMs = currentTimeMs()
|
||||
newResult
|
||||
@@ -80,6 +88,17 @@ class LocalBlossomCacheProbe(
|
||||
cachedAtMs = 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the cache just refused a connection. Flipping [available] right away turns
|
||||
* the bridge off everywhere it is read, instead of letting every sha256 URL keep failing
|
||||
* against the dead loopback port until the positive TTL runs out and someone re-probes.
|
||||
*/
|
||||
fun markUnavailable() {
|
||||
unavailableMarks.incrementAndGet()
|
||||
cachedAtMs = currentTimeMs()
|
||||
_available.value = false
|
||||
}
|
||||
|
||||
// Confined to Dispatchers.IO because callers reach this through suspend
|
||||
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
|
||||
// on the main dispatcher: building the OkHttp client and issuing the HEAD
|
||||
|
||||
+11
-5
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
@@ -56,13 +56,18 @@ class ServerHeadCache {
|
||||
|
||||
client(url).newCall(request).executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
cache.put(url, HasFile.NoFile)
|
||||
// Only a definitive answer is remembered. A 5xx, 429 or auth challenge is
|
||||
// transient: caching it would hide this server for the app's lifetime.
|
||||
if (response.code == 404 || response.code == 410) {
|
||||
cache.put(url, HasFile.NoFile)
|
||||
}
|
||||
return HasFile.NoFile
|
||||
}
|
||||
|
||||
// Retrieve the "Content-Length" header
|
||||
val contentLength = response.header("Content-Length")?.toLongOrNull()
|
||||
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
|
||||
// type/subtype only: a `; charset=binary` parameter must not fail the match below.
|
||||
val mimeType = response.header("Content-Type")?.toMediaTypeOrNull()?.let { "${it.type}/${it.subtype}" }
|
||||
|
||||
return if (contentLength != null && mimeType != null) {
|
||||
val result = HasFile.TypeAndSize(mimeType, contentLength)
|
||||
@@ -75,7 +80,8 @@ class ServerHeadCache {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
cache.put(url, HasFile.NoFile)
|
||||
// Offline, DNS failure, timeout: says nothing about the server having the file,
|
||||
// so it is not cached — a brief outage must not hide the blob until eviction.
|
||||
return HasFile.NoFile
|
||||
}
|
||||
}
|
||||
@@ -104,7 +110,7 @@ class ServerHeadCache {
|
||||
if (result.size == expectedSize) {
|
||||
return url
|
||||
}
|
||||
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
|
||||
if (expectedSize == null && result.size > 0 && result.mimeType.equals(expectedMimeType, ignoreCase = true)) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ fun MediaCacheSection(accountViewModel: AccountViewModel) {
|
||||
*/
|
||||
@Composable
|
||||
private fun CacheDetectionChip(accountViewModel: AccountViewModel) {
|
||||
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
|
||||
val probeAvailable by accountViewModel.localBlossomCacheDetected
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
|
||||
|
||||
+27
-43
@@ -39,48 +39,21 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.asDrawable
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import coil3.network.NetworkHeaders
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
|
||||
import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.forwardingPainter
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.onBackgroundColorFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
private fun rememberLocalBlossomBridgeForProfilePics(): Boolean {
|
||||
val sessionManager =
|
||||
try {
|
||||
Amethyst.instance.sessionManager
|
||||
} catch (e: UninitializedPropertyAccessException) {
|
||||
return false
|
||||
}
|
||||
val probe = Amethyst.instance.localBlossomCacheProbe
|
||||
val flow =
|
||||
remember {
|
||||
combine(
|
||||
sessionManager.accountContent.flatMapLatest { state ->
|
||||
if (state is AccountState.LoggedIn) state.account.settings.useLocalBlossomCache else flowOf(false)
|
||||
},
|
||||
probe.available,
|
||||
) { toggle, probeUp -> toggle && probeUp }
|
||||
}
|
||||
val state by flow.collectAsStateWithLifecycle(initialValue = false)
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
@@ -120,21 +93,16 @@ fun RobohashFallbackAsyncImage(
|
||||
loadRobohash: Boolean,
|
||||
autoPlayGif: Boolean = true,
|
||||
) {
|
||||
val useBridge = rememberLocalBlossomBridgeForProfilePics()
|
||||
val bridgedModel =
|
||||
remember(model, robot, useBridge) {
|
||||
bridgeProfilePictureUrl(model, useBridge, robot)
|
||||
}
|
||||
if (bridgedModel != null && loadProfilePicture && isAnimatedMediaUrl(bridgedModel)) {
|
||||
if (model != null && loadProfilePicture && isAnimatedMediaUrl(model)) {
|
||||
GifProfilePicture(
|
||||
userHex = robot,
|
||||
userPicture = bridgedModel,
|
||||
userPicture = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
loadRobohash = loadRobohash,
|
||||
autoPlay = autoPlayGif,
|
||||
)
|
||||
} else if (bridgedModel != null && loadProfilePicture) {
|
||||
} else if (model != null && loadProfilePicture) {
|
||||
val fallbackPainter =
|
||||
if (loadRobohash) {
|
||||
rememberVectorPainter(
|
||||
@@ -154,10 +122,10 @@ fun RobohashFallbackAsyncImage(
|
||||
// file://) would fail there. Route only remote http(s) pictures through the thumbnail
|
||||
// cache; hand local/content URIs to Coil's native fetchers, which load them directly.
|
||||
model =
|
||||
if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) {
|
||||
ProfilePictureUrl(bridgedModel)
|
||||
if (model.startsWith("http://", ignoreCase = true) || model.startsWith("https://", ignoreCase = true)) {
|
||||
ProfilePictureUrl(model)
|
||||
} else {
|
||||
bridgedModel
|
||||
model
|
||||
},
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
@@ -237,9 +205,25 @@ fun GifProfilePicture(
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
// Animated avatars skip ProfilePictureFetcher (its thumbnail cache would flatten them), so
|
||||
// they carry the profile-picture marker themselves for the local Blossom cache bridge.
|
||||
val model =
|
||||
remember(userPicture) {
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(userPicture)
|
||||
.httpHeaders(
|
||||
NetworkHeaders
|
||||
.Builder()
|
||||
.set(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE)
|
||||
.build(),
|
||||
).build()
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
SubcomposeAsyncImage(
|
||||
model = userPicture,
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.core.content.FileProvider
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
|
||||
import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType
|
||||
import com.vitorpamplona.amethyst.service.images.BlossomFetcher
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -93,7 +94,7 @@ object ShareHelper {
|
||||
): Pair<Uri, String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Safely get snapshot and file
|
||||
Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot ->
|
||||
Amethyst.instance.diskCache.openSnapshot(BlossomFetcher.diskCacheKey(imageUrl))?.use { snapshot ->
|
||||
val file = snapshot.data.toFile()
|
||||
|
||||
// Determine file extension and prepare sharable file
|
||||
|
||||
+1
-9
@@ -64,7 +64,6 @@ import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.failed_to_save_the_image
|
||||
@@ -81,7 +80,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
@@ -505,12 +503,6 @@ private fun RenderImageOrVideo(
|
||||
}
|
||||
|
||||
val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url)
|
||||
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
|
||||
val modifier =
|
||||
if (ratio != null) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
@@ -520,7 +512,7 @@ private fun RenderImageOrVideo(
|
||||
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
VideoViewInner(
|
||||
videoUri = bridgedUrl,
|
||||
videoUri = content.url,
|
||||
mimeType = content.mimeType,
|
||||
aspectRatio = ratio,
|
||||
title = content.description,
|
||||
|
||||
+10
-32
@@ -66,7 +66,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
@@ -105,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.commons.service.image.placeholderModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
|
||||
@@ -123,6 +121,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.hashVerifierMark
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.images.BlossomFetcher
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
@@ -200,26 +199,20 @@ fun ZoomableContentView(
|
||||
sourceBounds = coordinates.boundsInWindow()
|
||||
}
|
||||
|
||||
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
|
||||
|
||||
when (content) {
|
||||
is MediaUrlImage -> {
|
||||
val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url)
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
ContentWarningGate(
|
||||
isSensitive = content.contentWarning != null,
|
||||
reasons = setOfNotNull(content.contentWarning),
|
||||
preloadUrls = listOf(bridgedUrl),
|
||||
preloadUrls = listOf(content.url),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, contentScale),
|
||||
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
|
||||
) {
|
||||
if (content.isAnimatedMedia()) {
|
||||
GifVideoView(
|
||||
videoUri = bridgedUrl,
|
||||
videoUri = content.url,
|
||||
contentDescription = content.description,
|
||||
dimensions = content.dim,
|
||||
blurhash = content.blurhash,
|
||||
@@ -254,10 +247,6 @@ fun ZoomableContentView(
|
||||
content.dim?.aspectRatioOrNull()
|
||||
?: MediaAspectRatioCache.get(content.url)
|
||||
?: fallbackRatio
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
ContentWarningGate(
|
||||
isSensitive = content.contentWarning != null,
|
||||
reasons = setOfNotNull(content.contentWarning),
|
||||
@@ -277,7 +266,7 @@ fun ZoomableContentView(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = bridgedUrl,
|
||||
videoUri = content.url,
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
@@ -544,22 +533,17 @@ fun UrlImageView(
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
val imageModel =
|
||||
if (fullResolution) {
|
||||
remember(bridgedUrl, context) {
|
||||
remember(content.url, context) {
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(bridgedUrl)
|
||||
.data(content.url)
|
||||
.size(Size.ORIGINAL)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
bridgedUrl
|
||||
content.url
|
||||
}
|
||||
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||
@@ -1281,15 +1265,9 @@ private suspend fun shareLocalVideoFile(
|
||||
private fun verifyHash(content: MediaUrlContent): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
val keys = mutableListOf(content.url)
|
||||
val bridged = content.toCoilModel(true)
|
||||
if (bridged != content.url) keys.add(bridged)
|
||||
|
||||
for (key in keys) {
|
||||
Amethyst.instance.diskCache.openSnapshot(key)?.use { snapshot ->
|
||||
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
|
||||
return hashBytes.toHexKey() == content.hash
|
||||
}
|
||||
Amethyst.instance.diskCache.openSnapshot(BlossomFetcher.diskCacheKey(content.url))?.use { snapshot ->
|
||||
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
|
||||
return hashBytes.toHexKey() == content.hash
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawe
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.ProfileAppRecommendationsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictReviewScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen
|
||||
@@ -680,6 +681,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BackupConflictReview> { BackupConflictReviewScreen(it.slot, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RelayManagement> { RelayManagementScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RelayMembers> { RelayMembersScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
|
||||
+10
-25
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.model.Dao
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
@@ -275,32 +276,10 @@ class AccountViewModel(
|
||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||
|
||||
/**
|
||||
* `true` when feed/note media (images and videos in `MediaUrlContent`)
|
||||
* should be routed through the local Blossom cache. Requires the master
|
||||
* toggle on, the probe up, AND the profile-pictures-only restriction
|
||||
* to be off.
|
||||
* `true` when the local Blossom cache is enabled and the probe sees it up. Only drives the
|
||||
* "detected" chip in settings: the routing itself happens in LocalBlossomCacheRedirectInterceptor.
|
||||
*/
|
||||
val useLocalBlossomBridge: StateFlow<Boolean> =
|
||||
try {
|
||||
combine(
|
||||
account.settings.useLocalBlossomCache,
|
||||
account.settings.localBlossomCacheProfilePicturesOnly,
|
||||
Amethyst.instance.localBlossomCacheProbe.available,
|
||||
) { toggle, profileOnly, probeUp -> toggle && probeUp && !profileOnly }.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false,
|
||||
)
|
||||
} catch (e: UninitializedPropertyAccessException) {
|
||||
MutableStateFlow(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` when profile pictures should be routed through the local
|
||||
* Blossom cache. Requires only the master toggle and the probe to be
|
||||
* up; the profile-pictures-only restriction does not gate this flow.
|
||||
*/
|
||||
val useLocalBlossomBridgeForProfilePics: StateFlow<Boolean> =
|
||||
val localBlossomCacheDetected: StateFlow<Boolean> =
|
||||
try {
|
||||
combine(
|
||||
account.settings.useLocalBlossomCache,
|
||||
@@ -1663,6 +1642,12 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps another app's version of a backed-up list. Off the main thread: it waits for the
|
||||
* backup guard's lock, which the backup collectors hold while diffing large lists.
|
||||
*/
|
||||
fun acceptExternalBackupVersion(conflict: ReplaceableBackupConflict) = viewModelScope.launch(Dispatchers.IO) { account.acceptExternalVersion(conflict) }
|
||||
|
||||
inline fun launchSigner(crossinline action: suspend () -> Unit) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
reportSignerErrors { action() }
|
||||
|
||||
+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.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.EventDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
|
||||
|
||||
/**
|
||||
* Cards at the top of Home's feed, one per open [ReplaceableBackupConflict]: the user
|
||||
* changed one of the account's backed-up lists (or the profile) in another app, and
|
||||
* Amethyst asks to confirm before its backup moves on. They can't be dismissed. They stay
|
||||
* until the user decides on the review screen they open ([BackupConflictReviewScreen]).
|
||||
*
|
||||
* The most recent conflict gets a lead card with a headline and a picture of the change
|
||||
* (the follow list's bar, or removed/added counts); the rest are slim pills. They are the
|
||||
* first item of the feed, so they scroll away with it instead of covering posts.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupConflictCards(
|
||||
conflictMap: Map<String, ReplaceableBackupConflict>,
|
||||
nav: INav,
|
||||
onAccept: (ReplaceableBackupConflict) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (conflictMap.isEmpty()) return
|
||||
|
||||
val conflicts = remember(conflictMap) { conflictMap.values.sortedByDescending { it.cause.createdAt } }
|
||||
val open = { conflict: ReplaceableBackupConflict -> nav.nav(Route.BackupConflictReview(conflict.slot)) }
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val lead = conflicts.first()
|
||||
LeadConflictCard(lead, onAccept = { onAccept(lead) }) { open(lead) }
|
||||
conflicts.drop(1).forEach { conflict -> ConflictPill(conflict, onAccept = { onAccept(conflict) }) { open(conflict) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** A headline that says what happened, in the event's own terms. */
|
||||
@Composable
|
||||
private fun headlineOf(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
counts: ConflictCounts,
|
||||
): String {
|
||||
val diff = conflict.diff
|
||||
val specific: String? =
|
||||
when (diff) {
|
||||
// Counted, not "shrank": a version that drops 3 follows and adds 100 still raises
|
||||
// a conflict, and calling that a shrink contradicts the card's own bar.
|
||||
is ContactListDiff -> plural(R.plurals.backup_card_follows_dropped, diff.follows.removed.size)
|
||||
is MuteListDiff -> plural(R.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size)
|
||||
is MetadataDiff -> plural(R.plurals.backup_card_profile_lost_fields, counts.removed)
|
||||
is AdvertisedRelayListDiff -> plural(R.plurals.backup_card_nip65_lost, diff.relays.removed.size)
|
||||
is RelayListDiff -> relayListHeadline(conflict.eventType, diff.relays.removed.size)
|
||||
is ChannelListDiff -> plural(R.plurals.backup_card_leave_chats, diff.channels.removed.size)
|
||||
is CommunityListDiff -> plural(R.plurals.backup_card_leave_communities, diff.communities.removed.size)
|
||||
is EphemeralChatListDiff -> plural(R.plurals.backup_card_leave_rooms, diff.rooms.removed.size)
|
||||
is SimpleGroupListDiff -> plural(R.plurals.backup_card_leave_groups, diff.groups.removed.size)
|
||||
is FavoriteAlgoFeedsListDiff -> plural(R.plurals.backup_card_feeds_removed, diff.feeds.removed.size)
|
||||
is HashtagListDiff -> plural(R.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size)
|
||||
is GeohashListDiff -> plural(R.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size)
|
||||
is TrustProviderListDiff -> plural(R.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size)
|
||||
is NutzapInfoDiff ->
|
||||
when {
|
||||
diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_removed)
|
||||
diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_replaced)
|
||||
else -> plural(R.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size)
|
||||
}
|
||||
is PaymentTargetsDiff -> plural(R.plurals.backup_card_payment_targets_removed, diff.targets.removed.size)
|
||||
is Bolt12OfferListDiff -> plural(R.plurals.backup_card_offers_removed, diff.offers.removed.size)
|
||||
is CashuWalletDiff -> encryptedHeadline(R.string.backup_card_wallet_emptied, R.string.backup_card_wallet_rewritten, diff.wallet)
|
||||
is ConcordCommunityListDiff -> encryptedHeadline(R.string.backup_card_concord_emptied, R.string.backup_card_concord_rewritten, diff.communities)
|
||||
is AppSpecificDataDiff -> stringRes(R.string.backup_card_app_settings)
|
||||
else -> null
|
||||
}
|
||||
// A headline counting removed items reads wrong when only encrypted items were lost.
|
||||
if (specific.isNullOrEmpty()) return stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType)))
|
||||
// Headlines lead with the loss; what the other app added to the same list is said too,
|
||||
// so a list that dropped 3 and gained 40 doesn't read as if it only shrank. The wording
|
||||
// follows the headline's: following people, muting, joining chats, blocking relays.
|
||||
val added = addedToHeadlinedList(diff, conflict.eventType)
|
||||
if (added <= 0) return specific
|
||||
val tail =
|
||||
when {
|
||||
diff is ContactListDiff -> R.plurals.backup_card_and_followed
|
||||
diff is MuteListDiff -> R.plurals.backup_card_and_muted
|
||||
diff is ChannelListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> R.plurals.backup_card_and_joined
|
||||
conflict.eventType == BackupEventType.BLOCKED_RELAYS -> R.plurals.backup_card_and_blocked
|
||||
else -> R.plurals.backup_card_and_added
|
||||
}
|
||||
return pluralStringResource(tail, added, specific, added)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many items were added to the list the headline counts removals from, or 0 when the
|
||||
* headline isn't a count of list items (a key swap, an encrypted rewrite, a profile).
|
||||
*/
|
||||
private fun addedToHeadlinedList(
|
||||
diff: EventDiff,
|
||||
type: BackupEventType,
|
||||
): Int =
|
||||
when (diff) {
|
||||
is ContactListDiff -> diff.follows.added.size
|
||||
is MuteListDiff -> diff.publicMutes.added.size
|
||||
is AdvertisedRelayListDiff -> diff.relays.added.size
|
||||
is RelayListDiff -> if (relayListHasHeadline(type)) diff.relays.added.size else 0
|
||||
is ChannelListDiff -> diff.channels.added.size
|
||||
is CommunityListDiff -> diff.communities.added.size
|
||||
is EphemeralChatListDiff -> diff.rooms.added.size
|
||||
is SimpleGroupListDiff -> diff.groups.added.size
|
||||
is FavoriteAlgoFeedsListDiff -> diff.feeds.added.size
|
||||
is HashtagListDiff -> diff.hashtags.added.size
|
||||
is GeohashListDiff -> diff.geohashes.added.size
|
||||
is NutzapInfoDiff -> if (diff.p2pkPubkey == null) diff.mints.added.size else 0
|
||||
is PaymentTargetsDiff -> diff.targets.added.size
|
||||
is Bolt12OfferListDiff -> diff.offers.added.size
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/** The relay lists [relayListHeadline] has a counted headline for. */
|
||||
private fun relayListHasHeadline(type: BackupEventType): Boolean =
|
||||
when (type) {
|
||||
BackupEventType.DM_RELAYS, BackupEventType.KEY_PACKAGE_RELAYS, BackupEventType.SEARCH_RELAYS,
|
||||
BackupEventType.INDEXER_RELAYS, BackupEventType.RELAY_FEEDS, BackupEventType.PRIVATE_OUTBOX_RELAYS,
|
||||
BackupEventType.TRUSTED_RELAYS, BackupEventType.BLOCKED_RELAYS,
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** The count's plural, or "" when nothing of that kind was removed. */
|
||||
@Composable
|
||||
private fun plural(
|
||||
id: Int,
|
||||
count: Int,
|
||||
): String = if (count > 0) pluralStringResource(id, count, count) else ""
|
||||
|
||||
@Composable
|
||||
private fun relayListHeadline(
|
||||
type: BackupEventType,
|
||||
removed: Int,
|
||||
): String =
|
||||
when (type) {
|
||||
BackupEventType.DM_RELAYS -> plural(R.plurals.backup_card_dm_relays_lost, removed)
|
||||
BackupEventType.KEY_PACKAGE_RELAYS -> plural(R.plurals.backup_card_key_package_relays_lost, removed)
|
||||
BackupEventType.SEARCH_RELAYS -> plural(R.plurals.backup_card_search_relays_lost, removed)
|
||||
BackupEventType.INDEXER_RELAYS -> plural(R.plurals.backup_card_indexer_relays_lost, removed)
|
||||
BackupEventType.RELAY_FEEDS -> plural(R.plurals.backup_card_relay_feeds_lost, removed)
|
||||
BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(R.plurals.backup_card_private_outbox_lost, removed)
|
||||
BackupEventType.TRUSTED_RELAYS -> plural(R.plurals.backup_card_trusted_lost, removed)
|
||||
BackupEventType.BLOCKED_RELAYS -> plural(R.plurals.backup_card_unblocked, removed)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun encryptedHeadline(
|
||||
emptied: Int,
|
||||
rewritten: Int,
|
||||
change: ContentChange,
|
||||
): String = if (change == ContentChange.CLEARED) stringRes(emptied) else stringRes(rewritten)
|
||||
|
||||
@Composable
|
||||
private fun LeadConflictCard(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
onAccept: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
val counts = remember(conflict) { countsOf(conflict.diff) }
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
Modifier.border(1.dp, tones.removed.copy(alpha = 0.35f), RoundedCornerShape(22.dp)).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(Modifier.size(40.dp).clip(CircleShape).background(tones.removedContainer()), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.removed)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(headlineOf(conflict, counts), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
AcceptButton(onAccept)
|
||||
}
|
||||
val diff = conflict.diff
|
||||
if (diff is ContactListDiff) {
|
||||
val (saved, new) = conflict.followCounts
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(saved.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
||||
SplitBar(
|
||||
listOf(
|
||||
BarSegment(saved - diff.follows.removed.size, tones.kept),
|
||||
BarSegment(diff.follows.removed.size, tones.removed.copy(alpha = 0.6f)),
|
||||
BarSegment(diff.follows.added.size, tones.added),
|
||||
),
|
||||
Modifier.weight(1f),
|
||||
height = 8.dp,
|
||||
)
|
||||
Text(new.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = if (new < saved) tones.removed else tones.added)
|
||||
}
|
||||
} else {
|
||||
CountChips(counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CountChips(counts: ConflictCounts) {
|
||||
val tones = conflictTones()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (counts.removed > 0) StatusTag(stringRes(R.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed)
|
||||
if (counts.added > 0) StatusTag(stringRes(R.string.backup_conflict_count_added, counts.added.toString()), tones.added)
|
||||
if (counts.changed > 0) StatusTag(stringRes(R.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConflictPill(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
onAccept: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
val counts = remember(conflict) { countsOf(conflict.diff) }
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(32.dp).clip(CircleShape).background(tones.changedContainer()), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Text(headlineOf(conflict, counts), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
AcceptButton(onAccept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the change right from Home: the change was made on purpose in another app, so
|
||||
* keeping it is the quick path. Reverting needs the review screen, one tap on the card away.
|
||||
*/
|
||||
@Composable
|
||||
private fun AcceptButton(onAccept: () -> Unit) {
|
||||
FilledTonalButton(
|
||||
onClick = onAccept,
|
||||
modifier = Modifier.height(36.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
) {
|
||||
Text(stringRes(R.string.backup_card_accept), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
+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.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.channels
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
|
||||
/**
|
||||
* Per-event layouts for the account's other lists: each relay list framed by what it is
|
||||
* for (who can reach you, what gets unblocked…), joined spaces as tiles, followed topics
|
||||
* as a pill cloud, trust providers as a per-service table, payment targets and offers as
|
||||
* cards, and the encrypted-only events as a single explained panel. Returns false when
|
||||
* [conflict] has no layout here, so the caller falls back to the generic one.
|
||||
*/
|
||||
internal fun LazyListScope.listDiffItems(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
): Boolean {
|
||||
when (val diff = conflict.diff) {
|
||||
is RelayListDiff -> relayListItems(diff, conflict, nav)
|
||||
is ChannelListDiff -> publicChatItems(diff, conflict, accountViewModel, nav)
|
||||
is CommunityListDiff -> communityItems(diff, conflict, accountViewModel, nav)
|
||||
is EphemeralChatListDiff -> ephemeralRoomItems(diff, conflict, nav)
|
||||
is FavoriteAlgoFeedsListDiff -> favoriteFeedItems(diff, conflict, accountViewModel, nav)
|
||||
is HashtagListDiff -> topicItems(diff.hashtags, (conflict.saved as? HashtagListEvent)?.publicHashtags().orEmpty(), diff.privateItems, isPlace = false, nav)
|
||||
is GeohashListDiff -> topicItems(diff.geohashes, (conflict.saved as? GeohashListEvent)?.publicGeohashes().orEmpty(), diff.privateItems, isPlace = true, nav)
|
||||
is TrustProviderListDiff -> trustProviderItems(diff, conflict, accountViewModel, nav)
|
||||
is PaymentTargetsDiff -> paymentTargetItems(diff, conflict)
|
||||
is Bolt12OfferListDiff -> offerItems(diff, conflict)
|
||||
is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, R.string.backup_review_wallet_body)
|
||||
is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, R.string.backup_review_concord_body)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private val Pad = Modifier.padding(horizontal = 16.dp)
|
||||
|
||||
/** Fate of an item between the saved version and the new one. */
|
||||
internal enum class ItemFate { KEPT, DROPPED, ADDED, CHANGED }
|
||||
|
||||
/**
|
||||
* The saved items in their order, each marked kept or dropped (or changed), then the new
|
||||
* ones: the full picture of a list, not just its differences.
|
||||
*/
|
||||
private fun <T, K> fates(
|
||||
saved: List<T>,
|
||||
diff: ListDiff<T>,
|
||||
key: (T) -> K,
|
||||
): List<Pair<T, ItemFate>> {
|
||||
val removed = diff.removed.mapTo(HashSet(), key)
|
||||
val changed = diff.changed.associateBy { key(it.after) }
|
||||
val seen = HashSet<K>()
|
||||
val result = mutableListOf<Pair<T, ItemFate>>()
|
||||
saved.forEach { item ->
|
||||
val k = key(item)
|
||||
if (!seen.add(k)) return@forEach
|
||||
when (k) {
|
||||
in removed -> result.add(item to ItemFate.DROPPED)
|
||||
in changed -> result.add(changed.getValue(k).after to ItemFate.CHANGED)
|
||||
else -> result.add(item to ItemFate.KEPT)
|
||||
}
|
||||
}
|
||||
diff.added.forEach { result.add(it to ItemFate.ADDED) }
|
||||
return result
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fateColor(fate: ItemFate): Color {
|
||||
val tones = conflictTones()
|
||||
return when (fate) {
|
||||
ItemFate.KEPT -> MaterialTheme.colorScheme.onSurface
|
||||
ItemFate.DROPPED -> tones.removed
|
||||
ItemFate.ADDED -> tones.added
|
||||
ItemFate.CHANGED -> tones.changed
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> LazyListScope.countTiles(
|
||||
items: List<Pair<T, ItemFate>>,
|
||||
removedCaption: Int,
|
||||
addedCaption: Int,
|
||||
) {
|
||||
item(key = "count-tiles", contentType = "tiles") {
|
||||
val tones = conflictTones()
|
||||
Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatTile(items.count { it.second == ItemFate.DROPPED }.toString(), stringRes(removedCaption), tones.removed, Modifier.weight(1f))
|
||||
StatTile(items.count { it.second == ItemFate.ADDED }.toString(), stringRes(addedCaption), tones.added, Modifier.weight(1f))
|
||||
StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(R.string.backup_review_kept), tones.kept, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.privateItemsCard(change: ContentChange) {
|
||||
if (change == ContentChange.NONE) return
|
||||
item(key = "private-items", contentType = "private") { PrivateItemsCard(change, Pad.padding(top = 18.dp)) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Relay lists, framed by what each one is for.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** How a relay list's rows are presented: its icon and what a kept or dropped relay is called. */
|
||||
private class RelayFraming(
|
||||
val icon: MaterialSymbol,
|
||||
val keptTag: Int,
|
||||
val droppedTag: Int = R.string.backup_review_dropped_tag,
|
||||
)
|
||||
|
||||
private fun framingOf(type: BackupEventType): RelayFraming =
|
||||
when (type) {
|
||||
BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_receiving_tag)
|
||||
BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_listed_tag)
|
||||
BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_searching_tag)
|
||||
BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag)
|
||||
BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_listed_tag)
|
||||
BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_storing_tag)
|
||||
BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag)
|
||||
else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag)
|
||||
}
|
||||
|
||||
private fun savedRelaysOf(event: Event): List<NormalizedRelayUrl> =
|
||||
when (event) {
|
||||
is ChatMessageRelayListEvent -> event.relays()
|
||||
is KeyPackageRelayListEvent -> event.relays()
|
||||
is SearchRelayListEvent -> event.publicRelays()
|
||||
is IndexerRelayListEvent -> event.publicRelays()
|
||||
is RelayFeedsListEvent -> event.publicRelays()
|
||||
is BlockedRelayListEvent -> event.publicRelays()
|
||||
is TrustedRelayListEvent -> event.publicRelays()
|
||||
is PrivateOutboxRelayListEvent -> event.publicRelays()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun LazyListScope.relayListItems(
|
||||
diff: RelayListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
nav: INav,
|
||||
) {
|
||||
val rows = fates(savedRelaysOf(conflict.saved), diff.relays) { it }
|
||||
if (conflict.eventType == BackupEventType.BLOCKED_RELAYS) {
|
||||
blockedRelayItems(rows, nav)
|
||||
} else {
|
||||
// No summary card: the rows themselves, tagged kept / dropped / new, are the change.
|
||||
val framing = framingOf(conflict.eventType)
|
||||
items(rows, key = { "relay-" + it.first.url }, contentType = { "relay-row" }) { (url, fate) ->
|
||||
RelayFateRow(url, fate, framing, nav)
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateRelays)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayFateRow(
|
||||
url: NormalizedRelayUrl,
|
||||
fate: ItemFate,
|
||||
framing: RelayFraming,
|
||||
nav: INav,
|
||||
) {
|
||||
val color = fateColor(fate)
|
||||
val tag =
|
||||
when (fate) {
|
||||
ItemFate.KEPT -> framing.keptTag
|
||||
ItemFate.DROPPED -> framing.droppedTag
|
||||
ItemFate.ADDED -> R.string.backup_review_new_tag
|
||||
ItemFate.CHANGED -> R.string.backup_review_demoted_tag
|
||||
}
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(Route.RelayInfo(url.url)) }
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(symbol = framing.icon, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
url.url.removePrefix("wss://").removeSuffix("/"),
|
||||
modifier = Modifier.weight(1f),
|
||||
color = color,
|
||||
textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
StatusTag(stringRes(tag), if (fate == ItemFate.KEPT) MaterialTheme.colorScheme.placeholderText else color)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun LazyListScope.blockedRelayItems(
|
||||
rows: List<Pair<NormalizedRelayUrl, ItemFate>>,
|
||||
nav: INav,
|
||||
) {
|
||||
val unblocked = rows.filter { it.second == ItemFate.DROPPED }.map { it.first }
|
||||
val stillBlocked = rows.filter { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.map { it.first }
|
||||
val newlyBlocked = rows.filter { it.second == ItemFate.ADDED }.map { it.first }
|
||||
|
||||
item(key = "blocked-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
TintedPanel(tones.changedContainer(), Pad.padding(top = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(44.dp))
|
||||
Column {
|
||||
Text(stringRes(R.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed)
|
||||
Text(stringRes(R.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unblocked.isNotEmpty()) {
|
||||
item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) }
|
||||
items(unblocked, key = { "unblocked-" + it.url }, contentType = { "relay-row" }) { url ->
|
||||
val tones = conflictTones()
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, tones.changed.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(Route.RelayInfo(url.url)) }
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(36.dp).clip(CircleShape).border(2.dp, tones.changed, CircleShape), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Text(url.url.removePrefix("wss://").removeSuffix("/"), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
StatusTag(stringRes(R.string.backup_review_allowed_tag), tones.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newlyBlocked.isNotEmpty()) {
|
||||
item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) }
|
||||
items(newlyBlocked, key = { "newly-blocked-" + it.url }, contentType = { "relay-row" }) { url ->
|
||||
RelayFateRow(url, ItemFate.ADDED, framingOf(BackupEventType.BLOCKED_RELAYS), nav)
|
||||
}
|
||||
}
|
||||
if (stillBlocked.isNotEmpty()) {
|
||||
item(key = "still-blocked", contentType = "chips") {
|
||||
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionCaption(stringRes(R.string.backup_review_still_blocked))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
stillBlocked.forEach { url ->
|
||||
Row(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(Route.RelayInfo(url.url)) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.Lock, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(16.dp))
|
||||
Text(url.url.removePrefix("wss://").removeSuffix("/"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Joined spaces: public chats, communities, ephemeral rooms and favorite feeds as tiles.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** One space tile: its picture, name and subtitle are loaded by [content]. */
|
||||
private fun <T> LazyListScope.spaceGrid(
|
||||
keyPrefix: String,
|
||||
items: List<Pair<T, ItemFate>>,
|
||||
key: (T) -> String,
|
||||
content: @Composable (T, ItemFate, Modifier) -> Unit,
|
||||
) {
|
||||
countTiles(items, R.string.backup_review_left, R.string.backup_review_joined)
|
||||
items(items.chunked(2), key = { keyPrefix + key(it.first().first) }, contentType = { "space-row" }) { row ->
|
||||
Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
row.forEach { (item, fate) -> content(item, fate, Modifier.weight(1f)) }
|
||||
if (row.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpaceTile(
|
||||
name: String,
|
||||
subtitle: String,
|
||||
robot: String,
|
||||
picture: String?,
|
||||
fate: ItemFate,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Column(Modifier.alpha(if (fate == ItemFate.DROPPED) 0.45f else 1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = picture,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
Column {
|
||||
Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
val tag =
|
||||
when (fate) {
|
||||
ItemFate.DROPPED -> R.string.backup_review_left_tag to tones.removed
|
||||
ItemFate.ADDED -> R.string.backup_review_new_tag to tones.added
|
||||
ItemFate.CHANGED -> R.string.backup_review_moved_tag to tones.changed
|
||||
ItemFate.KEPT -> null
|
||||
}
|
||||
tag?.let { (res, color) -> StatusTag(stringRes(res), color, Modifier.align(Alignment.TopEnd)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.publicChatItems(
|
||||
diff: ChannelListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? ChannelListEvent)?.tags?.channels().orEmpty()
|
||||
spaceGrid("chat-", fates(saved, diff.channels) { it.eventId }, { it.eventId }) { tag, fate, modifier ->
|
||||
LoadPublicChatChannel(tag.eventId, accountViewModel) { channel ->
|
||||
// Subscribes to the channel's metadata on relays and recomposes when it arrives.
|
||||
val state by observeChannel(channel, accountViewModel)
|
||||
val current = state?.channel ?: channel
|
||||
SpaceTile(
|
||||
name = current.toBestDisplayName(),
|
||||
subtitle = stringRes(R.string.backup_type_public_chats),
|
||||
robot = tag.eventId,
|
||||
picture = channel.profilePicture(),
|
||||
fate = fate,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = modifier,
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
private fun LazyListScope.communityItems(
|
||||
diff: CommunityListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? CommunityListEvent)?.publicCommunities().orEmpty()
|
||||
spaceGrid("community-", fates(saved, diff.communities) { it.address }, { it.address.toValue() }) { tag, fate, modifier ->
|
||||
AddressableSpaceTile(tag.address, fate, accountViewModel, modifier) { nav.nav(Route.Community(tag.address)) }
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
private fun LazyListScope.favoriteFeedItems(
|
||||
diff: FavoriteAlgoFeedsListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? FavoriteAlgoFeedsListEvent)?.publicFavoriteAlgoFeeds().orEmpty()
|
||||
spaceGrid("feed-", fates(saved, diff.feeds) { it.address }, { it.address.toValue() }) { bookmark, fate, modifier ->
|
||||
AddressableSpaceTile(bookmark.address, fate, accountViewModel, modifier) {
|
||||
nav.nav { accountViewModel.getAddressableNoteIfExists(bookmark.address)?.let { routeFor(it, accountViewModel.account) } }
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
/** A community or feed tile: loads the definition event from relays for its name and picture. */
|
||||
@Composable
|
||||
private fun AddressableSpaceTile(
|
||||
address: Address,
|
||||
fate: ItemFate,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
val event =
|
||||
if (note != null) {
|
||||
val state by observeNote(note, accountViewModel)
|
||||
state.note.event
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val (name, picture) =
|
||||
when (event) {
|
||||
is CommunityDefinitionEvent -> event.name() to event.image()?.imageUrl
|
||||
is AppDefinitionEvent -> event.appMetaData().let { (it?.name ?: it?.displayName) to (it?.picture ?: it?.image) }
|
||||
else -> null to null
|
||||
}
|
||||
SpaceTile(
|
||||
name = name ?: address.dTag.ifBlank { address.toValue().toShortDisplay() },
|
||||
subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) R.string.backup_review_feed else R.string.backup_type_communities),
|
||||
robot = address.toValue(),
|
||||
picture = picture,
|
||||
fate = fate,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.ephemeralRoomItems(
|
||||
diff: EphemeralChatListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
nav: INav,
|
||||
) {
|
||||
val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty()
|
||||
val rooms = fates(saved, diff.rooms) { it }
|
||||
countTiles(rooms, R.string.backup_review_left, R.string.backup_review_joined)
|
||||
items(rooms, key = { "room-" + it.first.toKey() }, contentType = { "room-row" }) { (room, fate) -> RoomRow(room, fate, nav) }
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomRow(
|
||||
room: RoomId,
|
||||
fate: ItemFate,
|
||||
nav: INav,
|
||||
) {
|
||||
val color = fateColor(fate)
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.clickable { nav.nav(routeFor(room)) }
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(color.copy(alpha = 0.14f)), contentAlignment = Alignment.Center) {
|
||||
Text("#", fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, color = color)
|
||||
}
|
||||
Column(Modifier.weight(1f).alpha(if (fate == ItemFate.DROPPED) 0.55f else 1f)) {
|
||||
Text(room.id, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
room.relayUrl.url
|
||||
.removePrefix("wss://")
|
||||
.removeSuffix("/"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
when (fate) {
|
||||
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), color)
|
||||
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Followed hashtags and places: a pill cloud of what stays, goes and arrives.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun LazyListScope.topicItems(
|
||||
diff: ListDiff<String>,
|
||||
saved: List<String>,
|
||||
privateItems: ContentChange,
|
||||
isPlace: Boolean,
|
||||
nav: INav,
|
||||
) {
|
||||
val topics = fates(saved, diff) { it.lowercase() }
|
||||
countTiles(topics, if (isPlace) R.string.backup_review_places_gone else R.string.backup_review_topics_gone, R.string.backup_review_new_count_caption)
|
||||
item(key = "topic-cloud", contentType = "cloud") {
|
||||
val tones = conflictTones()
|
||||
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionCaption(stringRes(if (isPlace) R.string.backup_review_places_you_follow else R.string.backup_review_hashtags_you_follow))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) {
|
||||
topics.forEach { (topic, fate) ->
|
||||
val color = fateColor(fate)
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
when (fate) {
|
||||
ItemFate.KEPT -> MaterialTheme.colorScheme.surfaceContainer
|
||||
else -> color.copy(alpha = 0.12f)
|
||||
},
|
||||
).then(if (fate == ItemFate.DROPPED) Modifier.border(1.dp, tones.removed.copy(alpha = 0.5f), shape) else Modifier)
|
||||
.clickable { nav.nav(if (isPlace) Route.Geohash(topic) else Route.Hashtag(topic)) }
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (isPlace) Icon(symbol = MaterialSymbols.LocationOn, contentDescription = null, tint = color, modifier = Modifier.size(16.dp))
|
||||
val decoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null
|
||||
if (isPlace) {
|
||||
// Like the rest of the app: the city the geohash resolves to next to
|
||||
// its code, and just the code until (or unless) the name resolves.
|
||||
LoadCityName(topic, onLoading = { TopicText("#$topic", color, decoration) }) { city ->
|
||||
TopicText(if (city == topic) "#$topic" else "$city · #$topic", color, decoration)
|
||||
}
|
||||
} else {
|
||||
TopicText("#$topic", color, decoration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
privateItemsCard(privateItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopicText(
|
||||
text: String,
|
||||
color: Color,
|
||||
decoration: TextDecoration?,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = decoration,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Trust providers: one row per service, who provided it before and who does now.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private class ServiceRow(
|
||||
val service: String,
|
||||
val before: HexKey?,
|
||||
val after: HexKey?,
|
||||
)
|
||||
|
||||
private fun LazyListScope.trustProviderItems(
|
||||
diff: TrustProviderListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// A service can have several providers, so each side is a set per service: providers on
|
||||
// both sides are kept rows, and the ones only before or only after are paired up as swaps.
|
||||
val before = (conflict.saved as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey })
|
||||
val after = (conflict.incoming as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey })
|
||||
val services = (before.keys + after.keys).distinct()
|
||||
val rows =
|
||||
services.flatMap { service ->
|
||||
val had = before[service].orEmpty().distinct()
|
||||
val has = after[service].orEmpty().distinct()
|
||||
val gone = had - has.toSet()
|
||||
val new = has - had.toSet()
|
||||
had.filter { it in has }.map { ServiceRow(service, it, it) } +
|
||||
List(maxOf(gone.size, new.size)) { ServiceRow(service, gone.getOrNull(it), new.getOrNull(it)) }
|
||||
}
|
||||
val affected =
|
||||
rows
|
||||
.filter { it.before != it.after }
|
||||
.map { it.service }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
item(key = "trust-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
TintedPanel(MaterialTheme.colorScheme.surfaceContainer, Pad.padding(top = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(tones.changedContainer()), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.Group, contentDescription = null, tint = tones.changed)
|
||||
}
|
||||
Column {
|
||||
Text(stringRes(R.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
Text(stringRes(R.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "trust-header", contentType = "table-header") {
|
||||
Row(Pad.padding(top = 16.dp, bottom = 4.dp).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(R.string.backup_review_service, R.string.backup_review_saved, R.string.backup_review_new).forEachIndexed { index, res ->
|
||||
Text(stringRes(res).uppercase(), Modifier.weight(if (index == 0) 1.1f else 1f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(rows, key = { "service-" + it.service + "-" + it.before + "-" + it.after }, contentType = { "service-row" }) { row -> ServiceTableRow(row, accountViewModel, nav) }
|
||||
item(key = "trust-note", contentType = "note") {
|
||||
val tones = conflictTones()
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 14.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(tones.changedContainer())
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed)
|
||||
}
|
||||
}
|
||||
privateItemsCard(diff.privateItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceTableRow(
|
||||
row: ServiceRow,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
val tint =
|
||||
when {
|
||||
row.before == row.after -> MaterialTheme.colorScheme.surfaceContainer
|
||||
row.after == null -> tones.removedContainer()
|
||||
row.before == null -> tones.addedContainer()
|
||||
else -> tones.changedContainer()
|
||||
}
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(tint)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(serviceLabel(row.service), Modifier.weight(1.1f), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, maxLines = 2)
|
||||
ProviderCell(row.before, struck = row.before != row.after, color = tones.removed, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f))
|
||||
ProviderCell(row.after, struck = false, color = if (row.before == row.after) MaterialTheme.colorScheme.onSurface else tones.added, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun serviceLabel(type: String): String =
|
||||
when (type) {
|
||||
"rank" -> stringRes(R.string.backup_review_service_rank)
|
||||
"followers" -> stringRes(R.string.backup_review_service_followers)
|
||||
"t" -> stringRes(R.string.backup_review_service_topics)
|
||||
else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderCell(
|
||||
pubKey: HexKey?,
|
||||
struck: Boolean,
|
||||
color: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
if (pubKey == null) {
|
||||
Text("—", modifier, color = MaterialTheme.colorScheme.placeholderText)
|
||||
return
|
||||
}
|
||||
LoadUser(pubKey, accountViewModel) { user ->
|
||||
Row(
|
||||
modifier.clip(RoundedCornerShape(8.dp)).clickable { nav.nav(Route.Profile(pubKey)) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = pubKey,
|
||||
// Observed, so the picture appears when the provider's metadata arrives.
|
||||
model = observedPicture(pubKey, accountViewModel),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(color = if (struck) color else MaterialTheme.colorScheme.onSurface, textDecoration = if (struck) TextDecoration.LineThrough else null)) {
|
||||
if (user != null) {
|
||||
UsernameDisplay(user, fontWeight = FontWeight.Medium, textColor = if (struck) color else Color.Unspecified, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(pubKey.toShortDisplay(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Payment targets and BOLT12 offers: the ways people pay you, as cards.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private fun LazyListScope.paymentTargetItems(
|
||||
diff: PaymentTargetsDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
) {
|
||||
val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty()
|
||||
val targets = fates(saved, diff.targets) { it }
|
||||
moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_payment_targets_gone)
|
||||
items(targets, key = { "target-" + it.first.type + ":" + it.first.authority }, contentType = { "money-row" }) { (target, fate) -> PaymentTargetRow(target, fate) }
|
||||
}
|
||||
|
||||
private fun LazyListScope.offerItems(
|
||||
diff: Bolt12OfferListDiff,
|
||||
conflict: ReplaceableBackupConflict,
|
||||
) {
|
||||
val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty()
|
||||
val offers = fates(saved, diff.offers) { it }
|
||||
moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_offers_gone)
|
||||
items(offers, key = { "offer-" + it.first }, contentType = { "money-row" }) { (offer, fate) ->
|
||||
MoneyRow(stringRes(R.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.moneyHero(
|
||||
dropped: Int,
|
||||
headline: Int,
|
||||
) {
|
||||
item(key = "money-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
val color = if (dropped > 0) tones.removed else tones.added
|
||||
TintedPanel(color.copy(alpha = 0.12f), Pad.padding(top = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(color.copy(alpha = 0.18f)), contentAlignment = Alignment.Center) {
|
||||
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = color)
|
||||
}
|
||||
Column {
|
||||
Text(pluralStringResource(headline, dropped, dropped), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color)
|
||||
Text(stringRes(R.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentTargetRow(
|
||||
target: PaymentTarget,
|
||||
fate: ItemFate,
|
||||
) = MoneyRow(target.type.replaceFirstChar { it.uppercase() }, target.authority, fate)
|
||||
|
||||
@Composable
|
||||
private fun MoneyRow(
|
||||
kind: String,
|
||||
value: String,
|
||||
fate: ItemFate,
|
||||
) {
|
||||
val color = fateColor(fate)
|
||||
Row(
|
||||
Pad
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) {
|
||||
Text(kind.take(1), fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(kind, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = color,
|
||||
textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
when (fate) {
|
||||
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_dropped_tag), color)
|
||||
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Encrypted-only events (Cashu wallet, Concord list): one explained panel.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private fun LazyListScope.encryptedOnlyItems(
|
||||
icon: MaterialSymbol,
|
||||
change: ContentChange,
|
||||
body: Int,
|
||||
) {
|
||||
item(key = "encrypted-hero", contentType = "hero") {
|
||||
val tones = conflictTones()
|
||||
val (color, headline) =
|
||||
when (change) {
|
||||
ContentChange.CLEARED -> tones.removed to R.string.backup_review_encrypted_cleared
|
||||
ContentChange.ADDED -> tones.added to R.string.backup_review_encrypted_added
|
||||
else -> tones.changed to R.string.backup_review_encrypted_changed
|
||||
}
|
||||
Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
TintedPanel(color.copy(alpha = 0.12f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Icon(symbol = icon, contentDescription = null, tint = color, modifier = Modifier.size(44.dp))
|
||||
Text(stringRes(headline), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color)
|
||||
}
|
||||
}
|
||||
PrivateItemsCard(change)
|
||||
Text(stringRes(body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff
|
||||
|
||||
/** A row of the review list, flattened so hundreds of entries scroll lazily. */
|
||||
@Immutable
|
||||
internal sealed interface ReviewRow {
|
||||
class Section(
|
||||
val titleRes: Int,
|
||||
val count: Int,
|
||||
val tone: Tone,
|
||||
) : ReviewRow
|
||||
|
||||
class Note(
|
||||
val textRes: Int,
|
||||
) : ReviewRow
|
||||
|
||||
class Group(
|
||||
val labelRes: Int,
|
||||
) : ReviewRow
|
||||
|
||||
class Entry(
|
||||
val item: ReviewItem,
|
||||
) : ReviewRow
|
||||
}
|
||||
|
||||
internal enum class Tone { REMOVED, ADDED, CHANGED }
|
||||
|
||||
internal fun buildRows(
|
||||
presentation: DiffPresentation,
|
||||
skipGroups: Set<Int> = emptySet(),
|
||||
): List<ReviewRow> {
|
||||
val rows = mutableListOf<ReviewRow>()
|
||||
|
||||
fun section(
|
||||
titleRes: Int,
|
||||
count: Int,
|
||||
tone: Tone,
|
||||
contentNote: Int?,
|
||||
items: (DiffGroup) -> List<ReviewItem>,
|
||||
) {
|
||||
if (count == 0 && contentNote == null) return
|
||||
rows.add(ReviewRow.Section(titleRes, count, tone))
|
||||
contentNote?.let { rows.add(ReviewRow.Note(it)) }
|
||||
presentation.groups.forEach { group ->
|
||||
if (group.label in skipGroups) return@forEach
|
||||
val entries = items(group)
|
||||
if (entries.isNotEmpty()) {
|
||||
rows.add(ReviewRow.Group(group.label))
|
||||
entries.forEach { rows.add(ReviewRow.Entry(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val content = presentation.content
|
||||
section(R.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, R.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed }
|
||||
section(R.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, R.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added }
|
||||
section(R.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, R.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed }
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entry of a backup conflict, removed / added / changed, in a lazy list: people,
|
||||
* notes, communities, feeds, chats and relays are loaded from relays and open their own
|
||||
* screens when tapped, so the user can investigate the change before deciding. Opened from
|
||||
* the Home cards ([BackupConflictCards]); deciding resolves the conflict and removes its card.
|
||||
* Leaving without deciding keeps the card and the frozen backup.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupConflictReviewScreen(
|
||||
slot: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val settings = accountViewModel.account.settings
|
||||
val conflicts by settings.backupConflicts.collectAsStateWithLifecycle()
|
||||
val conflict = conflicts[slot]
|
||||
|
||||
// The only way out after a decision: resolving (from here or elsewhere) removes the
|
||||
// conflict, and this pops the screen exactly once. The buttons don't pop themselves,
|
||||
// which would pop a second screen, and a stale conflict (replaced by a newer one) is
|
||||
// simply shown in its place.
|
||||
val popped = remember { mutableStateOf(false) }
|
||||
if (conflict == null) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (!popped.value) {
|
||||
popped.value = true
|
||||
nav.popBack()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
BackupConflictReview(
|
||||
conflict = conflict,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
// A read-only account can't sign the saved version again, so it can only keep the new one.
|
||||
onRestore = if (accountViewModel.isWriteable()) ({ accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }) else null,
|
||||
onKeepNew = { accountViewModel.acceptExternalBackupVersion(conflict) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupConflictReview(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onRestore: (() -> Unit)?,
|
||||
onKeepNew: () -> Unit,
|
||||
) {
|
||||
val presentation = rememberPresentation(conflict.diff)
|
||||
val ui = remember(conflict.slot) { ReviewUiState() }
|
||||
val (keepLabel, restoreLabel) = actionLabels(conflict)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(eventTitle(conflict), nav)
|
||||
},
|
||||
bottomBar = { ReviewActions(keepLabel, restoreLabel, onRestore, onKeepNew) },
|
||||
) { padding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
LazyColumn(
|
||||
// Scaffold hands the content its insets (status bar, the bottom action bar and,
|
||||
// in landscape, a side 3-button navigation bar); consume them so the IME padding
|
||||
// below only adds what the keyboard covers beyond them.
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.consumeWindowInsets(padding)
|
||||
.imePaddingSafe(),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = padding.calculateStartPadding(layoutDirection),
|
||||
top = padding.calculateTopPadding(),
|
||||
end = padding.calculateEndPadding(layoutDirection),
|
||||
bottom = padding.calculateBottomPadding() + 16.dp,
|
||||
),
|
||||
) {
|
||||
item(key = "intro", contentType = "intro") {
|
||||
BackupConflictIntro(conflict, Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
}
|
||||
eventDiffItems(conflict, presentation, ui, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The default layout: removed / added / changed sections of labelled, typed entries. */
|
||||
internal fun LazyListScope.genericDiffItems(
|
||||
rows: List<ReviewRow>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(
|
||||
rows,
|
||||
contentType = { _, row ->
|
||||
when (row) {
|
||||
is ReviewRow.Section -> "section"
|
||||
is ReviewRow.Note -> "note"
|
||||
is ReviewRow.Group -> "group"
|
||||
is ReviewRow.Entry -> row.item::class.simpleName
|
||||
}
|
||||
},
|
||||
) { _, row ->
|
||||
when (row) {
|
||||
is ReviewRow.Section -> SectionRow(row)
|
||||
is ReviewRow.Note -> DetailText("• " + stringRes(row.textRes), Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
|
||||
is ReviewRow.Group -> GroupLabel(row.labelRes)
|
||||
is ReviewRow.Entry -> ReviewEntry(row.item, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GroupLabel(labelRes: Int) {
|
||||
Text(
|
||||
text = stringRes(labelRes),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SectionRow(row: ReviewRow.Section) {
|
||||
val color =
|
||||
when (row.tone) {
|
||||
Tone.REMOVED -> MaterialTheme.colorScheme.error
|
||||
Tone.ADDED -> MaterialTheme.colorScheme.primary
|
||||
Tone.CHANGED -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringRes(row.titleRes, row.count.toString()),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = color,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewActions(
|
||||
keepLabel: String,
|
||||
restoreLabel: String,
|
||||
onRestore: (() -> Unit)?,
|
||||
onKeepNew: () -> Unit,
|
||||
) {
|
||||
Surface(tonalElevation = 3.dp) {
|
||||
Row(
|
||||
// Scaffold only insets its content slot; a bottomBar handles its own. The padding
|
||||
// goes on the content, not the Surface, so the bar's background still reaches the
|
||||
// screen edge while the buttons sit above the 3-button navigation (and above the
|
||||
// keyboard, if one is up) instead of underneath it.
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.imePaddingSafe()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// Keeping the new version is the default: it's what is on relays already. Restoring
|
||||
// always reverts another app's change, so it is the red, secondary choice.
|
||||
if (onRestore != null) {
|
||||
OutlinedButton(
|
||||
onClick = onRestore,
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text(restoreLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Button(onClick = onKeepNew, modifier = Modifier.weight(1f).height(52.dp)) {
|
||||
Text(keepLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Follow list updated", "Mute list cleared"…: the event and what happened to it, as a title. */
|
||||
@Composable
|
||||
internal fun eventTitle(conflict: ReplaceableBackupConflict): String {
|
||||
val name = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() }
|
||||
// Says what happened to it, not just what it is: "Community list updated".
|
||||
val emptied = conflict.incoming.tags.isEmpty() && conflict.incoming.content.isEmpty()
|
||||
return stringRes(if (emptied) R.string.backup_review_title_cleared else R.string.backup_review_title_updated, name)
|
||||
}
|
||||
|
||||
/** The two choices, worded for what they do to this event: "Keep 120" / "Restore 523". */
|
||||
@Composable
|
||||
private fun actionLabels(conflict: ReplaceableBackupConflict): Pair<String, String> =
|
||||
when (val diff = conflict.diff) {
|
||||
is ContactListDiff -> {
|
||||
val (saved, new) = conflict.followCounts
|
||||
stringRes(R.string.backup_action_keep_count, new.toString()) to stringRes(R.string.backup_action_restore_count, saved.toString())
|
||||
}
|
||||
is MuteListDiff -> {
|
||||
val removed = diff.publicMutes.removed.size
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
if (removed > 0) stringRes(R.string.backup_action_remute, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
|
||||
}
|
||||
is SimpleGroupListDiff -> {
|
||||
val removed = diff.groups.removed.size
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
|
||||
}
|
||||
is MetadataDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_profile)
|
||||
is RelayListDiff ->
|
||||
if (conflict.eventType == BackupEventType.BLOCKED_RELAYS && diff.relays.removed.isNotEmpty()) {
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
stringRes(
|
||||
R.string.backup_action_block_again,
|
||||
diff.relays.removed.size
|
||||
.toString(),
|
||||
)
|
||||
} else {
|
||||
stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays)
|
||||
}
|
||||
is AdvertisedRelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays)
|
||||
is ChannelListDiff -> rejoinLabels(diff.channels.removed.size)
|
||||
is CommunityListDiff -> rejoinLabels(diff.communities.removed.size)
|
||||
is EphemeralChatListDiff -> rejoinLabels(diff.rooms.removed.size)
|
||||
is FavoriteAlgoFeedsListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_feeds)
|
||||
is HashtagListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics)
|
||||
// Geohashes are places, not topics: the screen counts "places gone" under "Followed
|
||||
// locations", so the button has to agree with the page it sits on.
|
||||
is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_places)
|
||||
is TrustProviderListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_providers)
|
||||
is CashuWalletDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_wallet)
|
||||
else -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_conflict_restore_mine)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rejoinLabels(removed: Int): Pair<String, String> =
|
||||
stringRes(R.string.backup_conflict_keep_new) to
|
||||
if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine)
|
||||
|
||||
/** When the other app changed it, and what the event is for. */
|
||||
@Composable
|
||||
private fun BackupConflictIntro(
|
||||
conflict: ReplaceableBackupConflict,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = conflictTones().removed,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(eventTypeExplainer(conflict.eventType)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
|
||||
@Composable
|
||||
internal fun ReviewEntry(
|
||||
item: ReviewItem,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
when (item) {
|
||||
is ReviewItem.Person -> PersonEntry(item, accountViewModel, nav)
|
||||
is ReviewItem.Thread ->
|
||||
LoadNote(item.eventId, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
Column(EntryPadding) {
|
||||
NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav)
|
||||
item.detail?.let { DetailText(it) }
|
||||
}
|
||||
} else {
|
||||
TextEntry(item.eventId.toShortDisplay(), item.detail)
|
||||
}
|
||||
}
|
||||
is ReviewItem.Addressable ->
|
||||
LoadAddressableNote(item.address, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
Column(EntryPadding) {
|
||||
NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav)
|
||||
item.detail?.let { DetailText(it) }
|
||||
}
|
||||
} else {
|
||||
TextEntry(item.address.dTag.ifBlank { item.address.toValue() }, item.detail)
|
||||
}
|
||||
}
|
||||
is ReviewItem.PublicChat -> PublicChatEntry(item, accountViewModel, nav)
|
||||
is ReviewItem.ChatRoom ->
|
||||
TextEntry(item.roomId.id + " @ " + item.roomId.relayUrl.url, item.detail) { nav.nav(routeFor(item.roomId)) }
|
||||
is ReviewItem.Relay -> TextEntry(item.url.url, item.detail) { nav.nav(Route.RelayInfo(item.url.url)) }
|
||||
is ReviewItem.Text -> TextEntry(item.text, item.detail)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonEntry(
|
||||
item: ReviewItem.Person,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadUser(item.pubKey, accountViewModel) { user ->
|
||||
if (user == null) {
|
||||
TextEntry(item.pubKey.toShortDisplay(), item.detail)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { nav.nav(routeFor(user)) }.then(EntryPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UserPicture(user, Size35dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Column(Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
// Subscribes to the user's metadata on relays, so unknown people resolve.
|
||||
UsernameDisplay(user, accountViewModel = accountViewModel)
|
||||
item.detail?.let { DetailText(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PublicChatEntry(
|
||||
item: ReviewItem.PublicChat,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadPublicChatChannel(item.channelId, accountViewModel) { channel ->
|
||||
// Subscribes to the channel's metadata on relays and recomposes when it arrives.
|
||||
val state by observeChannel(channel, accountViewModel)
|
||||
val name = state?.channel?.toBestDisplayName() ?: channel.toBestDisplayName()
|
||||
TextEntry(name, item.detail) { nav.nav(routeFor(channel)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TextEntry(
|
||||
text: String,
|
||||
detail: String?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val modifier = if (onClick != null) Modifier.fillMaxWidth().clickable(onClick = onClick) else Modifier.fillMaxWidth()
|
||||
Column(modifier.then(EntryPadding)) {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (onClick != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
detail?.let { DetailText(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
+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.commons.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/** The three meanings every conflict visual uses: lost, gained, edited. Theme-aware. */
|
||||
@Immutable
|
||||
class ConflictTones(
|
||||
val removed: Color,
|
||||
val added: Color,
|
||||
val changed: Color,
|
||||
val kept: Color,
|
||||
) {
|
||||
fun removedContainer() = removed.copy(alpha = 0.14f)
|
||||
|
||||
fun addedContainer() = added.copy(alpha = 0.14f)
|
||||
|
||||
fun changedContainer() = changed.copy(alpha = 0.14f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun conflictTones(): ConflictTones {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
return ConflictTones(
|
||||
removed = scheme.error,
|
||||
added = scheme.allGoodColor,
|
||||
changed = scheme.warningColor,
|
||||
kept = scheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
/** "523 → 120": the saved size against the new one, the headline of list conflicts. */
|
||||
@Composable
|
||||
fun HeroCounts(
|
||||
savedLabel: String,
|
||||
saved: Int,
|
||||
newLabel: String,
|
||||
new: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tones = conflictTones()
|
||||
Row(modifier, verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Column {
|
||||
Text(savedLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
Text(saved.toString(), fontSize = 44.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp))
|
||||
Column {
|
||||
Text(newLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
Text(
|
||||
new.toString(),
|
||||
fontSize = 44.sp,
|
||||
lineHeight = 44.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (new < saved) tones.removed else tones.added,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One segment of a [SplitBar]. */
|
||||
@Immutable
|
||||
class BarSegment(
|
||||
val weight: Int,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
/** A single bar split into kept / dropped / new segments, proportional to their counts. */
|
||||
@Composable
|
||||
fun SplitBar(
|
||||
segments: List<BarSegment>,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 14.dp,
|
||||
) {
|
||||
val visible = segments.filter { it.weight > 0 }
|
||||
Row(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(height / 2))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
visible.forEach {
|
||||
Box(Modifier.weight(it.weight.toFloat()).fillMaxHeight().background(it.color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A colored dot and a label: the legend under a [SplitBar]. */
|
||||
@Composable
|
||||
fun LegendItem(
|
||||
color: Color,
|
||||
text: String,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Text(text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
|
||||
/** A big signed number over a caption, on a tinted tile. */
|
||||
@Composable
|
||||
fun StatTile(
|
||||
value: String,
|
||||
caption: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(color.copy(alpha = 0.12f))
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(value, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = color)
|
||||
Text(caption, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small uppercase tag: DROPPED, NEW, RENAMED… */
|
||||
@Composable
|
||||
fun StatusTag(
|
||||
text: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
modifier = modifier.clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.14f)).padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
color = color,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/** An uppercase section caption. */
|
||||
@Composable
|
||||
fun SectionCaption(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A 3×3 color fingerprint of a key, so two keys can be told apart at a glance without
|
||||
* reading hex. Deterministic: the same key always draws the same grid.
|
||||
*/
|
||||
@Composable
|
||||
fun KeyFingerprint(
|
||||
hex: String,
|
||||
borderColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bytes = runCatching { Hex.decode(hex) }.getOrNull() ?: ByteArray(0)
|
||||
val colors =
|
||||
List(9) { i ->
|
||||
val b = if (bytes.isEmpty()) 0 else bytes[i % bytes.size].toInt() and 0xFF
|
||||
val b2 = if (bytes.isEmpty()) 0 else bytes[(i + 9) % bytes.size].toInt() and 0xFF
|
||||
Color.hsl(hue = b / 255f * 360f, saturation = 0.55f + (b2 % 30) / 100f, lightness = 0.62f)
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(borderColor)
|
||||
.padding(3.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
colors.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
row.forEach { Box(Modifier.size(24.dp).clip(RoundedCornerShape(6.dp)).background(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tinted rounded panel, the base of every hero block. */
|
||||
@Composable
|
||||
fun TintedPanel(
|
||||
tint: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(tint)
|
||||
.padding(18.dp),
|
||||
) { content() }
|
||||
}
|
||||
+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
|
||||
@@ -67,6 +73,8 @@ import com.vitorpamplona.amethyst.commons.resources.feed_is_empty
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_tab_everything
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_threads
|
||||
import com.vitorpamplona.amethyst.commons.resources.refresh
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.PagerStateKeys
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
|
||||
@@ -96,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictCards
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.NewHashtagPostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
|
||||
@@ -395,7 +404,9 @@ fun HomeFeeds(
|
||||
nav = nav,
|
||||
routeForLastRead = routeForLastRead,
|
||||
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
|
||||
onEmpty = { HomeFeedEmpty(onRefresh) },
|
||||
onEmpty = { WithBackupConflicts(accountViewModel, nav) { HomeFeedEmpty(onRefresh) } },
|
||||
onError = { WithBackupConflicts(accountViewModel, nav) { FeedError(it, onRefresh) } },
|
||||
onLoading = { WithBackupConflicts(accountViewModel, nav) { LoadingFeed() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -428,11 +439,30 @@ fun FeedLoaded(
|
||||
nav: INav,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
val backupConflicts by accountViewModel.account.settings.backupConflicts
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// The list keeps its position anchored to the first visible post, so cards that appear
|
||||
// while the feed sits at the top would be inserted just above the screen. Only then (not
|
||||
// when the user has scrolled down) bring them into view.
|
||||
val hasConflicts = backupConflicts.isNotEmpty()
|
||||
val atTop by remember(listState) { derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } }
|
||||
val cardsAppearedAtTop = remember(hasConflicts) { hasConflicts && atTop }
|
||||
LaunchedEffect(cardsAppearedAtTop) {
|
||||
if (cardsAppearedAtTop) listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
// Only while a conflict is open, so the list's first item (and with it how the
|
||||
// scroll position anchors when new posts arrive) is unchanged the rest of the time.
|
||||
if (hasConflicts) {
|
||||
item(key = "backupConflicts", contentType = "backupConflicts") {
|
||||
BackupConflictCards(backupConflicts, nav, onAccept = accountViewModel::acceptExternalBackupVersion)
|
||||
}
|
||||
}
|
||||
if (liveSection != null) {
|
||||
item {
|
||||
DisplayLiveBubbles(liveSection, accountViewModel, nav)
|
||||
@@ -513,6 +543,43 @@ fun HomeFeedEmptyPreview() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The backup-conflict cards above a feed that has no posts to show yet, so an empty or
|
||||
* still-loading Home doesn't hide the question. They get at most half the screen and scroll
|
||||
* there if there are many.
|
||||
*/
|
||||
@Composable
|
||||
private fun WithBackupConflicts(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val backupConflicts by accountViewModel.account.settings.backupConflicts
|
||||
.collectAsStateWithLifecycle()
|
||||
if (backupConflicts.isEmpty()) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
val padding = rememberFeedContentPadding(FeedPadding)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
BackupConflictCards(
|
||||
backupConflicts,
|
||||
nav,
|
||||
onAccept = accountViewModel::acceptExternalBackupVersion,
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
start = padding.calculateStartPadding(layoutDirection),
|
||||
top = padding.calculateTopPadding(),
|
||||
end = padding.calculateEndPadding(layoutDirection),
|
||||
),
|
||||
)
|
||||
Box(Modifier.weight(1f)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeFeedEmpty(onRefresh: () -> Unit) {
|
||||
Column(
|
||||
|
||||
+1
-8
@@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
@@ -50,7 +49,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isHlsMimeType
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
@@ -256,16 +254,11 @@ fun UrlImageView(
|
||||
|
||||
val isVideo = content is MediaUrlVideo
|
||||
val artworkUri = (content as? MediaUrlVideo)?.artworkUri
|
||||
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
|
||||
// Coil's VideoFrameDecoder can extract a frame from .mp4/.webm but not from an HLS .m3u8
|
||||
// playlist (it's a text manifest). For an HLS video without a separate artwork URL, sending
|
||||
// the playlist to SubcomposeAsyncImage just produces an Error state and a stand-in icon.
|
||||
// Skip the fetch in that case and render blurhash + play overlay directly.
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
val imageModelUrl = artworkUri ?: bridgedUrl
|
||||
val imageModelUrl = artworkUri ?: content.url
|
||||
val canLoadAsImage = !isVideo || artworkUri != null || !isHlsMedia(content.url, content.mimeType)
|
||||
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -206,7 +206,13 @@ class LocalBlossomCacheRedirectInterceptorTest {
|
||||
url: String,
|
||||
captured: MutableList<String>,
|
||||
): Interceptor.Chain {
|
||||
val request = Request.Builder().url(url.toHttpUrl()).build()
|
||||
// Marked the way the media loaders mark their downloads; only those are bridged.
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url.toHttpUrl())
|
||||
.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.MEDIA)
|
||||
.build()
|
||||
return Proxy.newProxyInstance(
|
||||
Interceptor.Chain::class.java.classLoader,
|
||||
arrayOf(Interceptor.Chain::class.java),
|
||||
|
||||
+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))
|
||||
}
|
||||
}
|
||||
+49
@@ -48,9 +48,16 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -387,6 +394,48 @@ class RadioBurstEstimatorTest {
|
||||
}
|
||||
|
||||
class LoopbackExclusionTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
/** Runs [UsageCountingInterceptor] over a chain whose downstream sent [sentUrl] for [url]. */
|
||||
private fun countedRequests(
|
||||
url: String,
|
||||
sentUrl: String,
|
||||
): Long? {
|
||||
val accountant = ResourceUsageAccountant(ResourceUsageStore(File(temp.root, "u.json")), CoroutineScope(Dispatchers.Unconfined), epochDay = { 1L })
|
||||
val interceptor = UsageCountingInterceptor(accountant, isMobile = { true }, isForeground = { true }, nowMs = { 0L })
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
val chain = mockk<Interceptor.Chain>()
|
||||
every { chain.request() } returns request
|
||||
every { chain.proceed(any()) } answers {
|
||||
Response
|
||||
.Builder()
|
||||
.request(request.newBuilder().url(sentUrl).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("".toResponseBody(null))
|
||||
.build()
|
||||
}
|
||||
interceptor.intercept(chain).close()
|
||||
|
||||
return runBlocking { accountant.allDaysIncludingLive() }[1L].orEmpty()[UsageKeys.netReqs(UsageKeys.ROLE_OTHER, mobile = true, foreground = true)]
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestRewrittenToTheLocalCacheIsNotCounted() {
|
||||
// This interceptor is outermost: it sees the CDN URL, but the request that actually went
|
||||
// out (LocalBlossomCacheRedirectInterceptor's rewrite) never touched the radio.
|
||||
val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
assertNull(countedRequests("https://cdn.example.com/$sha.jpg", "http://127.0.0.1:24242/$sha.jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestToARealHostIsCounted() {
|
||||
assertEquals(1L, countedRequests("https://cdn.example.com/a.jpg", "https://cdn.example.com/a.jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loopbackHostsAreRecognizedAndRealHostsAreNot() {
|
||||
assertTrue(UsageCountingInterceptor.isLoopback("127.0.0.1"))
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class BlossomServerResolverTest {
|
||||
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
private val author = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@After
|
||||
fun tearDown() = scope.cancel()
|
||||
|
||||
/** A resolver whose author server-list lookups are counted and answered by [serverList]. */
|
||||
private fun resolver(
|
||||
lookups: AtomicInteger,
|
||||
serverList: () -> Flow<BlossomServersEvent>,
|
||||
) = BlossomServerResolver(
|
||||
loggedInUsers = { emptyList() },
|
||||
blossomServers = { addresses ->
|
||||
lookups.incrementAndGet()
|
||||
addresses.map { serverList() }
|
||||
},
|
||||
httpClientBuilder = mockk<IRoleBasedHttpClientBuilder>(relaxed = true),
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun concurrentLookupsOfTheSameUriShareOneResolution() =
|
||||
runBlocking {
|
||||
val lookups = AtomicInteger(0)
|
||||
// The author's server list never arrives, like a relay that is slow to answer.
|
||||
val resolver = resolver(lookups) { flow { awaitCancellation() } }
|
||||
val uri = "blossom:$sha.jpg?as=$author"
|
||||
|
||||
val callers = List(5) { scope.launch { resolver.findServers(uri) } }
|
||||
delay(300)
|
||||
|
||||
assertEquals(1, lookups.get())
|
||||
callers.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnresolvableUriIsNotRetriedUntilTheMissExpiresOrCachesAreCleared() =
|
||||
runBlocking {
|
||||
val lookups = AtomicInteger(0)
|
||||
// No hints and no authors: resolves to nothing right away.
|
||||
val resolver = resolver(lookups) { flow { } }
|
||||
val uri = "blossom:$sha.jpg"
|
||||
|
||||
assertNull(resolver.findServers(uri))
|
||||
assertNull(resolver.findServers(uri))
|
||||
assertEquals(1, lookups.get())
|
||||
|
||||
resolver.clearCaches()
|
||||
assertNull(resolver.findServers(uri))
|
||||
assertEquals(2, lookups.get())
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
|
||||
private val sha256HexRegex = Regex("[0-9a-f]{64}")
|
||||
private val blossomLastSegmentRegex = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
|
||||
|
||||
/**
|
||||
* Converts this media content into a Coil/ExoPlayer-friendly model string.
|
||||
*
|
||||
* When the local-Blossom-cache bridge is active and the content has a
|
||||
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>&as=<authorPubKey>`
|
||||
* URI. The Coil pipeline recognises the scheme and routes the request
|
||||
* through `BlossomServerResolver`, which short-circuits to the local cache
|
||||
* at `127.0.0.1:24242`.
|
||||
*
|
||||
* Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI,
|
||||
* or a live stream) returns the original URL unchanged so today's
|
||||
* direct-to-CDN behaviour is preserved.
|
||||
*/
|
||||
fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String =
|
||||
bridgeUrl(
|
||||
url = url,
|
||||
useBridge = useLocalBlossomBridge,
|
||||
mimeType = mimeType,
|
||||
authorPubKey = authorPubKey,
|
||||
skipBridge = this is MediaUrlVideo && isLiveStream,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridge entry point for raw URL strings (e.g. profile pictures) that go
|
||||
* through a Coil fetcher routed by type (`ProfilePictureUrl`) and therefore
|
||||
* bypass [com.vitorpamplona.quartz.nipB7Blossom.BlossomUri] processing
|
||||
* entirely.
|
||||
*
|
||||
* Returns a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pubkey>`
|
||||
* URL so the request can flow through `NetworkFetcher` unchanged. Falls
|
||||
* back to the original URL when no sha256 can be recovered from the path
|
||||
* or when the bridge is off.
|
||||
*
|
||||
* @param localCacheBase the local Blossom cache origin (default `http://127.0.0.1:24242`).
|
||||
* @param authorPubKey 64-char lowercase hex pubkey appended as `as=` so the
|
||||
* cache can consult that author's BUD-03 server list on miss.
|
||||
*/
|
||||
fun bridgeProfilePictureUrl(
|
||||
url: String?,
|
||||
useBridge: Boolean,
|
||||
authorPubKey: String? = null,
|
||||
localCacheBase: String = DEFAULT_LOCAL_CACHE_BASE,
|
||||
): String? {
|
||||
if (url == null) return null
|
||||
if (!useBridge) return url
|
||||
if (url.startsWith("blossom:", ignoreCase = true)) return url
|
||||
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
|
||||
|
||||
val sha = extractSha256FromUrlPath(url) ?: return url
|
||||
val ext = guessExtension(url, null)
|
||||
val serverBase = extractServerBase(url, sha) ?: return url
|
||||
|
||||
val params =
|
||||
buildList {
|
||||
add("xs=${percentEncode(serverBase)}")
|
||||
authorPubKey
|
||||
?.lowercase()
|
||||
?.takeIf { sha256HexRegex.matches(it) }
|
||||
?.let { add("as=$it") }
|
||||
}
|
||||
|
||||
return "${localCacheBase.removeSuffix("/")}/$sha.$ext?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
const val DEFAULT_LOCAL_CACHE_BASE = "http://127.0.0.1:24242"
|
||||
|
||||
private fun bridgeUrl(
|
||||
url: String,
|
||||
useBridge: Boolean,
|
||||
mimeType: String?,
|
||||
authorPubKey: String?,
|
||||
skipBridge: Boolean,
|
||||
): String {
|
||||
if (!useBridge || skipBridge) return url
|
||||
if (url.startsWith("blossom:", ignoreCase = true)) return url
|
||||
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
|
||||
|
||||
// The local Blossom cache fetches `<xs>/<sha>.<ext>` on miss per BUD-01,
|
||||
// which only works when the upstream URL is itself BUD-01 layout — the
|
||||
// file at `<xs>/<sha>.<ext>` is the one named in the URL path, not the
|
||||
// imeta `x` hash (which on resizing CDNs may identify a different blob
|
||||
// than the URL filename, e.g. `x` = post-resize, `ox` = original).
|
||||
// Always use the URL's sha; never trust the imeta override.
|
||||
val sha = extractSha256FromUrlPath(url) ?: return url
|
||||
|
||||
val ext = guessExtension(url, mimeType)
|
||||
val serverBase = extractServerBase(url, sha) ?: return url
|
||||
|
||||
val authors =
|
||||
authorPubKey
|
||||
?.lowercase()
|
||||
?.takeIf { sha256HexRegex.matches(it) }
|
||||
?.let { listOf(it) }
|
||||
?: emptyList()
|
||||
|
||||
return BlossomUri(
|
||||
sha256 = sha,
|
||||
extension = ext,
|
||||
servers = listOf(serverBase),
|
||||
authors = authors,
|
||||
size = null,
|
||||
).toUriString()
|
||||
}
|
||||
|
||||
private fun percentEncode(input: String): String {
|
||||
val sb = StringBuilder(input.length)
|
||||
for (c in input) {
|
||||
if (c.isLetterOrDigit() || c in "-._~") {
|
||||
sb.append(c)
|
||||
} else {
|
||||
for (b in c.toString().encodeToByteArray()) {
|
||||
sb.append('%')
|
||||
sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun extractSha256FromUrlPath(url: String): String? {
|
||||
// Per Blossom (BUD-01) the last path segment must be exactly
|
||||
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
|
||||
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
|
||||
// the bridge must leave them alone — rewriting them would point the
|
||||
// local cache at a fallback `xs=` server that doesn't host the blob.
|
||||
val pathPart = url.substringBefore('?').substringBefore('#')
|
||||
val lastSegment = pathPart.substringAfterLast('/')
|
||||
val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null
|
||||
return match.groupValues[1].lowercase()
|
||||
}
|
||||
|
||||
private fun guessExtension(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): String {
|
||||
val pathPart = url.substringBefore('?').substringBefore('#')
|
||||
val lastDot = pathPart.lastIndexOf('.')
|
||||
val lastSlash = pathPart.lastIndexOf('/')
|
||||
if (lastDot > lastSlash && lastDot >= 0) {
|
||||
val ext = pathPart.substring(lastDot + 1).lowercase()
|
||||
if (ext.isNotEmpty() && ext.length <= 8 && ext.all { it.isLetterOrDigit() }) {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType != null) {
|
||||
for ((extension, mt) in mimeTypeMap) {
|
||||
if (mt.equals(mimeType, ignoreCase = true)) return extension
|
||||
}
|
||||
}
|
||||
|
||||
return "bin"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL prefix that the local Blossom cache should append `/<sha>`
|
||||
* to in order to reach the original blob, preserving any path prefix the
|
||||
* upstream CDN uses (e.g. `https://cdn.nostr.build/i` for nostr.build's
|
||||
* `/i/<sha>` scheme). Falls back to scheme+host when the sha can't be
|
||||
* located in the path.
|
||||
*/
|
||||
private fun extractServerBase(
|
||||
url: String,
|
||||
sha: String,
|
||||
): String? {
|
||||
val pathPart = url.substringBefore('?').substringBefore('#')
|
||||
val schemeEnd = pathPart.indexOf("://")
|
||||
if (schemeEnd < 0) return null
|
||||
val hostStart = schemeEnd + 3
|
||||
if (hostStart >= pathPart.length) return null
|
||||
|
||||
val shaIndex = pathPart.lastIndexOf(sha, ignoreCase = true)
|
||||
if (shaIndex >= 0) {
|
||||
// Anchor on the slash immediately preceding the sha so the cache
|
||||
// can append "/<sha>" verbatim per the local-blossom-cache spec.
|
||||
val slashBeforeSha = pathPart.lastIndexOf('/', shaIndex - 1)
|
||||
if (slashBeforeSha > hostStart - 1) {
|
||||
return pathPart.substring(0, slashBeforeSha)
|
||||
}
|
||||
}
|
||||
return extractHostBase(pathPart)
|
||||
}
|
||||
|
||||
private fun extractHostBase(url: String): String? {
|
||||
val schemeEnd = url.indexOf("://")
|
||||
if (schemeEnd < 0) return null
|
||||
val afterScheme = schemeEnd + 3
|
||||
var end = url.length
|
||||
for (i in afterScheme until url.length) {
|
||||
val c = url[i]
|
||||
if (c == '/' || c == '?' || c == '#') {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (end <= afterScheme) return null
|
||||
return url.substring(0, end)
|
||||
}
|
||||
+4
@@ -825,6 +825,10 @@ sealed class Route {
|
||||
val replyTo: HexKey? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BackupConflictReview(
|
||||
val slot: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayInfo(
|
||||
val url: String,
|
||||
) : Route()
|
||||
|
||||
-273
@@ -1,273 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MediaUrlContentExtTest {
|
||||
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
|
||||
@Test
|
||||
fun bridgeOffReturnsOriginalUrl() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/$sha.jpg", hash = sha)
|
||||
assertEquals("https://cdn.example.com/$sha.jpg", image.toCoilModel(useLocalBlossomBridge = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullHashReturnsOriginalUrl() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = null)
|
||||
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidHashReturnsOriginalUrl() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = "not-hex")
|
||||
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blossomUriReturnedUnchanged() {
|
||||
val image = MediaUrlImage(url = "blossom:$sha.jpg?xs=https://cdn.example.com", hash = sha)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveStreamReturnsOriginalUrl() {
|
||||
val video = MediaUrlVideo(url = "https://stream.example.com/play.m3u8", hash = sha, isLiveStream = true)
|
||||
assertEquals("https://stream.example.com/play.m3u8", video.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnRewritesPlainHttpsUrl() {
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://nostr.build/i/abc", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnPreservesNostrBuildPathPrefix() {
|
||||
val image = MediaUrlImage(url = "https://cdn.nostr.build/i/$sha.jpg", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.nostr.build/i", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnFlatBlossomPathYieldsHostOnlyXs() {
|
||||
val image = MediaUrlImage(url = "https://blossom.primal.net/$sha.jpg", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://blossom.primal.net", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnInfersExtensionFromMimeType() {
|
||||
// BUD-01 allows `<sha>` without an extension; mimeType supplies one.
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png")
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnFallsBackToBinExtension() {
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonHttpUrlReturnsOriginal() {
|
||||
val image = MediaUrlImage(url = "ftp://example.com/file", hash = sha)
|
||||
assertEquals("ftp://example.com/file", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uppercaseHashNormalisedToLowercase() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase())
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorPubKeyAddedAsAsParam() {
|
||||
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
|
||||
val image =
|
||||
MediaUrlImage(
|
||||
url = "https://cdn.example.com/$sha.jpg",
|
||||
hash = sha,
|
||||
authorPubKey = authorPub,
|
||||
)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com&as=$authorPub", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidAuthorPubKeyDropped() {
|
||||
val image =
|
||||
MediaUrlImage(
|
||||
url = "https://cdn.example.com/$sha.jpg",
|
||||
hash = sha,
|
||||
authorPubKey = "not-a-pubkey",
|
||||
)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() {
|
||||
// The imeta `x` hash refers to a blob whose canonical Blossom location
|
||||
// is /<sha>.<ext>, but the upstream URL serves it under a different
|
||||
// path (https://i.nostr.build/M5AwJ.gif). Routing this through the
|
||||
// local cache would set xs=https://i.nostr.build, and the cache would
|
||||
// fetch https://i.nostr.build/<sha>.gif on miss, which 404s.
|
||||
val url = "https://i.nostr.build/M5AwJ.gif"
|
||||
val image = MediaUrlImage(url = url, hash = sha)
|
||||
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() {
|
||||
val url = "https://i.nostr.build/M5AwJ.gif"
|
||||
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlNullReturnsNull() {
|
||||
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlOffReturnsOriginal() {
|
||||
assertEquals(
|
||||
"https://cdn.example.com/avatar.jpg",
|
||||
bridgeProfilePictureUrl("https://cdn.example.com/avatar.jpg", useBridge = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlExtractsShaFromPath() {
|
||||
val url = "https://nostr.build/i/$sha.jpg"
|
||||
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build%2Fi&as=$authorPub",
|
||||
bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlPreservesNostrBuildPath() {
|
||||
val url = "https://cdn.nostr.build/i/$sha.jpg"
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
|
||||
bridgeProfilePictureUrl(url, useBridge = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() {
|
||||
val url = "https://nostr.build/avatar.jpg"
|
||||
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlBlossomUriReturnedUnchanged() {
|
||||
val uri = "blossom:$sha.jpg?xs=https://nostr.build"
|
||||
assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() {
|
||||
// share.yabu.me layout: <cache-prefix-sha>/<blob-sha>.<ext>
|
||||
val image =
|
||||
MediaUrlImage(
|
||||
url = "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
|
||||
hash = null,
|
||||
)
|
||||
assertEquals(
|
||||
"blossom:28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
|
||||
image.toCoilModel(useLocalBlossomBridge = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlRewritesShaInLastPathSegmentWithHexPrefix() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
|
||||
bridgeProfilePictureUrl(
|
||||
"https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
|
||||
useBridge = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnSkipsWhenLastSegmentIsNotSha() {
|
||||
// Per BUD-01 the last segment is the blob; if it isn't a sha256, the
|
||||
// URL isn't a Blossom blob even if an earlier segment is hex.
|
||||
val url = "https://example.com/$sha/avatar.jpg"
|
||||
val image = MediaUrlImage(url = url, hash = null)
|
||||
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlSkipsWhenLastSegmentIsNotSha() {
|
||||
val url = "https://example.com/$sha/avatar.jpg"
|
||||
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
|
||||
// nostr.build /i/ layout: <prefix>_<sha>.<ext>. The hex inside the
|
||||
// filename isn't a Blossom blob per BUD-01 — the last segment must
|
||||
// be exactly <sha> or <sha>.<ext>.
|
||||
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
|
||||
val image = MediaUrlImage(url = url, hash = null)
|
||||
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
|
||||
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
|
||||
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() {
|
||||
val url = "https://example.com/${sha}_thumb.jpg"
|
||||
val image = MediaUrlImage(url = url, hash = null)
|
||||
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnUsesUrlShaNotImetaWhenTheyDiffer() {
|
||||
// On resizing CDNs the imeta `x` (post-resize) can differ from the
|
||||
// `ox` (original) embedded in the URL. The upstream file is named
|
||||
// after the URL's sha, so the cache request must use that — using
|
||||
// the imeta `x` would point xs= at a non-existent path on miss.
|
||||
val urlSha = "f24026b7281e598973a775adefb1b9a13b9f037a94ac98dd48ccc91b83f4b7b3"
|
||||
val imetaX = "6932a918de1bfae3bf6611794ff54dd677013d22b760a9212117a0bd9079badf"
|
||||
val image = MediaUrlImage(url = "https://image.nostr.build/$urlSha.png", hash = imetaX)
|
||||
assertEquals(
|
||||
"blossom:$urlSha.png?xs=https://image.nostr.build",
|
||||
image.toCoilModel(useLocalBlossomBridge = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.service.http
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -41,7 +42,7 @@ class DualHttpClientManager(
|
||||
keyCache: EncryptionKeyCache,
|
||||
scope: CoroutineScope,
|
||||
dns: SurgeDns,
|
||||
shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = null,
|
||||
// Required (not nullable): every general-purpose HTTP client we mint must be
|
||||
// wired into the OnionLocationCache so the app's `.onion`-routing behavior
|
||||
// is uniform across image, upload, NIP-05, money, preview, and push roles.
|
||||
@@ -53,8 +54,10 @@ class DualHttpClientManager(
|
||||
// Signs BUD-01 read-auth to retry auth-gated Blossom downloads on 401.
|
||||
// See [BlossomReadAuthInterceptor].
|
||||
blossomReadAuth: Interceptor? = null,
|
||||
// Told when the local Blossom cache refuses a connection. See [LocalBlossomCacheRedirectInterceptor].
|
||||
onLocalBlossomCacheUnreachable: () -> Unit = {},
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth)
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth, onLocalBlossomCacheUnreachable)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
@@ -96,10 +99,23 @@ class DualHttpClientManager(
|
||||
|
||||
/**
|
||||
* the okhttp can change on the manager without affecting other systems.
|
||||
*
|
||||
* [useProxy] is fixed when the factory is built (e.g. per video player pool), from the URL the
|
||||
* caller knew at the time. The URL that is finally requested can differ: a `blossom:` URI is
|
||||
* resolved to the local Blossom cache on `127.0.0.1:24242` only when the data source opens. Tor
|
||||
* refuses to connect to loopback/private addresses, so those requests must never take the proxied
|
||||
* client — the same rule RoleBasedHttpClientBuilder applies to URLs it sees upfront.
|
||||
*/
|
||||
class DynamicCallFactory(
|
||||
val useProxy: Boolean,
|
||||
val manager: DualHttpClientManager,
|
||||
) : Call.Factory {
|
||||
override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request)
|
||||
override fun newCall(request: Request): Call = manager.getHttpClient(shouldUseProxy(useProxy, request.url.toString())).newCall(request)
|
||||
|
||||
companion object {
|
||||
fun shouldUseProxy(
|
||||
useProxy: Boolean,
|
||||
url: String,
|
||||
): Boolean = useProxy && !RelayUrlNormalizer.isLocalHost(url) && !RelayUrlNormalizer.isOverlayNetwork(url)
|
||||
}
|
||||
}
|
||||
|
||||
+102
-17
@@ -20,43 +20,107 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.service.http
|
||||
|
||||
import okhttp3.Call
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.net.ConnectException
|
||||
|
||||
/**
|
||||
* App-wide OkHttp interceptor that transparently rewrites HTTP requests for
|
||||
* OkHttp interceptor that transparently rewrites media downloads of
|
||||
* sha256-keyed blobs to a local Blossom cache running on `127.0.0.1:24242`,
|
||||
* per https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md
|
||||
*
|
||||
* Activates when [shouldBridge] returns `true` AND the request URL contains
|
||||
* a 64-char hex sha256 segment in its path AND the host isn't already
|
||||
* `127.0.0.1`/`localhost`. The original scheme+host is appended as a `xs=`
|
||||
* proxy hint so the cache can fetch upstream on miss.
|
||||
* This is the only place feed media, videos and profile pictures are routed
|
||||
* to the cache: callers keep the real URL, so the Tor decision, Coil/ExoPlayer
|
||||
* cache keys and decryption-key lookups all see the origin.
|
||||
*
|
||||
* Coil's disk cache keys responses by the original `ImageRequest.data`, so
|
||||
* disk caching continues to work transparently even though the network
|
||||
* request now goes to localhost.
|
||||
* Bridging is **opt-in**: only requests the media loaders mark with the
|
||||
* [MEDIA_HEADER] header are considered (see [LocalBlossomMediaCallFactory]).
|
||||
* Anything else whose last path segment merely looks like a hash — a BUD-02
|
||||
* `GET /list/<pubkey>`, uploads, deletes, `HEAD` presence checks — must reach
|
||||
* its server. The marker header is always stripped, so no server ever sees it;
|
||||
* that is why Tor-proxied clients carry a non-bridging instance ([bridges] =
|
||||
* false) instead of none: Tor-routed media skips the cache but is still cleaned.
|
||||
*
|
||||
* A marked `GET` is rewritten when [shouldBridge] returns `true`, it carries no
|
||||
* `Authorization` (an auth-gated blob goes to the host the token was signed
|
||||
* for), its last path segment is `<sha256>[.ext]`, and the host isn't already
|
||||
* the cache. The original scheme+host(+path prefix) is passed as the `xs=`
|
||||
* hint so the cache can fetch upstream on miss.
|
||||
*
|
||||
* Encrypted blobs are looked up in [keyCache] by URL after this interceptor
|
||||
* runs, so their decryption key is registered under the rewritten URL too.
|
||||
*
|
||||
* When the cache refuses the connection (the app was closed since the last
|
||||
* probe), [onUnreachable] is told so the bridge can switch off, and the
|
||||
* request falls back to its original URL instead of failing.
|
||||
*/
|
||||
class LocalBlossomCacheRedirectInterceptor(
|
||||
private val shouldBridge: () -> Boolean,
|
||||
private val keyCache: EncryptionKeyCache? = null,
|
||||
private val onUnreachable: () -> Unit = {},
|
||||
val bridges: Boolean = true,
|
||||
// Last, so the `LocalBlossomCacheRedirectInterceptor { enabled }` trailing-lambda form binds here.
|
||||
// Told whether the request is a profile picture, for the "profile pictures only" setting.
|
||||
private val shouldBridge: (profilePicture: Boolean) -> Boolean,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val marked = chain.request()
|
||||
val kind = marked.header(MEDIA_HEADER)
|
||||
val request = if (kind != null) marked.newBuilder().removeHeader(MEDIA_HEADER).build() else marked
|
||||
|
||||
if (!shouldBridge()) return chain.proceed(request)
|
||||
if (request.method != "GET") return chain.proceed(request)
|
||||
|
||||
if (isLocalCache(request.url)) {
|
||||
// Already addressed to the cache (a resolved `blossom:` URI): there is no
|
||||
// original URL to fall back to here, but a dead cache must still be reported.
|
||||
try {
|
||||
return chain.proceed(request)
|
||||
} catch (e: ConnectException) {
|
||||
onUnreachable()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (!bridges || kind == null || request.header("Authorization") != null) return chain.proceed(request)
|
||||
|
||||
if (!shouldBridge(kind == PROFILE_PICTURE)) return chain.proceed(request)
|
||||
|
||||
val rewritten = rewriteIfApplicable(request.url) ?: return chain.proceed(request)
|
||||
|
||||
return chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.url(rewritten)
|
||||
.build(),
|
||||
)
|
||||
keyCache?.get(request.url.toString())?.let { keyCache.add(rewritten.toString(), it) }
|
||||
|
||||
val bridged =
|
||||
try {
|
||||
chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.url(rewritten)
|
||||
.build(),
|
||||
)
|
||||
} catch (e: ConnectException) {
|
||||
onUnreachable()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
if (bridged.isSuccessful) return bridged
|
||||
|
||||
// The cache answered, but not with the blob: it does not hold it and could not fetch it
|
||||
// from `xs` either (not every cache implements that, and the one that does can be offline,
|
||||
// still warming, or rate-limited). A miss is the ordinary state of a cache and must never
|
||||
// be worse than having no cache at all, so the origin is asked directly — without this the
|
||||
// 404 reached the caller and the image, video or encrypted file simply failed to load.
|
||||
//
|
||||
// Not reported through [onUnreachable]: the cache is alive and answering, so switching the
|
||||
// bridge off would be the wrong conclusion.
|
||||
bridged.close()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun isLocalCache(url: HttpUrl): Boolean = url.port == LOCAL_CACHE_PORT && (url.host == LOCAL_CACHE_HOST || url.host.equals("localhost", ignoreCase = true))
|
||||
|
||||
private fun rewriteIfApplicable(url: HttpUrl): HttpUrl? {
|
||||
val host = url.host
|
||||
if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null
|
||||
@@ -121,5 +185,26 @@ class LocalBlossomCacheRedirectInterceptor(
|
||||
const val LOCAL_CACHE_PORT = 24242
|
||||
const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT"
|
||||
private val BLOSSOM_LAST_SEGMENT_REGEX = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
|
||||
|
||||
/** Marks a request as a media download the local cache may serve. Stripped before sending. */
|
||||
const val MEDIA_HEADER = "X-Amethyst-Local-Blossom"
|
||||
const val MEDIA = "media"
|
||||
const val PROFILE_PICTURE = "profile-picture"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks every call it creates as a media download ([LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER]),
|
||||
* keeping a marker the request already carries (e.g. a profile picture's).
|
||||
*/
|
||||
class LocalBlossomMediaCallFactory(
|
||||
private val delegate: Call.Factory,
|
||||
private val kind: String = LocalBlossomCacheRedirectInterceptor.MEDIA,
|
||||
) : Call.Factory {
|
||||
override fun newCall(request: Request): Call =
|
||||
if (request.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER) != null) {
|
||||
delegate.newCall(request)
|
||||
} else {
|
||||
delegate.newCall(request.newBuilder().header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, kind).build())
|
||||
}
|
||||
}
|
||||
|
||||
+34
-14
@@ -55,12 +55,13 @@ class OkHttpClientFactory(
|
||||
val userAgent: String,
|
||||
private val dns: SurgeDns,
|
||||
/**
|
||||
* Returns `true` when sha256-keyed HTTP requests should be transparently
|
||||
* rewritten to the local Blossom cache (master toggle on, profile-pictures-only
|
||||
* restriction off, probe up). When `null`, the interceptor is disabled —
|
||||
* useful for tests or pre-configuration call sites.
|
||||
* Returns `true` when a sha256-keyed HTTP request should be transparently
|
||||
* rewritten to the local Blossom cache, given whether it is a profile picture
|
||||
* (master toggle on, probe up, and either not restricted to profile pictures
|
||||
* or this is one). When `null`, the interceptor is disabled — useful for
|
||||
* tests or pre-configuration call sites.
|
||||
*/
|
||||
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
val shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = null,
|
||||
private val onionCache: OnionLocationCache,
|
||||
/**
|
||||
* Resource-usage ledger counter, installed OUTERMOST on the shared base
|
||||
@@ -76,11 +77,24 @@ class OkHttpClientFactory(
|
||||
* anonymous. See [BlossomReadAuthInterceptor].
|
||||
*/
|
||||
private val blossomReadAuth: Interceptor? = null,
|
||||
/**
|
||||
* Called when the local Blossom cache refuses a connection, so the caller can
|
||||
* mark it unavailable instead of waiting for the next periodic probe.
|
||||
*/
|
||||
onLocalBlossomCacheUnreachable: () -> Unit = {},
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
|
||||
// Tor-routed media skips the local cache (the cache would fetch the origin outside Tor, and
|
||||
// Tor refuses 127.0.0.1 anyway), but its media marker header must still be stripped: this
|
||||
// non-bridging instance takes the redirect's place on Tor clients, and stands in for it when
|
||||
// the bridge isn't configured at all.
|
||||
private val blossomCacheMarkerStripper = LocalBlossomCacheRedirectInterceptor(bridges = false) { false }
|
||||
|
||||
private val blossomCacheRedirect =
|
||||
shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(it) }
|
||||
shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(keyCache, onLocalBlossomCacheUnreachable, bridges = true, shouldBridge = it) }
|
||||
?: blossomCacheMarkerStripper
|
||||
|
||||
// Most images/videos in a feed come from a small set of hosts (e.g. a single
|
||||
// Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests
|
||||
@@ -130,15 +144,13 @@ class OkHttpClientFactory(
|
||||
.followSslRedirects(true)
|
||||
.apply { usageInterceptor?.let { addInterceptor(it) } }
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.apply {
|
||||
blossomCacheRedirect?.let { addInterceptor(it) }
|
||||
}
|
||||
// Sits outside the network interceptors so its retry re-runs the
|
||||
// full stack (content-type, blossom cache, key decryptor) for the
|
||||
// authenticated response. Only signs on an actual 401.
|
||||
// Sits before the local-cache redirect and outside the network interceptors, so it
|
||||
// sees the origin host (a token is scoped to it) and its retry re-runs the rest of
|
||||
// the stack (blossom cache, key decryptor) for the authenticated response. Only
|
||||
// signs on an actual 401. A request carrying a token is never bridged.
|
||||
.apply {
|
||||
blossomReadAuth?.let { addInterceptor(it) }
|
||||
}
|
||||
}.addInterceptor(blossomCacheRedirect)
|
||||
// .addNetworkInterceptor(logging)
|
||||
.addNetworkInterceptor(keyDecryptor)
|
||||
// Passively populates [onionCache] from any HTTP/WebSocket response
|
||||
@@ -172,7 +184,15 @@ class OkHttpClientFactory(
|
||||
// `.onion`s — clearnet clients must never try to resolve `.onion`
|
||||
// (DNS would fail, and we don't want fingerprintable lookups).
|
||||
.apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) }
|
||||
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
|
||||
// The local Blossom cache lives on 127.0.0.1, which Tor refuses to reach, and it
|
||||
// would fetch the origin outside Tor. Tor-routed media keeps going to its origin
|
||||
// through Tor; the local cache is used by the direct client only.
|
||||
.apply {
|
||||
if (proxy != null) {
|
||||
val index = interceptors().indexOf(blossomCacheRedirect)
|
||||
if (index >= 0) interceptors()[index] = blossomCacheMarkerStripper
|
||||
}
|
||||
}.connectTimeout(Duration.ofSeconds(seconds.toLong()))
|
||||
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
.build()
|
||||
|
||||
+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() })
|
||||
}
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.service.http
|
||||
|
||||
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.lang.reflect.Proxy
|
||||
import java.net.ConnectException
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LocalBlossomCacheRedirectSafetyTest {
|
||||
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
private val origin = "https://blossom.example.com/$sha.jpg"
|
||||
private val bridged = "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com"
|
||||
|
||||
private fun media(
|
||||
url: String = origin,
|
||||
kind: String = LocalBlossomCacheRedirectInterceptor.MEDIA,
|
||||
) = Request.Builder().url(url).header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, kind)
|
||||
|
||||
private object IdentityCipher : NostrCipher {
|
||||
override fun name() = "identity"
|
||||
|
||||
override fun encrypt(bytesToEncrypt: ByteArray) = bytesToEncrypt
|
||||
|
||||
override fun decrypt(bytesToDecrypt: ByteArray) = bytesToDecrypt
|
||||
|
||||
override fun decryptOrNull(bytesToDecrypt: ByteArray) = bytesToDecrypt
|
||||
}
|
||||
|
||||
/** Records every request passed to proceed; [refuse] makes matching ones fail like a closed port. */
|
||||
private fun chain(
|
||||
request: Request,
|
||||
sent: MutableList<Request>,
|
||||
statusFor: (Request) -> Int = { 200 },
|
||||
refuse: (Request) -> Boolean = { false },
|
||||
): Interceptor.Chain =
|
||||
Proxy.newProxyInstance(
|
||||
Interceptor.Chain::class.java.classLoader,
|
||||
arrayOf(Interceptor.Chain::class.java),
|
||||
) { _, method, args ->
|
||||
when (method.name) {
|
||||
"request" -> request
|
||||
"proceed" -> {
|
||||
val proceeded = args[0] as Request
|
||||
sent.add(proceeded)
|
||||
if (refuse(proceeded)) throw ConnectException("Connection refused")
|
||||
val status = statusFor(proceeded)
|
||||
Response
|
||||
.Builder()
|
||||
.request(proceeded)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(status)
|
||||
.message(if (status == 200) "OK" else "Not Found")
|
||||
.body("".toResponseBody(null))
|
||||
.build()
|
||||
}
|
||||
else -> throw UnsupportedOperationException(method.name)
|
||||
}
|
||||
} as Interceptor.Chain
|
||||
|
||||
@Test
|
||||
fun getIsBridged() {
|
||||
val sent = mutableListOf<Request>()
|
||||
LocalBlossomCacheRedirectInterceptor { true }.intercept(chain(media().build(), sent)).close()
|
||||
assertEquals(bridged, sent.single().url.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profilePicturesOnlyBridgesProfilePicturesAlone() {
|
||||
// The "profile pictures only" setting: bridge a request only when it is a profile picture.
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { profilePicture -> profilePicture }
|
||||
|
||||
val feedImage = mutableListOf<Request>()
|
||||
interceptor.intercept(chain(media().build(), feedImage)).close()
|
||||
assertEquals(origin, feedImage.single().url.toString())
|
||||
|
||||
val avatar = mutableListOf<Request>()
|
||||
interceptor.intercept(chain(media(kind = LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE).build(), avatar)).close()
|
||||
assertEquals(bridged, avatar.single().url.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unmarkedRequestsAreNeverBridged() {
|
||||
// BUD-02 `GET /list/<pubkey>` ends in 64 hex chars too, but it is not a blob download.
|
||||
val list = "https://blossom.example.com/list/$sha"
|
||||
val sent = mutableListOf<Request>()
|
||||
LocalBlossomCacheRedirectInterceptor { true }
|
||||
.intercept(
|
||||
chain(
|
||||
Request
|
||||
.Builder()
|
||||
.url(list)
|
||||
.header("Authorization", "Nostr abc")
|
||||
.build(),
|
||||
sent,
|
||||
),
|
||||
).close()
|
||||
assertEquals(list, sent.single().url.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authGatedDownloadsGoToTheirServer() {
|
||||
val sent = mutableListOf<Request>()
|
||||
LocalBlossomCacheRedirectInterceptor { true }
|
||||
.intercept(chain(media().header("Authorization", "Nostr abc").build(), sent))
|
||||
.close()
|
||||
assertEquals(origin, sent.single().url.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theMarkerNeverLeavesTheApp() {
|
||||
listOf(
|
||||
LocalBlossomCacheRedirectInterceptor { true },
|
||||
LocalBlossomCacheRedirectInterceptor { false },
|
||||
LocalBlossomCacheRedirectInterceptor(bridges = false) { true },
|
||||
).forEach { interceptor ->
|
||||
val sent = mutableListOf<Request>()
|
||||
interceptor.intercept(chain(media().build(), sent)).close()
|
||||
assertNull(sent.single().header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNonBridgingInstanceLeavesMediaAlone() {
|
||||
val sent = mutableListOf<Request>()
|
||||
LocalBlossomCacheRedirectInterceptor(bridges = false) { true }.intercept(chain(media().build(), sent)).close()
|
||||
assertEquals(origin, sent.single().url.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaCallFactoryMarksCallsButKeepsAnExistingMarker() {
|
||||
val seen = mutableListOf<Request>()
|
||||
val factory =
|
||||
LocalBlossomMediaCallFactory({ request ->
|
||||
seen.add(request)
|
||||
OkHttpClient().newCall(request)
|
||||
})
|
||||
|
||||
factory.newCall(Request.Builder().url(origin).build())
|
||||
factory.newCall(media(kind = LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE).build())
|
||||
|
||||
assertEquals(
|
||||
listOf(LocalBlossomCacheRedirectInterceptor.MEDIA, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE),
|
||||
seen.map { it.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverSpecificMethodsAreNotBridged() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val requests =
|
||||
listOf(
|
||||
Request
|
||||
.Builder()
|
||||
.url(origin)
|
||||
.head()
|
||||
.build(),
|
||||
Request
|
||||
.Builder()
|
||||
.url(origin)
|
||||
.delete()
|
||||
.header("Authorization", "Nostr abc")
|
||||
.build(),
|
||||
Request
|
||||
.Builder()
|
||||
.url(origin)
|
||||
.put("x".toRequestBody())
|
||||
.build(),
|
||||
)
|
||||
requests.forEach { request ->
|
||||
val sent = mutableListOf<Request>()
|
||||
interceptor.intercept(chain(request, sent)).close()
|
||||
assertEquals(origin, sent.single().url.toString(), "${request.method} must reach its server")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptionKeyFollowsTheRewrite() {
|
||||
val keys = EncryptionKeyCache()
|
||||
val info = DecryptInformation(IdentityCipher, "image/jpeg")
|
||||
keys.add(origin, info)
|
||||
|
||||
val sent = mutableListOf<Request>()
|
||||
LocalBlossomCacheRedirectInterceptor(keyCache = keys) { true }.intercept(chain(media().build(), sent)).close()
|
||||
|
||||
// EncryptedBlobInterceptor runs after the rewrite and looks the key up by the URL it sees.
|
||||
assertSame(info, assertNotNull(keys.get(sent.single().url.toString())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deadCacheFallsBackToOriginAndIsReported() {
|
||||
var reports = 0
|
||||
val sent = mutableListOf<Request>()
|
||||
val response =
|
||||
LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true }
|
||||
.intercept(chain(media().build(), sent) { it.url.host == "127.0.0.1" })
|
||||
|
||||
assertEquals(200, response.code)
|
||||
assertEquals(listOf(bridged, origin), sent.map { it.url.toString() })
|
||||
assertEquals(1, reports)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deadCacheIsReportedForResolvedBlossomUrls() {
|
||||
var reports = 0
|
||||
val sent = mutableListOf<Request>()
|
||||
val request = media("http://127.0.0.1:24242/$sha.mp4?xs=https://blossom.example.com").build()
|
||||
|
||||
assertFailsWith<ConnectException> {
|
||||
LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true }
|
||||
.intercept(chain(request, sent) { true })
|
||||
}
|
||||
assertEquals(1, reports)
|
||||
assertTrue(sent.single().url.host == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCacheMissFallsBackToTheOrigin() {
|
||||
var reports = 0
|
||||
val sent = mutableListOf<Request>()
|
||||
|
||||
val response =
|
||||
LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true }
|
||||
.intercept(
|
||||
chain(media().build(), sent, statusFor = { if (it.url.host == "127.0.0.1") 404 else 200 }),
|
||||
)
|
||||
|
||||
// The blob still arrives: the cache is an optimisation, never a gate.
|
||||
assertEquals(200, response.code)
|
||||
assertEquals(origin, response.request.url.toString())
|
||||
assertEquals(listOf(bridged, origin), sent.map { it.url.toString() })
|
||||
// The cache answered, so it is alive — the bridge must not be switched off.
|
||||
assertEquals(0, reports)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCacheErrorFallsBackToTheOriginToo() {
|
||||
val sent = mutableListOf<Request>()
|
||||
|
||||
val response =
|
||||
LocalBlossomCacheRedirectInterceptor { true }
|
||||
.intercept(
|
||||
chain(media().build(), sent, statusFor = { if (it.url.host == "127.0.0.1") 500 else 200 }),
|
||||
)
|
||||
|
||||
assertEquals(200, response.code)
|
||||
assertEquals(listOf(bridged, origin), sent.map { it.url.toString() })
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aServedBlobIsNotRefetchedFromTheOrigin() {
|
||||
val sent = mutableListOf<Request>()
|
||||
|
||||
val response = LocalBlossomCacheRedirectInterceptor { true }.intercept(chain(media().build(), sent))
|
||||
|
||||
assertEquals(200, response.code)
|
||||
// A hit must cost exactly one request, to the cache.
|
||||
assertEquals(listOf(bridged), sent.map { it.url.toString() })
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.service.http
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.Request
|
||||
import java.net.ServerSocket
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* With "videos via Tor" on, the player pool is built with a Tor-proxied [DynamicCallFactory], but a
|
||||
* `blossom:` video resolves to the local Blossom cache on 127.0.0.1 only when the data source opens.
|
||||
* Tor refuses loopback addresses, so the local cache must be reached directly or no video plays.
|
||||
*/
|
||||
class LocalBlossomCacheTorRoutingTest {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = scope.cancel()
|
||||
|
||||
private fun manager(proxyPort: Int) =
|
||||
DualHttpClientManager(
|
||||
userAgent = "test",
|
||||
proxyPortProvider = MutableStateFlow(proxyPort),
|
||||
isMobileDataProvider = MutableStateFlow(false),
|
||||
keyCache = EncryptionKeyCache(),
|
||||
scope = scope,
|
||||
dns = SurgeDns(),
|
||||
shouldBridgeBlossomCache = { true },
|
||||
onionCache = OnionLocationCache(),
|
||||
)
|
||||
|
||||
/** A port nothing listens on: stands in for a SOCKS proxy that cannot reach the target. */
|
||||
private fun deadPort(): Int = ServerSocket(0).use { it.localPort }
|
||||
|
||||
/** Minimal one-shot HTTP server standing in for the local Blossom cache. */
|
||||
private fun localCache(body: String): ServerSocket {
|
||||
val server = ServerSocket(0)
|
||||
thread(isDaemon = true) {
|
||||
server.accept().use { socket ->
|
||||
// Drain the request headers before answering.
|
||||
val reader = socket.getInputStream().bufferedReader()
|
||||
do {
|
||||
val line = reader.readLine()
|
||||
} while (!line.isNullOrEmpty())
|
||||
socket.getOutputStream().write(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body".toByteArray(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
@Test
|
||||
fun proxiedFactoryReachesLoopbackCacheDirectly() {
|
||||
val cache = localCache("video-bytes")
|
||||
val factory = manager(deadPort()).getDynamicCallFactory(useProxy = true)
|
||||
|
||||
val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
val request = Request.Builder().url("http://127.0.0.1:${cache.localPort}/$sha.mp4?xs=https://blossom.example.com").build()
|
||||
|
||||
factory.newCall(request).execute().use { response ->
|
||||
assertEquals(200, response.code)
|
||||
assertEquals("video-bytes", response.body.string())
|
||||
}
|
||||
cache.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun proxyDecisionUsesTheFinalRequestUrl() {
|
||||
assertFalse(DynamicCallFactory.shouldUseProxy(true, "http://127.0.0.1:24242/abc.mp4?xs=https://blossom.primal.net"))
|
||||
assertFalse(DynamicCallFactory.shouldUseProxy(true, "http://localhost:24242/abc.mp4"))
|
||||
assertTrue(DynamicCallFactory.shouldUseProxy(true, "https://blossom.primal.net/abc.mp4"))
|
||||
assertFalse(DynamicCallFactory.shouldUseProxy(false, "https://blossom.primal.net/abc.mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTheDirectClientRewritesToTheLocalCache() {
|
||||
val manager = manager(deadPort())
|
||||
val direct = manager.getHttpClient(useProxy = false)
|
||||
val proxied = manager.getHttpClient(useProxy = true)
|
||||
|
||||
// Both carry one (it strips the media marker), but only the direct client's bridges.
|
||||
assertEquals(listOf(true), direct.interceptors.filterIsInstance<LocalBlossomCacheRedirectInterceptor>().map { it.bridges })
|
||||
assertEquals(listOf(false), proxied.interceptors.filterIsInstance<LocalBlossomCacheRedirectInterceptor>().map { it.bridges })
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,147 @@
|
||||
<string name="road_event_traffic_jam">यातायात अचलता</string>
|
||||
<string name="road_event_unknown">मार्ग घटना</string>
|
||||
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
|
||||
<string name="geocache_unnamed">भूकोष</string>
|
||||
<string name="geocache_type_traditional">पारम्परिक</string>
|
||||
<string name="geocache_type_multi">बहुकोष</string>
|
||||
<string name="geocache_type_mystery">रहस्य</string>
|
||||
<string name="geocache_size_micro">सूक्ष्म</string>
|
||||
<string name="geocache_size_small">लघु</string>
|
||||
<string name="geocache_size_regular">सामान्य</string>
|
||||
<string name="geocache_size_large">बृहत</string>
|
||||
<string name="geocache_size_other">अन्य आकार</string>
|
||||
<string name="geocache_difficulty_short">कठिनाई%1$d</string>
|
||||
<string name="geocache_terrain_short">धरा%1$d</string>
|
||||
<string name="geocache_first_to_find">खोज में पहला</string>
|
||||
<string name="geocache_is_art">कलाकृति</string>
|
||||
<string name="geocache_mission">मुख्य खोज</string>
|
||||
<string name="geocache_needs_verification">सत्यापित खोज</string>
|
||||
<string name="geocache_claimed">कथित</string>
|
||||
<string name="geocache_archived">संगृहीत</string>
|
||||
<string name="geocache_hint_tap_to_reveal">क्या आप अटक गए। दबाएँ सहायक सूचना देखने के लिए</string>
|
||||
<string name="geocache_hint_other_reading">क्या पठनशक्य नहीं। दबाएँ अतिरिक्त पठन के लिए</string>
|
||||
<string name="geocache_nearby">निकट</string>
|
||||
<string name="geocache_found_it">प्राप्त।</string>
|
||||
<string name="geocache_verified_find">सत्यापित खोज</string>
|
||||
<string name="geocache_invalid_proof">प्रमाण ठीक नहीं</string>
|
||||
<string name="geocache_loading">कोष आवहन…</string>
|
||||
<string name="geocache_hidden_by">छिपाया गया %1$s द्वारा</string>
|
||||
<string name="geocache_navigate">मार्गदर्शन</string>
|
||||
<string name="geocache_copy_coordinates">निर्देशांकों की अनुकृति</string>
|
||||
<string name="geocache_coordinates_copied">निर्देशांक अनुकृत</string>
|
||||
<string name="geocache_photos_section">चित्र</string>
|
||||
<string name="geocache_mission_section">मुख्य खोज अभियान</string>
|
||||
<string name="geocache_logs_section">अभिलेख</string>
|
||||
<string name="geocache_no_logs">इस कोष का अभिलेखन अब तक किसी ने नहीं किया। सर्वप्रथम बनें।</string>
|
||||
<string name="geocache_i_found_it">मैं ने खोज लिया</string>
|
||||
<string name="geocache_didnt_find_it">खोज असफल</string>
|
||||
<string name="geocache_add_note">टीका जोडें</string>
|
||||
<string name="geocache_needs_maintenance">देखरेख आवश्यक वृत्तान्त</string>
|
||||
<string name="geocache_add_to_hunt">खोज में जोडें</string>
|
||||
<string name="geocache_hunt_new">नई खोज…</string>
|
||||
<string name="geocache_hunt_none_yet">आपने कोई खोज नहीं की अब तक।</string>
|
||||
<string name="geocache_hunt_already_on">पहले से इस खोज में लगे</string>
|
||||
<string name="geocache_caches_here">%1$d कोष यहाँ</string>
|
||||
<string name="geocache_photo_remove">इस चित्र को हटाएँ</string>
|
||||
<string name="geocache_archived_notice">इस कोष को संगृहीत कर दिया गया। यह अब खेल में नहीं है।</string>
|
||||
<string name="geocache_ftf_won_by">सर्वप्रथम खोज का कथन %1$s द्वारा</string>
|
||||
<string name="geocache_ftf_available">सर्वप्रथम खोज अभी भी सम्भव</string>
|
||||
<string name="geocache_owner_actions">स्वत्वधारी</string>
|
||||
<string name="geocache_owner_edit">कोष सम्पादन</string>
|
||||
<string name="geocache_owner_archive">कोष को संगृहीत करें</string>
|
||||
<string name="geocache_owner_archive_confirm">संगृहीत करें। खोजकर्ता देखेंगे कि यह खेल के बाहर हो गया है। तथा सूचीकृत विवरण पठनशक्य रहेगा।</string>
|
||||
<string name="geocache_owner_lock_ftf">सर्वप्रथम खोजक तालाबद्ध करें</string>
|
||||
<string name="geocache_owner_lock_ftf_explain">एक एफ सूचक का प्रकाशन करता है %1$s का नाम लेकर। यह स्थायी है।</string>
|
||||
<string name="geocache_owner_no_finders">कोई सत्यापित खोज नहीं अब तक। इसलिए तालाबद्ध करने के लिए कोई नहीं।</string>
|
||||
<string name="geocache_owner_show_qr">सत्यापन अक्षरराशि दिखाएँ</string>
|
||||
<string name="geocache_owner_qr_warning">इसे छाप कर कोष में रखें। अमेथिस्ट इसको नहीं रखेगा। यदि आप इसे खो देते हैं तो खोजक प्रमाणित नहीं कर पाएँगे कि वे वहाँ थे।</string>
|
||||
<string name="geocache_log_type_dnf">खोज असफल</string>
|
||||
<string name="geocache_log_type_note">टीका</string>
|
||||
<string name="geocache_log_type_maintenance">देखरेख आवश्यक</string>
|
||||
<string name="geocache_log_title">खोज का अभिलेखन</string>
|
||||
<string name="geocache_log_cache_label">कोष</string>
|
||||
<string name="geocache_log_your_log">आपका अभिलेख</string>
|
||||
<string name="geocache_log_placeholder">कैसे चला।</string>
|
||||
<string name="geocache_log_add_photo">चित्र जोडें</string>
|
||||
<string name="geocache_log_mission_answer">आपका मुख्य खोज उत्तर</string>
|
||||
<string name="geocache_log_proof_section">उपस्थिति प्रमाण</string>
|
||||
<string name="geocache_log_scan_code">कोष में अक्षरराशि परखें</string>
|
||||
<string name="geocache_log_scan_again">एक अन्य अक्षरराशि परखें</string>
|
||||
<string name="geocache_log_proof_verified">सत्यापित। आप कोष के स्थान पर उपस्थित थे</string>
|
||||
<string name="geocache_log_proof_explain">हस्ताक्षरित एक बार पेटीका में प्राप्त अक्षरराशि से। कुंचिका का अभिलेखन कभी भी नहीं किया जाता तथा इस पटल के बाहर नहीं जाता।</string>
|
||||
<string name="geocache_log_proof_bad_code">वह एक सत्यापन अक्षरराशि नहीं।</string>
|
||||
<string name="geocache_log_proof_wrong_cache">वह अक्षरराशि एक पृथक कोष का है।</string>
|
||||
<string name="geocache_log_proof_optional">यह कोष प्रमाण नहीं पूछता। इसलिए आप सीधे इसका अभिलेखन कर सकते हैं।</string>
|
||||
<string name="geocache_log_no_location_attached">आपका स्थान इस अभिलेख में संलग्न नहीं।</string>
|
||||
<string name="geocache_log_post">पत्र प्रकाशन</string>
|
||||
<string name="geocache_log_comment_title">अभिलेख जोडें</string>
|
||||
<string name="geocache_tab_nearby">निकट</string>
|
||||
<string name="geocache_tab_map">मानचित्र</string>
|
||||
<string name="geocache_tab_hunts">खोज</string>
|
||||
<string name="geocache_tab_finds">सफल खोज</string>
|
||||
<string name="geocache_tab_mine">मेरा</string>
|
||||
<string name="geocache_filter_unfound">अप्राप्त</string>
|
||||
<string name="geocache_filter_difficulty">कठिनाई %1$d समन्यूनवा</string>
|
||||
<string name="geocache_filter_terrain">धरा %1$d समन्यूनवा</string>
|
||||
<string name="geocache_filter_has_prize">पुरस्कारयुक्त</string>
|
||||
<string name="geocache_filter_verified_only">प्रमाण आवश्यक</string>
|
||||
<string name="geocache_use_my_location">मेरे स्थान का उपयोग करें</string>
|
||||
<string name="geocache_sorted_by_distance">दूरी अनुसार क्रमबद्ध</string>
|
||||
<string name="geocache_sorted_by_recent">नवीनतम पहले</string>
|
||||
<string name="geocache_hide_a_cache">कोष को छिपाएँ</string>
|
||||
<string name="geocache_empty_nearby">कोई कोष नहीं यहाँ अब तक। अधिक विस्तृत अनुगमन सूची का प्रयास करें। अथवा प्रथम को छिपाएँ।</string>
|
||||
<string name="geocache_empty_finds">आपने किसी खोज का अभिलेखन नहीं किया अब तक।</string>
|
||||
<string name="geocache_empty_mine">आपने किसी कोष को छिपाया नहीं अब तक।</string>
|
||||
<string name="geocache_empty_hunts">कोई खोज नहीं अब तक।</string>
|
||||
<string name="geocache_stat_finds">सफल खोज</string>
|
||||
<string name="geocache_stat_ftf">सर्वप्रथम खोज</string>
|
||||
<string name="geocache_stat_verified">सत्यापित</string>
|
||||
<string name="geocache_stat_hidden">छिपा हुआ</string>
|
||||
<string name="geocache_map_recenter">मुझ पर मध्यीकरण</string>
|
||||
<string name="geocache_map_long_press">मानचित्र पर दीर्घ दबाएँ वहाँ एक कोष छिपाने के लिए</string>
|
||||
<string name="geocache_map_open">खोलें</string>
|
||||
<string name="geocache_map_pin_count">%1$d कोष</string>
|
||||
<string name="geocache_new_section_where">कहाँ</string>
|
||||
<string name="geocache_new_section_what">क्या</string>
|
||||
<string name="geocache_new_section_extras">अतिरिक्त</string>
|
||||
<string name="geocache_new_section_proof">प्रमाण</string>
|
||||
<string name="geocache_new_section_relays">पुनःप्रसारक</string>
|
||||
<string name="geocache_new_location_warning">यह एक सार्वजनिक स्थान प्रकाशित करता है जो वास्तविक से पाँच मीटर व्यासार्ध तक की विस्थापन सम्भावना रखता है। नोस्टर पर कोई भी इसे देख सकता है। प्रकाशित घटना को पुनःअप्रकाशित नहीं किया जा सकता।</string>
|
||||
<string name="geocache_new_pick_location">स्थल का चयन करें</string>
|
||||
<string name="geocache_new_ladder">प्रकाशित करता है %1$s को। तथा स्थूलतर विभाग तीन अक्षरों तक निकटवर्ती खोज के लिए।</string>
|
||||
<string name="geocache_new_name">नाम</string>
|
||||
<string name="geocache_new_description">विवरण</string>
|
||||
<string name="geocache_new_type">प्रकार</string>
|
||||
<string name="geocache_new_size">आकार</string>
|
||||
<string name="geocache_new_difficulty">कठिनाई</string>
|
||||
<string name="geocache_new_terrain">धरा</string>
|
||||
<string name="geocache_new_hint">सहायक संकेत</string>
|
||||
<string name="geocache_new_hint_preview">खोजक देखेंगे</string>
|
||||
<string name="geocache_new_hint_scramble">सहायक संकेत को अस्तव्यस्त करें (रोट॰तेरह)</string>
|
||||
<string name="geocache_new_mission">मुख्य खोज अभियान</string>
|
||||
<string name="geocache_new_modifiers">संशोधक</string>
|
||||
<string name="geocache_new_require_proof">उपस्थिति प्रमाण आवश्यक</string>
|
||||
<string name="geocache_new_proof_ready">एक सत्यापन कुंचिका उत्पादित इस कोष के लिए।</string>
|
||||
<string name="geocache_new_log_relays">खोजक कहाँ अभिलेखन करना चाहिए</string>
|
||||
<string name="geocache_new_publish">प्रकाशन</string>
|
||||
<string name="geocache_new_needs_name">कोष नामकरण।</string>
|
||||
<string name="geocache_new_needs_location">कोष कहाँ छिपाया जाए चयन करें।</string>
|
||||
<string name="geocache_hunt_progress">%2$d में से %1$d प्राप्त</string>
|
||||
<string name="geocache_hunt_start">खोज आरम्भ करें</string>
|
||||
<string name="geocache_hunt_caches">%1$d कोष</string>
|
||||
<string name="geocache_hunt_title">शीर्षक</string>
|
||||
<string name="geocache_hunt_description">विवरण</string>
|
||||
<string name="geocache_hunt_banner">ध्वज चित्र</string>
|
||||
<string name="geocache_hunt_pick_caches">कोष क्रमानुसार</string>
|
||||
<string name="geocache_hunt_add_cache">कोष जोडें</string>
|
||||
<string name="geocache_hunt_move_up">ऊर्ध्वगमन</string>
|
||||
<string name="geocache_hunt_move_down">अधोगमन</string>
|
||||
<string name="geocache_hunt_remove">हटाएँ</string>
|
||||
<string name="geocache_hunt_theme">पृष्ठ शैली</string>
|
||||
<string name="geocache_hunt_map_style">मानचित्र शैली</string>
|
||||
<string name="geocache_hunt_needs_title">खोज का एक शीर्षक रखें।</string>
|
||||
<string name="geocache_hunt_needs_caches">न्यूनतम एक कोष जोडें।</string>
|
||||
<string name="geocache_publish_failed">प्रकाशन असफल। पुनःप्रसारक संयोजन का जाँच करें तथा पुनःप्रयास करें।</string>
|
||||
<string name="podcast_value_zap_split_hint">इसको ज्साप इनमें विभाजित :</string>
|
||||
<string name="podcast_value_split_percent">%1$d प्रतिशतप्रतिशत</string>
|
||||
<string name="podcast_value_for_value">मूल्य के लिए मूल्य</string>
|
||||
@@ -214,6 +355,18 @@
|
||||
<string name="qr_scanner_kind_web">यह एक जाल योजक है।</string>
|
||||
<string name="qr_scanner_kind_text">यह साधारण लेख है। नोस्टर सम्बन्धित अक्षरराशि नहीं।</string>
|
||||
<string name="qr_scanner_kind_nostr_unsupported">यह नोस्टर सम्बन्धित अक्षरराशि है। पर इस पटल द्वारा नहीं खोला जा सकता।</string>
|
||||
<string name="qr_scanner_label_profile">परिचय</string>
|
||||
<string name="qr_scanner_label_note">टीका</string>
|
||||
<string name="qr_scanner_label_nostr">नोस्टर अक्षरराशि</string>
|
||||
<string name="qr_scanner_label_relay">पुनःप्रसारक</string>
|
||||
<string name="qr_scanner_label_nsec">निजी कुंचिका</string>
|
||||
<string name="qr_scanner_label_wallet">धनकोष संयोजन</string>
|
||||
<string name="qr_scanner_label_bunker">दूरस्थ हस्ताक्षरकर्ता पता</string>
|
||||
<string name="qr_scanner_label_signer">हस्ताक्षरकर्ता संयोजन अनुरोध</string>
|
||||
<string name="qr_scanner_label_lightning">लैटनिंग चालान</string>
|
||||
<string name="qr_scanner_label_cashu">काशयू अक्षरराशि</string>
|
||||
<string name="qr_scanner_label_web">जाल योजक</string>
|
||||
<string name="qr_scanner_label_text">साधारण लेख</string>
|
||||
<string name="qr_scanner_open_link">योजक खोलें</string>
|
||||
<string name="qr_scanner_copy">अनुकृति</string>
|
||||
<string name="qr_scanner_copied">अनुकृत</string>
|
||||
@@ -675,14 +828,27 @@
|
||||
<string name="workout_hours">घण्टे</string>
|
||||
<string name="workout_minutes">मिनुट</string>
|
||||
<string name="workout_seconds">सेकण्ड</string>
|
||||
<string name="workout_suggestion_connect_title">पहले ही अभिलेखित व्यायामों का उपयोग करें</string>
|
||||
<string name="workout_suggestion_connect_message">स्वास्थ्य संयोजन (सामसंग स्वास्थ्य गूगिल फिट फिटबिट गार्मिन इत्यादि) से जोडें मेरा स्वास्थ्य पटल का निर्माण करने के लिए तथा आपका अपना व्यायाम साराम्श तथा इस व्यायाम को आपकी घडी के अभिलेखों से भरने के लिए प्रत्येक संख्या को हाथ से लिखने के स्थान पर।</string>
|
||||
<string name="workout_suggestion_connect_button">संयोजन</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s सहस्रमीटर</string>
|
||||
<string name="workout_from_health_connect">स्वास्थ्य संयोजन से</string>
|
||||
<string name="workout_suggestion_connect_details">अमेथिस्ट क्या पढता है</string>
|
||||
<string name="health_connect_rationale_title">स्वास्थ्य संयोजन तथा अमेथिस्ट</string>
|
||||
<string name="health_connect_rationale_headline">अमेथिस्ट स्वास्थ्य संयोजन का उपयोग करता है मेरा स्वास्थ्य पटल का निर्माण करने के लिए। आपका अपना अभ्यास साराम्श आपके अपने संचारयन्त्र पर।</string>
|
||||
<string name="health_connect_rationale_intro">मेरा स्वास्थ्य (विकल्पसूची से आप से मेरा स्वास्थ्य) आपको दिखाता है आप कैसे अभ्यास कर रहे हैं। इस सप्ताह कितना किया पूर्व सप्ताह की तुलना में। आपका समय कैसे बाँटा गया धवन द्विचक्रचालन चलन तरण तथा व्यायामशाला में। आपके सर्वोत्तम प्रयास। कितने दिन आपने अभ्यास किया। तथा आपकी वर्तमान निरन्तर अभ्यास दिनों की स्थिति। अमेथिस्ट इस चित्र का निर्माण करता है उन व्यायामों से जिन्हें आपकी घडी ने अथवा स्वास्थ्य क्रमक ने पहले से स्वास्थ्य संयोजन में अभिलेखित किए हैं। तथा अन्य व्यायाम जो आपने सीधे अमेथिस्ट में अभिलेखित किए। इसका परिकलन आपके संचारयन्त्र पर किया जाता है तथा आपको दिखाया जाता है।</string>
|
||||
<string name="health_connect_rationale_what_title">अमेथिस्ट क्या पढता है और क्यों</string>
|
||||
<string name="health_connect_rationale_exercise">व्यायाम। आप ने कौनसी क्रिया की। वह कब आरम्भ हुआ तथा कितने समय तक चला। यह व्यायाम स्वयम है। यह चलाता है आपकी व्यायाम गिनती आपका अभ्यास समय प्रत्येक क्रिया का विश्लेशन आपके सक्रिय दिन तथा निरन्तर अभ्यास दिन।</string>
|
||||
<string name="health_connect_rationale_distance">दूरी। आप कितने दूर गए। प्रदर्शित इस सप्ताह की दूरी पूर्व सप्ताह की तुलना में। आपका साप्ताहिक मध्यमान। दूरी प्रति क्रिया अनुसार। तथा सर्वोत्तम दूरी।</string>
|
||||
<string name="health_connect_rationale_calories">सक्रिय तथा कुल उष्ममानइकाई। ऊर्जा जिसका व्यय व्यायाम से किया गया। प्रदर्शित आपकी ऊर्जा इस सप्ताह के रूप में तथा पूर्व सप्ताह की तुलना में। सक्रिय उष्ममानइकाई उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममानइकाई विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं।</string>
|
||||
<string name="health_connect_rationale_heart_rate">हृदयस्पन्दन गति। मध्यमान तथा उच्चतम हृदयस्पन्दन गति व्यायाम के समय। तथा उस कालवधि का मध्यमान तथा उच्चतम हृदयस्पन्दन गति में संयुक्त। इसी से आप बता सकते हैं एक सरल सप्ताह और कठिन सप्ताह में पृथकता समान दूरी पर।</string>
|
||||
<string name="health_connect_rationale_steps">पद। पदों की गणना एक धवन चलन अथवा पदयात्रा की। प्रदर्शित आपका साप्ताहिक पद मध्यमान तथा आपकी उच्चतम पद गिनती के रूप में।</string>
|
||||
<string name="health_connect_rationale_elevation">ऊँचाई वृद्धि। आप कितना ऊपर चढे। प्रदर्शित आपका साप्ताहिक आरोहण मध्यमान तथा अपका बृहत्तम आरोहण के रूप में। यही है पृथकता एक समतल सप्ताह का एक पर्वतीय से।</string>
|
||||
<string name="health_connect_rationale_limits_title">अमेथिस्ट क्या नहीं करता</string>
|
||||
<string name="health_connect_rationale_limit_window">केवल पिछले चार सप्ताहों के व्यायामों को पढता है। तथा तब ही जब मेरा स्वास्थ्य अथवा व्यायाम सम्पादक खुला है। पृष्ठभूत होकर कभी पढता नही।</string>
|
||||
<string name="health_connect_rationale_limit_write">स्वास्थ्य संयोजन पर कुछ भी लिखता नहीं। तथा आपको कभी भी नहीं पूछता आपका व्यायाम मार्ग अथवा स्थल अथवा अन्य किसी भी स्वास्थ्य जानकारी प्रकार के विषय में।</string>
|
||||
<string name="health_connect_rationale_limit_publish">आपकी स्वास्थ्य जानकारी कहीं अन्यत्र नहीं भेजता। मेरा स्वास्थ्य का परिकलन आपके संचारयन्त्र पर किया जाता है तथा वहीं रहता है। यदि आगे आप एक व्यायाम को प्रकाशित करना चाहते हैं तो उस व्यायाम में बाँटें पर दबाएँ। अमेथिस्ट ने जो भरा उसे पढें। तथा स्वयम प्रकाशित करें। एक व्यायाम प्रत्येक बार। स्वचालित कभी नहीं।</string>
|
||||
<string name="health_connect_rationale_limit_optional">यह सम्पूर्ण सुविधा विकल्पात्मक है। अनुमतियों का निराकरण करें स्वास्थ्य संयोजन में कभी भी। मेरा स्वास्थ्य अपनी आज्ञा अनुरोध अवस्था तक लौट जाएगा। अथवा स्थापना विकल्पों में सम्पादन पटल में सम्पादक सुझाव निष्क्रिय करें। अमेथिस्ट के शेष भाग कार्य करते रहेंगे।</string>
|
||||
<string name="health_connect_rationale_privacy_policy">सम्पूर्ण गोपनीयता नीति पढें</string>
|
||||
<string name="my_fitness_title">मेरा स्वास्थ्य</string>
|
||||
<string name="my_fitness_window">पिछले चार सप्ताह</string>
|
||||
@@ -721,7 +887,7 @@
|
||||
<string name="my_fitness_connect_button">संयोजन</string>
|
||||
<string name="my_fitness_share">व्यायाम बाँटें</string>
|
||||
<string name="my_fitness_unit_bpm">स्पन्दनप्रतिमिनुट</string>
|
||||
<string name="my_fitness_unit_kcal">सहस्रउष्ममानइकाई</string>
|
||||
<string name="my_fitness_unit_kcal">सहस्रउष्म॰इ</string>
|
||||
<string name="my_fitness_unit_km">सहस्रमीटर</string>
|
||||
<string name="my_fitness_unit_mi">मील</string>
|
||||
<string name="my_fitness_unit_m">मीटर</string>
|
||||
@@ -1161,6 +1327,8 @@
|
||||
<string name="poll_closing_date_time">मतदान समापन दिनांक तथा समय</string>
|
||||
<string name="poll_closing_in">मतदान %1$s में समाप्त होगा</string>
|
||||
<string name="looking_for_event">घटना %1$s के लिए खोज चल रहा है</string>
|
||||
<string name="looking_for_event_title">आवहन चालू</string>
|
||||
<string name="looking_for_private_event">आपके निजी सन्देशों का आवहन की प्रतीक्षा…</string>
|
||||
<string name="send_zap">ज्साप भेजें</string>
|
||||
<string name="custom_zaps_add_a_message">सार्वजनिक संदेश जोडें</string>
|
||||
<string name="custom_zaps_add_a_message_private">निजी संदेश जोडें</string>
|
||||
@@ -1436,6 +1604,14 @@
|
||||
<string name="user_preferences">उपयोगकर्ता आद्यताएँ</string>
|
||||
<string name="settings_search_placeholder">खोज स्थापना विकल्प</string>
|
||||
<string name="settings_search_no_results">"%1$s" के लिए कोई स्थापना विकल्प प्राप्त नहीं</string>
|
||||
<string name="marmot_preview_group_updated">समूह अद्यतन किया गया</string>
|
||||
<string name="marmot_preview_media">संलग्न अभिलेख</string>
|
||||
<string name="marmot_preview_no_text">सन्देश</string>
|
||||
<string name="marmot_preview_with_sender">%1$s : %2$s</string>
|
||||
<string name="marmot_empty_create_action">समूह बनाएँ</string>
|
||||
<string name="marmot_invite_device_banner">मार्मोट आमन्त्रण अन्य यन्त्र तक जा रहे हैं तथा यहाँ नहीं खोले जा सकते।</string>
|
||||
<string name="marmot_invite_device_banner_dismiss">हटाएँ</string>
|
||||
<string name="marmot_invite_device_publish">इस यन्त्र से प्रकाशन</string>
|
||||
<string name="reset_marmot_confirm_title">क्या मार्मोट॰ स्थिति पुनःस्थापित करें।</string>
|
||||
<string name="reset_marmot_confirm_body">यह स्थायी रूप से सभी मार्मोट॰ समूह चर्चा तथा सन्देश इतिहास तथा एमएलएस॰ कुंचिका मिटा देगा इस यन्त्र पर वर्तमान लेखा के लिए। समकक्षजन को सूचित नहीं किया जाएगा तथा सम्भाव्यतः आपको समूहों मे देखते रहेंगे उनके अगले स्थायी अभिलेखन तक। इसे पूर्ववत नहीं किया जा सकता। एक नयी कुंचिकापोटली प्रकाशित की जाएगी अगली बार जब क्रमक समचरणीकरण किया जाएगा।</string>
|
||||
<string name="reset_marmot_confirm_action">पुनःस्थापन</string>
|
||||
@@ -2025,6 +2201,8 @@
|
||||
<string name="video_player_settings_action_pip_description">चलचित्र दिखाएँ तैरते गवाक्ष में (अदृश्य यदि अनालम्बित)</string>
|
||||
<string name="video_player_settings_action_cast">यन्त्र पर निक्षेपण</string>
|
||||
<string name="video_player_settings_action_cast_description">चलचित्र को क्रेमकास्ट॰ ग्राहक तक भेजें आपके वैफै॰ पर (स्थानीय अभिलेखों के लिए अदृष्ट)</string>
|
||||
<string name="video_player_settings_action_captions">अवबोधक</string>
|
||||
<string name="video_player_settings_action_captions_description">उपशीर्षक सक्रिय अथवा निष्क्रिय करें। अदृश्य जब चलचित्र में उपलब्ध नहीं</string>
|
||||
<string name="audio_visualizer_settings_description">ध्वनि के स्वर बजते समय प्रदर्शनीय चलनाकृति का चयन करें।</string>
|
||||
<string name="audio_visualizer_off">निष्क्रिय</string>
|
||||
<string name="audio_visualizer_bars">वर्णक्रम पट्टियाँ</string>
|
||||
@@ -3003,13 +3181,18 @@
|
||||
<string name="app_notification_replies_channel_description">आपको सूचित करता है जब किसी ने आपके पत्र को उत्तर लिखा हो</string>
|
||||
<string name="app_notification_replies_channel_name">प्रतिवचन</string>
|
||||
<string name="app_notification_replies_summary">नए उत्तर</string>
|
||||
<string name="app_notification_reply_failed">नहीं भेजा गया</string>
|
||||
<string name="app_notification_reply_label">उत्तर</string>
|
||||
<string name="app_notification_reply_retry">पुनःप्रयास</string>
|
||||
<string name="app_notification_reply_sending">भेजा जा रहा है…</string>
|
||||
<string name="app_notification_reply_unconfirmed">अभी भी भेजा जा रहा है…</string>
|
||||
<string name="app_notification_reposts_channel_description">आपको सूचित करता है जब कोई आपके पत्र को पुनःप्रकाशित करते हैं</string>
|
||||
<string name="app_notification_reposts_channel_message">%1$s ने आपके पत्र को पुनःप्रकाशित किया</string>
|
||||
<string name="app_notification_reposts_channel_name">पुनःप्रकाशन</string>
|
||||
<string name="app_notification_reposts_summary">नए पुनःप्रकाशन</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">सूचनाएँ जब एक समयनिर्धारित पत्र का प्रकाशन होता है अथवा प्रकाशन असफल।</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">प्रकाशन समय निर्धारित पत्र</string>
|
||||
<string name="app_notification_shortcut_account_removed">इस लेखा को अमेथिस्ट से हटाया गया था।</string>
|
||||
<string name="app_notification_zaps_channel_description">सूचित करता है जब किसी ने आपको ज्साप किया</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s साट्स</string>
|
||||
<string name="app_notification_zaps_channel_message_for">%1$s के लिए</string>
|
||||
@@ -3210,6 +3393,10 @@
|
||||
<string name="call_unmute">मौन हटाएँ</string>
|
||||
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">प्रत्याह्वान जालपता उपलब्ध नहीं %1$s के उत्तर से</string>
|
||||
<string name="cancel">निरस्त करें</string>
|
||||
<string name="captions_off">निष्क्रिय</string>
|
||||
<string name="captions_turn_off">अवबोधक निष्क्रिय करें</string>
|
||||
<string name="captions_turn_on">अवबोधक सक्रिय करें</string>
|
||||
<string name="captions_unnamed_track">अवबोधक</string>
|
||||
<string name="cashu">काशयू अक्षरराशि</string>
|
||||
<string name="cashu_checking_mint">टकसाल का जाँच…</string>
|
||||
<string name="cashu_failed_redemption">काशयू नहीं चुका पाए</string>
|
||||
@@ -3695,10 +3882,12 @@
|
||||
<string name="kind_adventure_prologue">साहसिक प्रस्तावना</string>
|
||||
<string name="kind_adventure_reading">साहसिक पठन</string>
|
||||
<string name="kind_adventure_scene">साहसिक दृश्य</string>
|
||||
<string name="kind_app_curation_set">क्रमक सूची</string>
|
||||
<string name="kind_app_recommendations">क्रमक अनुशंसाएँ</string>
|
||||
<string name="kind_appointment">समयनियुक्ति</string>
|
||||
<string name="kind_apps">क्रमक</string>
|
||||
<string name="kind_appt_rsvp">समयनियुक्ति प्रत्युत्तर अपेक्षित</string>
|
||||
<string name="kind_article_curation_set">लेख सूची</string>
|
||||
<string name="kind_audio_header">ध्वनि शीर्षक</string>
|
||||
<string name="kind_audio_track">ध्वनि अभिलेख</string>
|
||||
<string name="kind_authored_podcasts">कृत पुटप्रसार</string>
|
||||
@@ -3711,6 +3900,7 @@
|
||||
<string name="kind_blossom_auth">ब्लोस्सम॰ प्रमाणीकरण</string>
|
||||
<string name="kind_blossom_servers">ब्लोस्सम॰ प्रसारसंगणक</string>
|
||||
<string name="kind_bookmark_list">स्मर्त्तव्य सूची</string>
|
||||
<string name="kind_bookmark_set">स्मर्त्तव्य सूची</string>
|
||||
<string name="kind_broadcast_relays">प्रसारण पुनःप्रसारक</string>
|
||||
<string name="kind_calendar">दिनदर्शिका</string>
|
||||
<string name="kind_channel_definition">प्रणाली परिभाषा</string>
|
||||
@@ -3748,6 +3938,7 @@
|
||||
<string name="kind_emoji_packs">भावचिह्न पोटलियाँ</string>
|
||||
<string name="kind_ephemeral_chat">अनभिलिखित चर्चा</string>
|
||||
<string name="kind_ephemeral_chatrooms">अस्थायी चर्चाशालाएँ</string>
|
||||
<string name="kind_favorite_algo_feeds">इष्ट सूचनावलियाँ</string>
|
||||
<string name="kind_favorite_podcasts">प्रिय पुटप्रसार</string>
|
||||
<string name="kind_favorite_relays">इष्ट पुनःप्रसारक</string>
|
||||
<string name="kind_file_headers">अभिलेख शीर्षक</string>
|
||||
@@ -3756,28 +3947,36 @@
|
||||
<string name="kind_follow_packs">अनुचरण पोटलियाँ</string>
|
||||
<string name="kind_geohash_follows">भूभागसूचक अनुचरित</string>
|
||||
<string name="kind_gift_wraps">उपहारकोष</string>
|
||||
<string name="kind_git_authors">गिट लेखक</string>
|
||||
<string name="kind_git_issue">गिट समस्या</string>
|
||||
<string name="kind_git_patch">गिट परिहार</string>
|
||||
<string name="kind_git_pr">कर्षण अनुरोध</string>
|
||||
<string name="kind_git_pr_update">कर्षण अनुरोध अद्यतन</string>
|
||||
<string name="kind_git_reply">गिट उत्तर</string>
|
||||
<string name="kind_git_repo">गिट क्रमलेखकोष</string>
|
||||
<string name="kind_git_repositories">गिट क्रमलेखकोश</string>
|
||||
<string name="kind_git_status_applied">गिट स्थिति। प्रयुक्त</string>
|
||||
<string name="kind_git_status_closed">गिट स्थिति। आवृत</string>
|
||||
<string name="kind_git_status_draft">गिट स्थिति। कच्चा</string>
|
||||
<string name="kind_git_status_open">गिट स्थिति। खुला</string>
|
||||
<string name="kind_good_wiki_authors">विश्वसनीय समूहसम्पाद्य लेखक</string>
|
||||
<string name="kind_good_wiki_relays">विश्वसनीय समूहसम्पाद्य पुनःप्रसारक</string>
|
||||
<string name="kind_hashtag_follows">विषयसूचक अनुचरित</string>
|
||||
<string name="kind_highlights">प्रमुखताएँ</string>
|
||||
<string name="kind_http_auth">एचटीटीपी॰ प्रमाणीकरण</string>
|
||||
<string name="kind_index_relay_list">निर्देशिका पुनःप्रसारक सूची</string>
|
||||
<string name="kind_interest_set">रुचियाँ</string>
|
||||
<string name="kind_live_chats">वर्तमान बातचीत</string>
|
||||
<string name="kind_live_streams">वर्तमानप्रवाह</string>
|
||||
<string name="kind_media_follows">श्रव्यदृश्याभिलेख अनुचरित</string>
|
||||
<string name="kind_media_starter_pack">श्रव्यदृश्याभिलेख आरम्भ पोटली</string>
|
||||
<string name="kind_medical_data">चिकित्सा अभिलेख</string>
|
||||
<string name="kind_meeting_room">मेलन शाला</string>
|
||||
<string name="kind_meeting_space">मेलन स्थल</string>
|
||||
<string name="kind_music_playlist">संगीतसूची</string>
|
||||
<string name="kind_music_track">संगीत अभिलेख</string>
|
||||
<string name="kind_mute_list">मौन सूची</string>
|
||||
<string name="kind_mute_set_for_kind">प्रकार %1$s मौनकृत</string>
|
||||
<string name="kind_named_bookmarks">नाम युक्त स्मर्त्तव्य सूची</string>
|
||||
<string name="kind_nests_servers">निलय सेवासंगणक</string>
|
||||
<string name="kind_nip">निप॰</string>
|
||||
@@ -3792,6 +3991,7 @@
|
||||
<string name="kind_outbox_relays">निर्गतपेटिका पुनःप्रसारक</string>
|
||||
<string name="kind_pay_to">के लिए भुगतान</string>
|
||||
<string name="kind_people_lists">व्यक्ति सूचियाँ</string>
|
||||
<string name="kind_picture_curation_set">चित्र सूची</string>
|
||||
<string name="kind_pictures">चित्र</string>
|
||||
<string name="kind_pins">टाँके</string>
|
||||
<string name="kind_podcast_episode">पुटप्रसार कडी</string>
|
||||
@@ -3818,6 +4018,7 @@
|
||||
<string name="kind_search_relays">खोज पुनःप्रसारक</string>
|
||||
<string name="kind_shorts">लघु चलचित्र</string>
|
||||
<string name="kind_shorts_repl">लघु चलचित्र (अनु)</string>
|
||||
<string name="kind_simple_groups">समूह</string>
|
||||
<string name="kind_torrent_comments">अनेकत्रावरोहण टिप्पणियाँ</string>
|
||||
<string name="kind_torrents">अनेकत्रावरोहण</string>
|
||||
<string name="kind_trusted_providers">विश्वसनीय प्रदाता</string>
|
||||
@@ -3826,12 +4027,15 @@
|
||||
<string name="kind_user_settings">प्रयोक्ता स्थापना विकल्प</string>
|
||||
<string name="kind_user_status">उपयोगकर्ता स्थिति</string>
|
||||
<string name="kind_video">चलचित्र</string>
|
||||
<string name="kind_video_collaboration">चलचित्र सहयोग</string>
|
||||
<string name="kind_video_list">चलचित्र सूची</string>
|
||||
<string name="kind_video_repl">चलचित्र (अनु)</string>
|
||||
<string name="kind_video_subtitles">चलचित्र उपशीर्षक</string>
|
||||
<string name="kind_voice_msg">ध्वनि सन्देश</string>
|
||||
<string name="kind_voice_reply">ध्वनि उत्तर</string>
|
||||
<string name="kind_wake">जगाना</string>
|
||||
<string name="kind_web_bookmark">जाल स्मर्त्तव्यचिह्न</string>
|
||||
<string name="kind_wiki">विकि॰</string>
|
||||
<string name="kind_wiki">समूहसम्पाद्य</string>
|
||||
<string name="kind_workouts">व्यायाम</string>
|
||||
<string name="kind_zap_goals">ज्साप उद्देश्य</string>
|
||||
<string name="kind_zap_poll">ज्साप मतदान</string>
|
||||
@@ -3863,6 +4067,8 @@
|
||||
<string name="location_change_place">मानचित्र पर स्थल परिवर्तन करें</string>
|
||||
<string name="location_channels">स्थानीय प्रणालियाँ</string>
|
||||
<string name="location_pick_on_map">मानचित्र पर एक स्थल चुनें</string>
|
||||
<string name="login_bunker_not_supported">अमेथिस्ट का आण्ड्रोइड संस्करण एक bunker:// पता से हस्ताक्षर प्रवेश नहीं कर सकता। इनका वितरण करता है जिससे अन्य क्रमक अमेथिस्ट को हस्ताक्षर करने का अनुरोध कर सकते हैं। स्वयम हस्ताक्षर प्रवेश नहीं कर सकता। एक बाहरी हस्ताक्षरकर्ता क्रमक का उपयोग करें अथवा अमेथिस्ट का लेखनपीठ संगणक संस्करण।</string>
|
||||
<string name="login_nostrconnect_not_supported">वह एक क्रमक है जो आपके हस्ताक्षरकर्ता से संयोजन का अनुरोध कर रहा है। हस्ताक्षर प्रवेश करने की विधि नहीं है। पहले हस्ताक्षर प्रवेश करें। तत्पश्चात उसे निप॰छियालीस हस्ताक्षरण पटल से खोलें।</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को उद्धृत करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। अनुचरण करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_hide_word">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। शब्द अथवा वाक्य छिपाने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string>
|
||||
@@ -4217,6 +4423,7 @@
|
||||
</plurals>
|
||||
<string name="nip46_signer_remote_app">दूरस्थ हस्ताक्षरकर्ता क्रमक</string>
|
||||
<string name="nip46_signer_title">दूरस्थ हस्ताक्षरकर्ता</string>
|
||||
<string name="nip51_list_has_private_members">इस सूची में निजी सदस्य हैं जो केवल इसका कर्ता देख सकेगा</string>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d सम्पत्ति समावेशित</item>
|
||||
<item quantity="other">%1$d सम्पत्ति समावेशित</item>
|
||||
@@ -4753,12 +4960,21 @@
|
||||
<string name="route_calendars">दिनदर्शिकाएँ</string>
|
||||
<string name="route_chess">चतुरंग</string>
|
||||
<string name="route_discover">आविष्करण</string>
|
||||
<string name="route_edit_geocache">कोष सम्पादन</string>
|
||||
<string name="route_edit_geocache_hunt">खोज सम्पादन</string>
|
||||
<string name="route_geocache_detail">कोष</string>
|
||||
<string name="route_geocache_hunt_detail">खोज</string>
|
||||
<string name="route_geocache_hunts">कोष खोज</string>
|
||||
<string name="route_geocaches">भूकोष</string>
|
||||
<string name="route_home">घर</string>
|
||||
<string name="route_import_follows">अनुचरित आयात करें</string>
|
||||
<string name="route_log_geocache_find">एक खोज का अभिलेखन</string>
|
||||
<string name="route_media">श्रव्यदृश्याभिलेख</string>
|
||||
<string name="route_messages">संदेश</string>
|
||||
<string name="route_music_playlists">संगीतसूचियाँ</string>
|
||||
<string name="route_music_tracks">संगीत</string>
|
||||
<string name="route_new_geocache">कोष को छिपाएँ</string>
|
||||
<string name="route_new_geocache_hunt">नई खोज</string>
|
||||
<string name="route_notifications">सूचनाएँ</string>
|
||||
<string name="route_podcast_episodes">कडियाँ</string>
|
||||
<string name="route_podcasts">पुटप्रसार</string>
|
||||
@@ -4948,6 +5164,18 @@
|
||||
<string name="vanish_request_sent">अदृश्यीकरण अनुरोध भेजा गया</string>
|
||||
<string name="video_codec_h265_description">अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं।</string>
|
||||
<string name="video_codec_h265_label">एच॰२६५ अथवा एचईवीसी॰ संकुचनविस्तारणविधि उपयोग करें</string>
|
||||
<string name="video_collaboration_accepted_on">सहयोग श्रेय स्वीकृत</string>
|
||||
<string name="video_collaboration_declined_on">सहयोग श्रेय अस्वीकृत</string>
|
||||
<string name="video_credit_accepted">श्रेय स्वीकृत</string>
|
||||
<string name="video_credit_audio_from">ध्वनि इन से</string>
|
||||
<string name="video_credit_featuring">सहभागी</string>
|
||||
<string name="video_credit_inspired_by">प्रेरित इनसे</string>
|
||||
<string name="video_credit_references">उद्धरणसूची</string>
|
||||
<string name="video_curation_set_private_items">निजी सूची</string>
|
||||
<plurals name="video_curation_set_video_count">
|
||||
<item quantity="one">%1$d चलचित्र</item>
|
||||
<item quantity="other">%1$d चलचित्र</item>
|
||||
</plurals>
|
||||
<string name="video_download_has_started_toast">चलचित्र अवरोहण आरम्भ हुआ …</string>
|
||||
<string name="video_player_settings">चलचित्र चालक घुण्डियाँ</string>
|
||||
<string name="video_saved_to_the_gallery">चलचित्र को संचारयन्त्र के चलचित्रालय में सुरक्षित रखा गया</string>
|
||||
@@ -5007,4 +5235,13 @@
|
||||
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">ग्राहक की लैटनिंग सेवा %1$s पर उपलब्ध नहीं। इसका परिकलन किया गया "%2$s" लैटनिंग पता से। अपक्रम : %3$s। जाँच करें यदि सेवा संगणक चल रहा है तथा यदि लैटनिंग पता सम्यक है</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s से लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं।</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">लैटनिंग चालान नहीं बना पाए। %1$s से संदेश : %2$s</string>
|
||||
<string name="marmot_invite_device_success">यह यन्त्र अब आपके मार्मोट आमन्त्रणों को प्राप्त करता है।</string>
|
||||
<string name="marmot_invite_device_failure">कुंचिकापोटली प्रकाशन असफल। %1$s</string>
|
||||
<string name="marmot_invite_device_rejected">किसी भी पुनःप्रसारक ने कुंचिकापोटली स्वीकारा नहीं। अपने पुनःप्रसारक स्थापना विकल्पों का जाँच करें तथा पुनःप्रयास करें।</string>
|
||||
<string name="chat_delivery_sending">भेजा जा रहा है\u2026</string>
|
||||
<string name="chat_delivery_failed">भेजने में असफल। पुनःप्रयास करने के लिए दबाएँ।</string>
|
||||
<plurals name="marmot_unread_messages">
|
||||
<item quantity="one">%1$d नया सन्देश</item>
|
||||
<item quantity="other">%1$d नए सन्देश</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -166,6 +166,147 @@
|
||||
<string name="road_event_traffic_jam">Forgalmi dugó</string>
|
||||
<string name="road_event_unknown">Útesemény</string>
|
||||
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
|
||||
<string name="geocache_unnamed">Geoláda</string>
|
||||
<string name="geocache_type_traditional">Hagyományos</string>
|
||||
<string name="geocache_type_multi">Többpontos</string>
|
||||
<string name="geocache_type_mystery">Rejtélyes</string>
|
||||
<string name="geocache_size_micro">Mikro</string>
|
||||
<string name="geocache_size_small">Kicsi</string>
|
||||
<string name="geocache_size_regular">Normál</string>
|
||||
<string name="geocache_size_large">Nagy</string>
|
||||
<string name="geocache_size_other">Egyéb méret</string>
|
||||
<string name="geocache_difficulty_short">Nehézség: %1$d</string>
|
||||
<string name="geocache_terrain_short">Terep: %1$d</string>
|
||||
<string name="geocache_first_to_find">Első megtaláló</string>
|
||||
<string name="geocache_is_art">Műalkotás</string>
|
||||
<string name="geocache_mission">Kulcsküldetés</string>
|
||||
<string name="geocache_needs_verification">Ellenőrzött megtalálások</string>
|
||||
<string name="geocache_claimed">Igényelve</string>
|
||||
<string name="geocache_archived">Archiválva</string>
|
||||
<string name="geocache_hint_tap_to_reveal">Eltévedt? Koppintson a tippek megjelenítéséhez</string>
|
||||
<string name="geocache_hint_other_reading">Nem olvasható? Koppintson egy másik olvasatért</string>
|
||||
<string name="geocache_nearby">Közeli</string>
|
||||
<string name="geocache_found_it">Megtaláltam!</string>
|
||||
<string name="geocache_verified_find">Ellenőrzött megtalálás</string>
|
||||
<string name="geocache_invalid_proof">A bizonyíték nem érvényes</string>
|
||||
<string name="geocache_loading">Láda betöltése…</string>
|
||||
<string name="geocache_hidden_by">Elrejtette: %1$s</string>
|
||||
<string name="geocache_navigate">Navigálás</string>
|
||||
<string name="geocache_copy_coordinates">Koordináták másolása</string>
|
||||
<string name="geocache_coordinates_copied">Koordináták másolva</string>
|
||||
<string name="geocache_photos_section">Fényképek</string>
|
||||
<string name="geocache_mission_section">Kulcsküldetés küldetés</string>
|
||||
<string name="geocache_logs_section">Naplók</string>
|
||||
<string name="geocache_no_logs">Még senki sem naplózta ezt a ládát. Legyen Ön az első.</string>
|
||||
<string name="geocache_i_found_it">Megtaláltam</string>
|
||||
<string name="geocache_didnt_find_it">Nem találtam meg</string>
|
||||
<string name="geocache_add_note">Megjegyzés hozzáadása</string>
|
||||
<string name="geocache_needs_maintenance">Karbantartás szükségességének jelentése</string>
|
||||
<string name="geocache_add_to_hunt">Hozzáadás egy vadászathoz</string>
|
||||
<string name="geocache_hunt_new">Új vadászat…</string>
|
||||
<string name="geocache_hunt_none_yet">Még nem hozott létre vadászatot.</string>
|
||||
<string name="geocache_hunt_already_on">Már szerepel ebben a vadászatban</string>
|
||||
<string name="geocache_caches_here">%1$d láda van itt</string>
|
||||
<string name="geocache_photo_remove">Fénykép eltávolítása</string>
|
||||
<string name="geocache_archived_notice">Ezt a ládát archiválták. Már nem játszik.</string>
|
||||
<string name="geocache_ftf_won_by">Az első megtalálást %1$s szerezte meg</string>
|
||||
<string name="geocache_ftf_available">Az első megtalálás még szabad</string>
|
||||
<string name="geocache_owner_actions">Tulajdonos</string>
|
||||
<string name="geocache_owner_edit">Láda szerkesztése</string>
|
||||
<string name="geocache_owner_archive">Láda archiválása</string>
|
||||
<string name="geocache_owner_archive_confirm">Archiválás. A keresők látni fogják, hogy már nem játszik, de a lista olvasható marad.</string>
|
||||
<string name="geocache_owner_lock_ftf">Első megtaláló rögzítése</string>
|
||||
<string name="geocache_owner_lock_ftf_explain">Közzétesz egy F címkét %1$s névvel. Ez végleges.</string>
|
||||
<string name="geocache_owner_no_finders">Még nincs ellenőrzött megtalálás, így nincs kit rögzíteni.</string>
|
||||
<string name="geocache_owner_show_qr">Ellenőrző kód megjelenítése</string>
|
||||
<string name="geocache_owner_qr_warning">Nyomtassa ki és tegye a ládába. Az Amethyst nem tárolja — ha elveszíti, a keresők többé nem tudják bizonyítani, hogy ott jártak.</string>
|
||||
<string name="geocache_log_type_dnf">Nem találta meg</string>
|
||||
<string name="geocache_log_type_note">Megjegyzés</string>
|
||||
<string name="geocache_log_type_maintenance">Karbantartást igényel</string>
|
||||
<string name="geocache_log_title">Megtalálás naplózása</string>
|
||||
<string name="geocache_log_cache_label">Láda</string>
|
||||
<string name="geocache_log_your_log">Saját napló</string>
|
||||
<string name="geocache_log_placeholder">Hogy ment?</string>
|
||||
<string name="geocache_log_add_photo">Fénykép hozzáadása</string>
|
||||
<string name="geocache_log_mission_answer">Saját kulcsküldetés-válasz</string>
|
||||
<string name="geocache_log_proof_section">Jelenlét bizonyítása</string>
|
||||
<string name="geocache_log_scan_code">Olvassa be a ládában talált kódot</string>
|
||||
<string name="geocache_log_scan_again">Másik kód beolvasása</string>
|
||||
<string name="geocache_log_proof_verified">Ellenőrizve — Ön járt a ládánál</string>
|
||||
<string name="geocache_log_proof_explain">Egyszer aláírva a dobozban lévő kóddal. A kulcs soha nem kerül mentésre, és soha nem hagyja el ezt a képernyőt.</string>
|
||||
<string name="geocache_log_proof_bad_code">Ez nem egy ellenőrző kód.</string>
|
||||
<string name="geocache_log_proof_wrong_cache">Ez a kód egy másik ládához tartozik.</string>
|
||||
<string name="geocache_log_proof_optional">Ez a láda nem kér bizonyítékot, így nyugodtan naplózhatja.</string>
|
||||
<string name="geocache_log_no_location_attached">Az Ön saját helyzete nincs hozzácsatolva ehhez a naplóhoz.</string>
|
||||
<string name="geocache_log_post">Közzététel</string>
|
||||
<string name="geocache_log_comment_title">Napló hozzáadása</string>
|
||||
<string name="geocache_tab_nearby">Közeli</string>
|
||||
<string name="geocache_tab_map">Térkép</string>
|
||||
<string name="geocache_tab_hunts">Vadászatok</string>
|
||||
<string name="geocache_tab_finds">Megtalálások</string>
|
||||
<string name="geocache_tab_mine">Sajátjaim</string>
|
||||
<string name="geocache_filter_unfound">Megtalálatlan</string>
|
||||
<string name="geocache_filter_difficulty">Nehézség ≤ %1$d</string>
|
||||
<string name="geocache_filter_terrain">Terep ≤ %1$d</string>
|
||||
<string name="geocache_filter_has_prize">Van nyereménye</string>
|
||||
<string name="geocache_filter_verified_only">Bizonyíték szükséges</string>
|
||||
<string name="geocache_use_my_location">Saját helyzet használata</string>
|
||||
<string name="geocache_sorted_by_distance">Rendezés távolság szerint</string>
|
||||
<string name="geocache_sorted_by_recent">Legújabb elöl</string>
|
||||
<string name="geocache_hide_a_cache">Láda elrejtése</string>
|
||||
<string name="geocache_empty_nearby">Itt még nincsenek ládák. Próbáljon szélesebb követési listát, vagy rejtse el az elsőt.</string>
|
||||
<string name="geocache_empty_finds">Még nem naplózott megtalálást.</string>
|
||||
<string name="geocache_empty_mine">Még nem rejtett el ládát.</string>
|
||||
<string name="geocache_empty_hunts">Még nincsenek vadászatok.</string>
|
||||
<string name="geocache_stat_finds">Megtalálások</string>
|
||||
<string name="geocache_stat_ftf">Első megtalálások</string>
|
||||
<string name="geocache_stat_verified">Ellenőrzött</string>
|
||||
<string name="geocache_stat_hidden">Elrejtett</string>
|
||||
<string name="geocache_map_recenter">Középre</string>
|
||||
<string name="geocache_map_long_press">Egy láda elrejtéséhez nyomja meg hossza a térképet</string>
|
||||
<string name="geocache_map_open">Megnyitás</string>
|
||||
<string name="geocache_map_pin_count">%1$d láda</string>
|
||||
<string name="geocache_new_section_where">Hol</string>
|
||||
<string name="geocache_new_section_what">Mi</string>
|
||||
<string name="geocache_new_section_extras">Extrák</string>
|
||||
<string name="geocache_new_section_proof">Bizonyíték</string>
|
||||
<string name="geocache_new_section_relays">Átjátszók</string>
|
||||
<string name="geocache_new_location_warning">Ez egy nyilvános helyzetet tesz közzé, körülbelül 5 méteres pontossággal. Bárki láthatja a Nostron, ez egy közzétett esemény nem vonható vissza..</string>
|
||||
<string name="geocache_new_pick_location">Válassza ki a helyet</string>
|
||||
<string name="geocache_new_ladder">Közzéteszi a(z) %1$s értéket, valamint durvább cellákat egészen 3 karakterig a közelségi kereséshez.</string>
|
||||
<string name="geocache_new_name">Név</string>
|
||||
<string name="geocache_new_description">Leírás</string>
|
||||
<string name="geocache_new_type">Típus</string>
|
||||
<string name="geocache_new_size">Méret</string>
|
||||
<string name="geocache_new_difficulty">Nehézség</string>
|
||||
<string name="geocache_new_terrain">Terep</string>
|
||||
<string name="geocache_new_hint">Tipp</string>
|
||||
<string name="geocache_new_hint_preview">Ezt látják majd a keresők</string>
|
||||
<string name="geocache_new_hint_scramble">Tipp összekeverése (ROT13)</string>
|
||||
<string name="geocache_new_mission">Kulcsküldetés küldetés</string>
|
||||
<string name="geocache_new_modifiers">Módosítók</string>
|
||||
<string name="geocache_new_require_proof">Jelenlét bizonyítékának megkövetelése</string>
|
||||
<string name="geocache_new_proof_ready">Ehhez a ládához elő lett állítva egy ellenőrzőkulcs.</string>
|
||||
<string name="geocache_new_log_relays">Hol naplózzanak a keresők</string>
|
||||
<string name="geocache_new_publish">Közzététel</string>
|
||||
<string name="geocache_new_needs_name">Adjon nevet a ládának.</string>
|
||||
<string name="geocache_new_needs_location">Válassza ki, hogy hol van elrejtve a láda.</string>
|
||||
<string name="geocache_hunt_progress">%2$d / %1$d megtalálva</string>
|
||||
<string name="geocache_hunt_start">Vadászat indítása</string>
|
||||
<string name="geocache_hunt_caches">%1$d láda</string>
|
||||
<string name="geocache_hunt_title">Cím</string>
|
||||
<string name="geocache_hunt_description">Leírás</string>
|
||||
<string name="geocache_hunt_banner">Borítókép</string>
|
||||
<string name="geocache_hunt_pick_caches">Ládák sorrendben</string>
|
||||
<string name="geocache_hunt_add_cache">Láda hozzáadása</string>
|
||||
<string name="geocache_hunt_move_up">Mozgatás fel</string>
|
||||
<string name="geocache_hunt_move_down">Mozgatás le</string>
|
||||
<string name="geocache_hunt_remove">Eltávolítás</string>
|
||||
<string name="geocache_hunt_theme">Oldal témája</string>
|
||||
<string name="geocache_hunt_map_style">Térkép stílusa</string>
|
||||
<string name="geocache_hunt_needs_title">Adjon címet a vadászatnak.</string>
|
||||
<string name="geocache_hunt_needs_caches">Adjon hozzá legalább egy ládát.</string>
|
||||
<string name="geocache_publish_failed">Nem sikerült közzétenni. Ellenőrizze a kapcsolatot az átjátszóval, és próbálja újra.</string>
|
||||
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_for_value">Értéket az értékért</string>
|
||||
@@ -214,6 +355,18 @@
|
||||
<string name="qr_scanner_kind_web">Ez egy webhivatkozás.</string>
|
||||
<string name="qr_scanner_kind_text">Ez egy egyszerű szöveg, nem Nostr-kód.</string>
|
||||
<string name="qr_scanner_kind_nostr_unsupported">Ez egy Nostr-kód, de ez a képernyő nem tudja megnyitni.</string>
|
||||
<string name="qr_scanner_label_profile">Profil</string>
|
||||
<string name="qr_scanner_label_note">Megjegyzés</string>
|
||||
<string name="qr_scanner_label_nostr">Nostr-kód</string>
|
||||
<string name="qr_scanner_label_relay">Átjátszó</string>
|
||||
<string name="qr_scanner_label_nsec">Privát kulcs</string>
|
||||
<string name="qr_scanner_label_wallet">Pénztárca-kapcsolat</string>
|
||||
<string name="qr_scanner_label_bunker">Távoli aláíró címe</string>
|
||||
<string name="qr_scanner_label_signer">Aláírási kapcsolatkérés</string>
|
||||
<string name="qr_scanner_label_lightning">Lightning számla</string>
|
||||
<string name="qr_scanner_label_cashu">Cashu token</string>
|
||||
<string name="qr_scanner_label_web">Webes hivatkozás</string>
|
||||
<string name="qr_scanner_label_text">Egyszerű szöveg</string>
|
||||
<string name="qr_scanner_open_link">Hivatkozás megnyitása</string>
|
||||
<string name="qr_scanner_copy">Másolás</string>
|
||||
<string name="qr_scanner_copied">Másolva</string>
|
||||
@@ -676,17 +829,31 @@
|
||||
<string name="workout_hours">Óra</string>
|
||||
<string name="workout_minutes">Perc</string>
|
||||
<string name="workout_seconds">Másodperc</string>
|
||||
<string name="workout_suggestion_connect_title">Használja a már rögzített edzéseit</string>
|
||||
<string name="workout_suggestion_connect_message">Kapcsolja össze a Health Connectet (Samsung Health, Google Fit, Fitbit, Garmin…), hogy felépítse a Saját Fitneszt, a saját edzésösszegzését, és hogy ezt az edzést egy már rögzített órai edzésből töltse ki ahelyett, hogy minden számot kézzel írna be.</string>
|
||||
<string name="workout_suggestion_connect_button">Kapcsolódás</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="workout_from_health_connect">Health Connectből</string>
|
||||
<string name="workout_suggestion_connect_details">Mihez fér hozzá az Amethyst</string>
|
||||
<string name="health_connect_rationale_title">Health Connect és Amethyst</string>
|
||||
<string name="health_connect_rationale_headline">Az Amethyst a Health Connectet használja a Saját Fitness felépítéséhez — az Ön saját edzésösszegzéséhez, az eszközén.</string>
|
||||
<string name="health_connect_rationale_intro">A Saját fitnesz (menü → Ön → Saját fitnesz megmutatja, hogyan edzett: mennyit tett ezen a héten a múlt héthez képest, hogyan oszlik meg az ideje futás, kerékpározás, gyaloglás, úszás és edzőterem között, a legjobb erőfeszítéseit, hány napot edzett, és a jelenlegi sorozatát. Az Amethyst ezt a képet az órája vagy fitneszalkalmazása által a Health Connectbe már mentett edzésekből, valamint az Amethystben naplózott edzésekből építi fel. Az eszközén számítódik ki, és csak Önnek jelenik meg.</string>
|
||||
<string name="health_connect_rationale_what_title">Mihez fér hozzá az Amethyst, és miért</string>
|
||||
<string name="health_connect_rationale_exercise">Edzés · milyen tevékenységet végzett, mikor kezdte és mennyi ideig tartott. Ez maga az edzés: ez hajtja az edzésszámot, az edzésidőt, a tevékenységenkénti bontást, az aktív napokat és a sorozatot.</string>
|
||||
<string name="health_connect_rationale_distance">Távolság · milyen messzire ment, megjelenítve az e heti és a múlt heti távolságát, a heti átlagát, a tevékenységenkénti távolságot és a leghosszabb távolságát.</string>
|
||||
<string name="health_connect_rationale_calories">Aktív és teljes kalória · az edzés által elégetett energia, megjelenítve az e heti energiáját és annak összehasonlítását a múlt héttel. Az aktív kalóriákat akkor használja, ha a forrás rögzíti azokat; a teljes kalória azoknak a forrásoknak a tartaléka, amelyek csak a teljes energiát rögzítik.</string>
|
||||
<string name="health_connect_rationale_heart_rate">Pulzusszám · az átlag és a maximum minden edzésen, az időszakra vonatkozó átlagos és legmagasabb pulzusszámba kombinálva. Ez alapján különbözteti meg a könnyű hetet a kemény héttől azonos távolság mellett.</string>
|
||||
<string name="health_connect_rationale_steps">Lépések · egy futás, séta vagy túra lépésszáma, megjelenítve a heti lépésátlagát és a legmagasabb lépésszámát.</string>
|
||||
<string name="health_connect_rationale_elevation">Emelkedés · mennyit mászott, megjelenítve a heti mászási átlagát és a legnagyobb mászását. Ez különbözteti meg a sík hetet a dombos héttől.</string>
|
||||
<string name="health_connect_rationale_limits_title">Mit nem csinál az Amethyst</string>
|
||||
<string name="health_connect_rationale_limit_window">Csak az elmúlt 4 hét edzéseit olvassa, és csak akkor, amikor a „Saját fitnesz” vagy az edzésszerkesztő nyitva van. Soha nem olvas a háttérben.</string>
|
||||
<string name="health_connect_rationale_limit_write">Soha nem ír semmit a Health Connectbe, és soha nem kéri le az edzési útvonalat, a tartózkodási helyet vagy bármilyen egyéb egészségügyi adatot.</string>
|
||||
<string name="health_connect_rationale_limit_publish">Soha nem küldi el az egészségügyi adatait sehová. A „Saját fitnesz” a telefonján számítódik ki, és ott is marad. Ha később közzé szeretne tenni egy edzést, koppintson a „Megosztás” gombra azon az edzésen, olvassa el az Amethyst által kitöltött bejegyzést, és tegye közzé saját maga (egyszerre csak egy edzést, és soha nem automatikusan).</string>
|
||||
<string name="health_connect_rationale_limit_optional">Az egész funkció nem kötelező. Bármikor visszavonhatja az engedélyeket a Health Connectben — a Saját fitnesz visszaáll a beállítására —, vagy kikapcsolhatja a szerkesztő javaslatait a Beállítások → Szerkesztési beállítások alatt. Az Amethyst többi része tovább működik.</string>
|
||||
<string name="health_connect_rationale_privacy_policy">Olvassa el a teljes adatvédelmi nyilatkozatot</string>
|
||||
<string name="my_fitness_title">Fitnesz</string>
|
||||
<string name="my_fitness_window">Elmúlt 4 hét</string>
|
||||
<string name="my_fitness_loading_metrics">Távolság, kalória és pulzusszám betöltése…</string>
|
||||
<string name="my_fitness_window_note">Az edzés utolsó négy teljes hete: a közzétett edzések, valamint minden egyéb adat, amelyet a Health Connect rögzít ezen az eszközön. Az Amethyst az eszközön állítja össze az összefoglalót, és abból semmit sem tesz közzé.</string>
|
||||
<string name="my_fitness_this_week">Ezen a héten</string>
|
||||
<string name="my_fitness_vs_last_week">elmúlt héten</string>
|
||||
@@ -1161,6 +1328,8 @@
|
||||
<string name="poll_closing_date_time">Szavazás lezárásának dátuma és ideje</string>
|
||||
<string name="poll_closing_in">A szavazás lezárul %1$s múlva</string>
|
||||
<string name="looking_for_event">%1$s esemény keresése</string>
|
||||
<string name="looking_for_event_title">Betöltés</string>
|
||||
<string name="looking_for_private_event">Várakozás a privát üzenetek betöltésére…</string>
|
||||
<string name="send_zap">Zap küldése</string>
|
||||
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
|
||||
<string name="custom_zaps_add_a_message_private">Privát üzenet hozzáadása</string>
|
||||
@@ -1436,6 +1605,14 @@
|
||||
<string name="user_preferences">Felhasználói beállítások</string>
|
||||
<string name="settings_search_placeholder">Keresési beállítások</string>
|
||||
<string name="settings_search_no_results">Nincs találat a beállításokban erre: „%1$s”</string>
|
||||
<string name="marmot_preview_group_updated">Csoport frissítve</string>
|
||||
<string name="marmot_preview_media">Melléklet</string>
|
||||
<string name="marmot_preview_no_text">Üzenet</string>
|
||||
<string name="marmot_preview_with_sender">%1$s: %2$s</string>
|
||||
<string name="marmot_empty_create_action">Csoport létrehozása</string>
|
||||
<string name="marmot_invite_device_banner">A Marmot meghívók egy másik eszközre mennek, és itt nem nyithatók meg.</string>
|
||||
<string name="marmot_invite_device_banner_dismiss">Eltüntetés</string>
|
||||
<string name="marmot_invite_device_publish">Közzététel erről az eszközről</string>
|
||||
<string name="reset_marmot_confirm_title">Visszaállítja a Marmot-állapotot?</string>
|
||||
<string name="reset_marmot_confirm_body">Ez a művelet véglegesen töröl minden Marmot csoportos csevegést, üzenetelőzményt és MLS-kulcsot ezen az eszközön a jelenlegi fiókhoz. A partnerek nem kapnak erről értesítést, és a következő véglegesítésükig továbbra is láthatják Önt a csoportokban. Ez a művelet nem vonható vissza. Egy új kulcscsomag lesz közzétéve az alkalmazás következő szinkronizálásakor.</string>
|
||||
<string name="reset_marmot_confirm_action">Visszaállítás</string>
|
||||
@@ -2025,6 +2202,8 @@
|
||||
<string name="video_player_settings_action_pip_description">Videó lejátszása lebegő ablakban (rejtett, ha a rendszer nem támogatja)</string>
|
||||
<string name="video_player_settings_action_cast">Közvetítés eszközre</string>
|
||||
<string name="video_player_settings_action_cast_description">Videó küldése a Wi-Fi hálózaton található Chromecast eszközre (helyi fájloknál nem érhető el)</string>
|
||||
<string name="video_player_settings_action_captions">Feliratok</string>
|
||||
<string name="video_player_settings_action_captions_description">Feliratok be- vagy kikapcsolása (rejtve, ha a videónak nincs)</string>
|
||||
<string name="audio_visualizer_settings_description">Válassza ki a hangjegyzetek lejátszása közben megjelenő animációt.</string>
|
||||
<string name="audio_visualizer_off">Ki</string>
|
||||
<string name="audio_visualizer_bars">Spektrumsávok</string>
|
||||
@@ -3003,13 +3182,18 @@
|
||||
<string name="app_notification_replies_channel_description">Értesítés, amikor valaki válaszol az egyik bejegyzésre</string>
|
||||
<string name="app_notification_replies_channel_name">Válaszok</string>
|
||||
<string name="app_notification_replies_summary">Új válaszok</string>
|
||||
<string name="app_notification_reply_failed">Nincs elküldve</string>
|
||||
<string name="app_notification_reply_label">Válasz</string>
|
||||
<string name="app_notification_reply_retry">Újra</string>
|
||||
<string name="app_notification_reply_sending">Küldés…</string>
|
||||
<string name="app_notification_reply_unconfirmed">Küldés…</string>
|
||||
<string name="app_notification_reposts_channel_description">Értesítés ha valaki megosztja az Ön bejegyzését</string>
|
||||
<string name="app_notification_reposts_channel_message">%1$s megosztotta az Ön bejegyzését</string>
|
||||
<string name="app_notification_reposts_channel_name">Továbbosztások</string>
|
||||
<string name="app_notification_reposts_summary">Új továbbosztások</string>
|
||||
<string name="app_notification_scheduled_posts_channel_description">Értesítések, amikor egy ütemezett bejegyzés megjelenik vagy nem sikerül közzététenni.</string>
|
||||
<string name="app_notification_scheduled_posts_channel_name">Ütemezett bejegyzések</string>
|
||||
<string name="app_notification_shortcut_account_removed">Ezt a fiókot eltávolították az Amethystből.</string>
|
||||
<string name="app_notification_zaps_channel_description">Értesítés, amikor valaki zapet küld Önnek</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s satoshi</string>
|
||||
<string name="app_notification_zaps_channel_message_for">neki: %1$s</string>
|
||||
@@ -3210,6 +3394,10 @@
|
||||
<string name="call_unmute">Némítás megszüntetése</string>
|
||||
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nem található a visszahívási webcím a(z) %1$s válaszából</string>
|
||||
<string name="cancel">Mégse</string>
|
||||
<string name="captions_off">Ki</string>
|
||||
<string name="captions_turn_off">Feliratok kikapcsolása</string>
|
||||
<string name="captions_turn_on">Feliratok bekapcsolása</string>
|
||||
<string name="captions_unnamed_track">Feliratok</string>
|
||||
<string name="cashu">Cashu token</string>
|
||||
<string name="cashu_checking_mint">Pénzverde ellenőrzése…</string>
|
||||
<string name="cashu_failed_redemption">Nem sikerült a Cashut beváltani</string>
|
||||
@@ -3695,10 +3883,12 @@
|
||||
<string name="kind_adventure_prologue">Kalandprológus</string>
|
||||
<string name="kind_adventure_reading">Kalandos olvasmány</string>
|
||||
<string name="kind_adventure_scene">Kalandjelenet</string>
|
||||
<string name="kind_app_curation_set">Alkalmazáslista</string>
|
||||
<string name="kind_app_recommendations">Ajánlott alkalmazások </string>
|
||||
<string name="kind_appointment">Találkozó</string>
|
||||
<string name="kind_apps">Alkalmazások</string>
|
||||
<string name="kind_appt_rsvp">Időpont-visszaigazolás</string>
|
||||
<string name="kind_article_curation_set">Cikklista</string>
|
||||
<string name="kind_audio_header">Audio-fejléc</string>
|
||||
<string name="kind_audio_track">Hangsáv</string>
|
||||
<string name="kind_authored_podcasts">Saját készítésű hangműsorok</string>
|
||||
@@ -3711,6 +3901,7 @@
|
||||
<string name="kind_blossom_auth">Blossom-hitelesítés</string>
|
||||
<string name="kind_blossom_servers">Blossom kiszolgálók</string>
|
||||
<string name="kind_bookmark_list">Könyvjelzőlista</string>
|
||||
<string name="kind_bookmark_set">Könyvjelzőkészlet</string>
|
||||
<string name="kind_broadcast_relays">Közvetítési átjátszók</string>
|
||||
<string name="kind_calendar">Naptár</string>
|
||||
<string name="kind_channel_definition">Csatorna alapadatai</string>
|
||||
@@ -3748,6 +3939,7 @@
|
||||
<string name="kind_emoji_packs">Emodzsicsomagok</string>
|
||||
<string name="kind_ephemeral_chat">Eltűnő csevegés</string>
|
||||
<string name="kind_ephemeral_chatrooms">Eltűnő csevegőszobák</string>
|
||||
<string name="kind_favorite_algo_feeds">Kedvenc hírfolyamok</string>
|
||||
<string name="kind_favorite_podcasts">Kedvenc hangműsorok</string>
|
||||
<string name="kind_favorite_relays">Kedvenc átjátszók</string>
|
||||
<string name="kind_file_headers">Fájl-fejléc</string>
|
||||
@@ -3756,28 +3948,36 @@
|
||||
<string name="kind_follow_packs">Követési csomagok</string>
|
||||
<string name="kind_geohash_follows">Területi követések</string>
|
||||
<string name="kind_gift_wraps">Ajándékcsomagolások</string>
|
||||
<string name="kind_git_authors">Git-szerzők</string>
|
||||
<string name="kind_git_issue">Git hibajegy</string>
|
||||
<string name="kind_git_patch">Git javítófolt</string>
|
||||
<string name="kind_git_pr">Beolvasztási kérés</string>
|
||||
<string name="kind_git_pr_update">Beolvasztási kérés frissítése</string>
|
||||
<string name="kind_git_reply">Git válasz</string>
|
||||
<string name="kind_git_repo">Git tároló</string>
|
||||
<string name="kind_git_repositories">Git-tárolók</string>
|
||||
<string name="kind_git_status_applied">Git-állapot: Elfogadva</string>
|
||||
<string name="kind_git_status_closed">Git-állapot: Lezárva</string>
|
||||
<string name="kind_git_status_draft">Git-állapot: Piszkozat</string>
|
||||
<string name="kind_git_status_open">Git-állapot: Nyitott</string>
|
||||
<string name="kind_good_wiki_authors">Megbízható wiki-szerzők</string>
|
||||
<string name="kind_good_wiki_relays">Megbízható wiki-átjátszók</string>
|
||||
<string name="kind_hashtag_follows">Kulcsszókövetések</string>
|
||||
<string name="kind_highlights">Kiemelések</string>
|
||||
<string name="kind_http_auth">Http-hitelesítés</string>
|
||||
<string name="kind_index_relay_list">Indexelő átjátszók listája</string>
|
||||
<string name="kind_interest_set">Érdeklődési körök</string>
|
||||
<string name="kind_live_chats">Élő csevegések</string>
|
||||
<string name="kind_live_streams">Élő közvetítések</string>
|
||||
<string name="kind_media_follows">Médiakövetések</string>
|
||||
<string name="kind_media_starter_pack">Média-kezdőcsomag</string>
|
||||
<string name="kind_medical_data">Egészségügyi adatok</string>
|
||||
<string name="kind_meeting_room">Tárgyalószoba</string>
|
||||
<string name="kind_meeting_space">Megbeszélési helyszín</string>
|
||||
<string name="kind_music_playlist">Zenei lejátszási lista</string>
|
||||
<string name="kind_music_track">Zeneszám</string>
|
||||
<string name="kind_mute_list">Lista némítása</string>
|
||||
<string name="kind_mute_set_for_kind">Némítva a(z) %1$s típusra</string>
|
||||
<string name="kind_named_bookmarks">Címkézett könyvjelzők</string>
|
||||
<string name="kind_nests_servers">Hangszoba-kiszolgálók</string>
|
||||
<string name="kind_nip">NIP</string>
|
||||
@@ -3792,6 +3992,7 @@
|
||||
<string name="kind_outbox_relays">Kimenő átjátszók</string>
|
||||
<string name="kind_pay_to">fizetés kedvezményezettje</string>
|
||||
<string name="kind_people_lists">Személylisták</string>
|
||||
<string name="kind_picture_curation_set">Képlista</string>
|
||||
<string name="kind_pictures">Képek</string>
|
||||
<string name="kind_pins">Rögzítettek</string>
|
||||
<string name="kind_podcast_episode">Hangműsor-rész</string>
|
||||
@@ -3818,6 +4019,7 @@
|
||||
<string name="kind_search_relays">Keresési átjátszók</string>
|
||||
<string name="kind_shorts">Rövidek</string>
|
||||
<string name="kind_shorts_repl">Rövidek (válasz)</string>
|
||||
<string name="kind_simple_groups">Csoportok</string>
|
||||
<string name="kind_torrent_comments">Torrent-megjegyzések</string>
|
||||
<string name="kind_torrents">torentek</string>
|
||||
<string name="kind_trusted_providers">Megbízható szolgáltatók</string>
|
||||
@@ -3826,7 +4028,10 @@
|
||||
<string name="kind_user_settings">Felhasználói beállítások</string>
|
||||
<string name="kind_user_status">Felhasználói állapot</string>
|
||||
<string name="kind_video">Videó</string>
|
||||
<string name="kind_video_collaboration">Videó-együttműködés</string>
|
||||
<string name="kind_video_list">Videólista</string>
|
||||
<string name="kind_video_repl">Videó (válasz)</string>
|
||||
<string name="kind_video_subtitles">Videófeliratok</string>
|
||||
<string name="kind_voice_msg">Hangüzenet</string>
|
||||
<string name="kind_voice_reply">Hangos válasz</string>
|
||||
<string name="kind_wake">Ébredés</string>
|
||||
@@ -3863,6 +4068,8 @@
|
||||
<string name="location_change_place">Helyszín módosítása a térképen</string>
|
||||
<string name="location_channels">Helyszíncsatornák</string>
|
||||
<string name="location_pick_on_map">Válasszon egy helyet a térképen</string>
|
||||
<string name="login_bunker_not_supported">Az Amethyst Androidra nem tud bunker:// címmel bejelentkezni. Kiad ilyeneket, hogy más alkalmazások kérhessék tőle az aláírást, de ő maga nem tud ilyennel bejelentkezni. Használjon külső aláíró alkalmazást vagy az Amethyst Desktopot.</string>
|
||||
<string name="login_nostrconnect_not_supported">Ez egy alkalmazás, amely az aláírójához szeretne csatlakozni, nem bejelentkezési mód. Először jelentkezzen be, majd nyissa meg a NIP-46 aláíró képernyőről.</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_hide_word">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat el tudjon rejteni</string>
|
||||
@@ -4217,6 +4424,7 @@
|
||||
</plurals>
|
||||
<string name="nip46_signer_remote_app">Távoli aláíró alkalmazás</string>
|
||||
<string name="nip46_signer_title">Távoli aláíró</string>
|
||||
<string name="nip51_list_has_private_members">Ennek a listának vannak privát tagjai, amelyeket csak a szerzője láthat</string>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d összetevő egybecsomagolva</item>
|
||||
<item quantity="other">%1$d asset egybecsomagolva</item>
|
||||
@@ -4753,12 +4961,21 @@
|
||||
<string name="route_calendars">Naptárak</string>
|
||||
<string name="route_chess">Sakk</string>
|
||||
<string name="route_discover">Felfedezés</string>
|
||||
<string name="route_edit_geocache">Láda szerkesztése</string>
|
||||
<string name="route_edit_geocache_hunt">Vadászat szerkesztése</string>
|
||||
<string name="route_geocache_detail">Láda</string>
|
||||
<string name="route_geocache_hunt_detail">Vadászat</string>
|
||||
<string name="route_geocache_hunts">Ládavadászatok</string>
|
||||
<string name="route_geocaches">Geoládák</string>
|
||||
<string name="route_home">Főoldal</string>
|
||||
<string name="route_import_follows">Követettek importálása</string>
|
||||
<string name="route_log_geocache_find">Megtalálás naplózása</string>
|
||||
<string name="route_media">Média</string>
|
||||
<string name="route_messages">Üzenetek</string>
|
||||
<string name="route_music_playlists">Lejátszási listák</string>
|
||||
<string name="route_music_tracks">Zene</string>
|
||||
<string name="route_new_geocache">Láda elrejtése</string>
|
||||
<string name="route_new_geocache_hunt">Új vadászat</string>
|
||||
<string name="route_notifications">Értesítések</string>
|
||||
<string name="route_podcast_episodes">Részek</string>
|
||||
<string name="route_podcasts">Hangműsor</string>
|
||||
@@ -4948,6 +5165,18 @@
|
||||
<string name="vanish_request_sent">Törlési kérés elküldve</string>
|
||||
<string name="video_codec_h265_description">Jobb minőség kisebb fájlméret mellett, de nem minden eszköz támogatja a H.265 lejátszást.</string>
|
||||
<string name="video_codec_h265_label">H.265/HEVC-kodek használata</string>
|
||||
<string name="video_collaboration_accepted_on">Elfogadott egy együttműködési kreditet</string>
|
||||
<string name="video_collaboration_declined_on">Elutasított egy együttműködési kreditet</string>
|
||||
<string name="video_credit_accepted">Kredit elfogadva</string>
|
||||
<string name="video_credit_audio_from">Hang innen:</string>
|
||||
<string name="video_credit_featuring">Közreműködik</string>
|
||||
<string name="video_credit_inspired_by">Ihlette:</string>
|
||||
<string name="video_credit_references">Hivatkozások</string>
|
||||
<string name="video_curation_set_private_items">Privát lista</string>
|
||||
<plurals name="video_curation_set_video_count">
|
||||
<item quantity="one">%1$d videó</item>
|
||||
<item quantity="other">%1$d videó</item>
|
||||
</plurals>
|
||||
<string name="video_download_has_started_toast">A videó letöltése megkezdődött…</string>
|
||||
<string name="video_player_settings">Videólejátszó gombjai</string>
|
||||
<string name="video_saved_to_the_gallery">Videó mentve a videógalériába</string>
|
||||
@@ -5007,4 +5236,13 @@
|
||||
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">A kedvezményezett lightning-szolgáltatása ennél: %1$s nem érhető el. A(z) „%2$s” lightning-cím lett kiszámítva. Hiba: %3$s. Ellenőrizze, hogy a kiszolgáló működik-e, és hogy a lightning-cím helyes-e</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Nem sikerült Lightning-számlát létrehozni a következőből: %1$s: A „PR” elem nem található a kapott JSON-ban.</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Nem lehetett lightning-számlát létrehozni. Üzenet a következőtől: %1$s: %2$s</string>
|
||||
<string name="marmot_invite_device_success">Ez az eszköz mostantól fogadja a Marmot meghívóit.</string>
|
||||
<string name="marmot_invite_device_failure">Nem sikerült közzétenni a KeyPackage-et: %1$s</string>
|
||||
<string name="marmot_invite_device_rejected">Egyetlen átjátszó sem fogadta el a KeyPackage-et. Ellenőrizze az átjátszó beállításait, és próbálja újra.</string>
|
||||
<string name="chat_delivery_sending">Küldés\u2026</string>
|
||||
<string name="chat_delivery_failed">Nem sikerült elküldeni. Koppintson az újrapróbálkozáshoz.</string>
|
||||
<plurals name="marmot_unread_messages">
|
||||
<item quantity="one">%1$d új üzenet</item>
|
||||
<item quantity="other">%1$d új üzenet</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user