Merge origin/main into claude/serene-galileo-punak3

Conflicts: main moved the DataStore preference layer into commons (deleting
AccountPreferenceStores.kt) and switched the backup screens to Compose resources
(Res.*). Took main's side and re-applied the event renames to every file main
touched. String literals were left alone, so stored keys such as
"migrated.followLists" and the preference key names are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VvJ8XpeYqBe8bYT3bJ7UD
This commit is contained in:
Claude
2026-09-26 22:38:11 +00:00
345 changed files with 16073 additions and 7296 deletions
+42
View File
@@ -285,6 +285,48 @@ Quartz uses expect/actual for platform-specific implementations (e.g. crypto
backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on
JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
## Strings
**MANDATORY: every new user-visible string goes in `commonsUI` Compose resources**,
`commonsUI/src/commonMain/composeResources/values/strings.xml` — **never** in
`amethyst/src/main/res/values/strings.xml`, even for an Android-only screen. A
screen cannot move to `commonsUI` (or be rendered by Desktop) while its labels
live in Android `res/`, and every key added there has to be migrated back out
later. (The 2026-09-22 move emptied Android `res/` down to ~190 keys; the next two
features put 447 back.)
- Reference it as `Res.string.my_key` / `Res.plurals.my_key`, importing
`com.vitorpamplona.amethyst.commons.resources.Res` **and** the per-key accessor
`com.vitorpamplona.amethyst.commons.resources.my_key` (Compose resources generates
each key as an extension property).
- Read it with `stringRes(Res.string.x)` / `pluralStringRes(Res.plurals.x, n, …)`
in composition — the app-side `ui.stringRes` overloads delegate to the
commonsUI bridge (`commons/ui/StringRes.kt`), so one import serves both kinds.
- Outside composition there is **no blocking accessor**: use `loadStringRes(…)`
from a coroutine (or make the function `suspend`), or resolve the label in
composition and pass it down as a `String`. A `runBlocking` bridge is ruled out.
- Hold a string reference as `StringResource` / `PluralStringResource`, not a
`@StringRes Int`.
- Format arguments must be positional (`%1$s`, `%2$d`) — compose-resources does
not format bare `%s`/`%d`.
- Escaping is not Android's: write `'` and `"` bare (no `\'`), and never wrap a
value in quotes to keep its whitespace. `.claude/hooks/compose_escaping_check.py`
checks this before a push.
The **only** strings that stay in Android `res/` are the ones a platform API
reads synchronously under a deadline, where a `suspend` read cannot run: a
foreground service's `startForeground` notification, a notification channel
created during service startup, PiP `RemoteAction`s, napplet capability labels
read in the sandbox process, and `AndroidManifest.xml` / `res/xml` references.
Most of those are *copies* of a key that also lives in `commonsUI`. See
`commons/plans/2026-09-22-strings-to-compose-resources.md` ("the
synchronous-platform tier"). If you think a new string belongs in that tier,
say why in the PR.
To move keys that already landed in Android `res/`, use
`tools/strings-migrate/migrate.py <keys…>`. It moves every locale byte-for-byte,
so Crowdin sees a pure move. Then repoint `R.string.x` → `Res.string.x`.
## Icons
The Material Symbols font bundled at
+14
View File
@@ -27,6 +27,9 @@ Repair with:
idempotent, quote-unwrapping is not, and a second unwrap strips the real display
quotes from values like `import_follows_tips`.
Raw line breaks and tabs inside a value are the whitespace half of the same
mismatch: aapt collapsed them, Compose renders them (the same repair fixes them).
Only the Compose catalog is scanned. In an Android res tree the same escaping is
correct and must be left alone.
"""
@@ -41,6 +44,10 @@ CATALOG = "*/src/*/composeResources/values*/strings.xml"
# Compose resolves those itself.
ANDROID_ESCAPE = re.compile(r"(?<!\\)\\(['\"?@])")
TOOLS_ATTR = re.compile(r'tools:[\w.-]+="')
# A value whose text holds a raw line break or tab. aapt collapsed that XML layout to
# one space; Compose draws it, and a markdown value indented by it becomes a code block.
STRING_TEXT = re.compile(r"<(string|item)\b[^>]*>(.*?)</\1>", re.S)
LAYOUT_WS = re.compile(r"[\r\n\t]")
def find_violations(root: Path):
@@ -52,6 +59,13 @@ def find_violations(root: Path):
n = len(TOOLS_ATTR.findall(text))
if n:
found[path]["tools: attribute"] += n
n = sum(
1
for m in STRING_TEXT.finditer(text)
if "<![CDATA[" not in m.group(2) and LAYOUT_WS.search(m.group(2))
)
if n:
found[path]["raw line break/tab in a value"] += n
return found
+18 -3
View File
@@ -875,18 +875,33 @@ fun ProfileScreen(
### 3. Resource Access
**Strings do not go in `amethyst/src/main/res`.** New user-visible strings live in
`commonsUI/src/commonMain/composeResources/values/strings.xml` and are read as
`Res.string.x`, even on an Android-only screen. See "Strings" in `.claude/CLAUDE.md`
for the rule and the small platform tier that is the only exception.
```kotlin
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.button_clicked
import com.vitorpamplona.amethyst.commons.resources.button_label
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun LocalizedButton() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Button(
onClick = {
val message = context.getString(R.string.button_clicked)
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
// No blocking accessor outside composition: read it from a coroutine,
// or resolve it in composition and capture the String.
scope.launch {
Toast.makeText(context, loadStringRes(Res.string.button_clicked), Toast.LENGTH_SHORT).show()
}
}
) {
Text(stringResource(R.string.button_label))
Text(stringRes(Res.string.button_label))
}
}
```
@@ -26,6 +26,13 @@ There are two separate `strings.xml` trees, each with its own default `values/`
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` |
**New keys go in the `commonsUI` tree, always.** The amethyst tree is frozen at
the small synchronous-platform tier (foreground-service and notification-channel
text, PiP actions, napplet capability labels, manifest references). If you find
a new feature key in it, it belongs in `commonsUI`. Move it with
`tools/strings-migrate/migrate.py` rather than translating it where it is. See
"Strings" in `.claude/CLAUDE.md`.
The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
+114 -17
View File
@@ -335,10 +335,29 @@ jobs:
name: Quartz iOS Test Reports
path: quartz/build/reports
test-and-build-android:
# Android Lint, split out of test-and-build-android.
#
# That job died with "the runner has received a shutdown signal" (SIGTERM,
# exit 143) seven times across this workflow's history — the OOM killer taking
# the runner agent on a 16GB box. Two attempts to fix it by tuning numbers
# have now been spent: capping both daemons to 4g traded the runner OOM for an
# R8/lintAnalyze "Java heap space", and --max-workers=3 survives a warm cache
# but still dies on a cold one.
#
# This is the structural fix rather than a third number. lintAnalyze is the
# single heaviest step in that job — measured at 13 of its 35 minutes on one
# cold run — and it holds a large analysis graph while the Kotlin daemon, a
# forked test JVM and R8 are all still resident. Giving the two lint tasks
# their own runner removes that peak from the critical job instead of trying
# to squeeze everything under one ceiling, and the two now run concurrently.
#
# The cost is honest: both jobs restore the same read-only Gradle cache and so
# repeat some module compilation. That buys back more than it spends here,
# because the duplicated work is parallel while the memory pressure was not.
lint-android:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 90
steps:
- name: Checkout code
uses: actions/checkout@v7
@@ -354,12 +373,61 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Lint + focused unit tests + benchmark assembly in one Gradle invocation.
# Previously: one invocation for lint, one for `test` (which compiled all
# six amethyst variants × all flavors), one for `assembleBenchmark`
# (re-walking the same task graph). Combining them keeps the daemon hot
# across phases and lets task-level dedup (e.g. compileKotlin) only
# happen once.
# Same daemon cap as the sibling job: lintAnalyze draws on the Gradle
# daemon's heap, which is why the earlier 4g experiment broke it. Only the
# Kotlin daemon is trimmed.
- name: Lint Android (gradle)
run: |
./gradlew \
-Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \
--max-workers=3 \
:amethyst:lintFdroidBenchmark \
:amethyst:lintPlayBenchmark
- name: Upload Android Lint Reports
uses: actions/upload-artifact@v7
if: always()
with:
name: Android Lint Reports
path: amethyst/build/reports/lint-results-*.html
test-and-build-android:
needs: lint
runs-on: ubuntu-latest
# 90, not 60. On main this job's Gradle step takes ~46 minutes, which used
# 76% of a 60-minute budget — fine for an incremental run, but PR runs set
# `cache-read-only` (below), so they restore main's Gradle cache and never
# save. A PR that touches `quartz` or `commons` invalidates most of what
# that cache holds for everything downstream, and the job then rebuilds it
# from cold: measured 2-3x the main-branch time across every job in the
# workflow, which puts this one past the cap and gets it killed mid-step
# with no test report. Raising the cap costs nothing on runs that finish
# early — `timeout-minutes` bounds a job, it does not reserve the time.
timeout-minutes: 120
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Focused unit tests + benchmark assembly in one Gradle invocation.
# Previously: one invocation for `test` (which compiled all six amethyst
# variants × all flavors) and one for `assembleBenchmark` (re-walking the
# same task graph). Combining them keeps the daemon hot across phases and
# lets task-level dedup (e.g. compileKotlin) only happen once.
#
# Lint used to run here too and now has its own job (lint-android above) —
# see the note there for why. What remains is still the heaviest job in
# the workflow, so the memory notes below continue to apply.
#
# `-PdisableAbiSplits=true` produces a single non-split APK per
# (flavor, buildType) instead of 5 (4 ABIs + universal). The CI only
@@ -371,11 +439,47 @@ jobs:
# variants are compile-equivalent for unit-test purposes; running all six
# adds ~5× the kotlinc work without catching new defects on PRs. Push to
# main still gets the full test matrix via the production-build path.
# Memory, CI-only. gradle.properties asks for -Xmx6g (Gradle) plus -Xmx8g
# and 2g metaspace (Kotlin daemon) — about 16GB of ceiling on a 16GB
# ubuntu-latest runner, before the launcher JVM, Lint's fork and the KSP
# workers. This job is the only one heavy enough to reach it, and it died
# five times mid-compile with "the runner has received a shutdown signal",
# which is the OOM killer taking the runner agent.
#
# Only the Kotlin daemon is cut. An earlier attempt capped BOTH to 4g and
# traded one OOM for another: the runner survived and the job ran to
# completion, but R8 and lintAnalyze then failed with
# "java.lang.OutOfMemoryError: Java heap space" — they draw on the Gradle
# daemon's heap, and 4g is not enough for them on this app. 6g always was,
# so it stays; 8g + 2g for kotlinc is the part that did not fit.
#
# Overridden here rather than in gradle.properties so local builds on
# bigger machines keep the headroom.
#
# `--max-workers` is the second half, and it is about concurrency rather
# than ceilings. The heap numbers above are per-JVM limits; what actually
# tips a 16GB runner over is how many heavy JVMs are live at once. Gradle
# defaults max-workers to the core count (4 on ubuntu-latest) and
# org.gradle.parallel is on, so a cold run can have several kotlinc
# workers, a lint fork and a forked test JVM resident alongside the two
# daemons.
#
# Cold is the case that matters. The 4g cap was first validated on a run
# that only changed this file, so it restored main's Gradle cache and
# built almost nothing; the next PR run that touched `commons`
# invalidated that cache, rebuilt from cold, and died the same way at
# ~20 minutes. Fewer workers is what makes the cold path fit — dropping
# heap further would start starving R8 again, which is the trade the
# previous attempt already lost.
#
# 3 rather than 2: this job is mostly a chain of single-task module
# compiles, so the parallelism it loses is small, and halving it risks
# the timeout on a cold run. The cap goes to 120 for the same reason.
- name: Test + Build Android (gradle)
run: |
./gradlew \
:amethyst:lintFdroidBenchmark \
:amethyst:lintPlayBenchmark \
-Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \
--max-workers=3 \
:quartz:jvmTest \
:commons:jvmTest \
:commonsUI:jvmTest \
@@ -386,13 +490,6 @@ jobs:
:amethyst:assembleBenchmark \
-PdisableAbiSplits=true
- name: Upload Android Lint Reports
uses: actions/upload-artifact@v7
if: always()
with:
name: Android Lint Reports
path: amethyst/build/reports/lint-results-*.html
# Publishes the JUnit XML produced by the unit-test tasks above as inline
# annotations plus a job summary. Replaces asadmansr/android-test-report-action,
# which was abandoned (last release 2020) and rebuilt an EOL Ubuntu 18.04 +
@@ -0,0 +1,140 @@
# Retiring EncryptedStorage
Status: **migrated, not yet deleted.** Every key has a home in the new stores.
The legacy files are still written, so they are still there — and the reader
can never go.
## The constraint
Every migration in the preference layer is *lazy*: it reads the legacy store
when it runs, not when the app is installed. Ten come through
`EncryptedStorage` — the eight `LegacyKeyTable` copies on the per-account
DataStore plus the key, secret and roster stores. Only the Cashu counters and
calendar reminders read a plain (non-encrypted) source and would survive its
removal.
So deleting `EncryptedStorage` does not merely affect installs that have not
upgraded yet. It strands anyone who **skips** the release introducing the new
stores: a pre-migration build upgrading straight to a post-deletion build runs
its migration against a reader that no longer exists. Keys, accounts, wallets
and settings stay encrypted on disk with nothing able to read them, and the app
opens as a fresh install. Auto-update off, the F-Droid cadence and restoring
from a backup all skip releases.
**The reader is permanent.** What a later release can retire is the legacy
*write*, which stops new data landing there while old data stays readable.
`androidx.security.crypto` has to stay for as long as the reader does. That is
an unmaintained-library risk, not an active vulnerability, and a much smaller
cost than stranding users.
## Where each key went
`LegacyKeyCoverageTest` holds this to being exhaustive: every constant in
`PrefKeys` is either claimed by a migration table, one of the secrets, on the
accepted-loss list, or a key of the global file. A key added to `PrefKeys` and
to none of those fails that test at the commit that adds it.
| group | destination | legacy write |
|---|---|---|
| follow lists, cached events, upload, dialogs, relay auth, feed visibility, notifications | the account's plain DataStore | already retired |
| identity — pubkey, signer, local relays, backup conflicts, backup flag | the account's plain DataStore | **kept** |
| private key | `SecureKeyStorage` | **kept** |
| NIP-46 material, wallets, payment source | the account's encrypted DataStore | **kept** |
| current account, saved accounts | the encrypted roster store | **kept** |
| UI settings (`shared_settings`) | `UiSharedPreferences`' own DataStore | none left |
| location-chat identity — seed, nickname | the account's encrypted DataStore, as its own `GeohashIdentitySecrets` group | **kept** |
The stores that still mirror are the ones whose loss is not an annoyance: an
account that cannot be listed, signed with, or paid from. They keep the
rollback window open until the device pass below has happened.
The location-chat identity is the odd one, in two ways worth knowing before
step 4. It is its **own** group rather than fields on `AccountSecrets`: every
account save mirrors a whole `AccountSecrets` built from `AccountSettings`,
which does not hold these, and that group save removes keys whose value is
null — folded in, the seed would be deleted by the next unrelated save and
every geohash identity the user has would silently change. And its legacy home
is a **different file**, `secret_keeper_<pubkey hex>`, because its writer
passed `signer.pubKey` where every other caller passes an npub.
The UI settings copy is **guarded** where the others are not. That store has
been the real home of these settings for a while, so most installs already
have a populated one and copying the old blob over it would undo every UI
change since. The copy only runs into a store that has never been saved
(`ui.theme` absent, which `save` always writes).
## Deliberately not migrated
| key | what is lost |
|---|---|
| `PENDING_ATTESTATIONS` | queued OTS attestations are not published |
| `NOTIF_GLOBAL_TO_CURATED_MIGRATED` | the one-shot notification filter migration runs once more |
| `LAST_READ_PER_ROUTE` | every feed reads as unread once |
| `USE_PROXY`, `PROXY_PORT` | nothing — only ever removed, never read |
| `TOR_SETTINGS` | nothing — no reader left anywhere |
These are listed in `LegacyAccountKeys.accepted`, which is what lets the
cleanup treat any *other* unclaimed key as a reason to keep the file.
## Deleting a legacy file
`LegacyPreferenceCleanup` runs after every successful account load and deletes
that account's files — `secret_keeper_<npub>` **and** the location-chat
identity's `secret_keeper_<pubkey hex>` — only when it can prove nothing would
be lost. Both, because nothing else would ever remove the second one: the
cleanup enumerates npub-keyed files, so left out of this it would sit on disk
holding a seed forever.
1. **Every key in the file is accounted for** — claimed by a table, one of the
secrets, or on the accepted list. Driven from the file's own keys, not from
a checklist, because a checklist fails silently in the one direction that
matters.
2. **Every copy that had something to copy has run.** Marker-based, not a value
comparison: those groups stopped being legacy-written when they moved, so
the file is a frozen snapshot and the two are *expected* to diverge as soon
as the user changes a setting. `CopyOnceMigration` commits the values and
its marker as one `Preferences`, so the marker cannot be set without them.
3. **The secrets and the private key read back identical** from the current
stores. Those *are* still dual-written, so the stronger question is
available and is asked.
4. **The location-chat identity has been copied**, when its file holds one.
Read from the hex-keyed file, not the npub one — these two keys were never
in that one, so a check pointed at it would never fire. An account that
never opened a location chat holds neither key, which is a real answer and
must not hold the file hostage.
5. A store that cannot be read is a reason, never a pass.
It refuses today, and says so, because of the last condition:
`LEGACY_WRITES_RETIRED` is false. While the app still mirrors into the file,
deleting it achieves nothing — the next save recreates it — and would look
like it had worked.
## Order of work
1. ~~Migrate the remaining keys.~~ Done.
2. ~~Gate deletion on a per-account read-back.~~ Done.
3. Do the device pass below.
4. Flip `LEGACY_WRITES_RETIRED` and drop the legacy writes for the identity,
key, secret and roster stores. This ends the rollback window, so it is a
release of its own. The flag is `internal`, not private, so the
location-chat mirror in `GeohashChatIdentityState` reads the same switch —
flipping it stops that write too, and the cleanup then removes both of the
account's legacy files. One flip, nothing left behind.
5. Keep the reader, and `androidx.security.crypto`, indefinitely.
## Verification this needs and has not had
None of the AndroidKeyStore paths have executed: this environment has no device
or emulator, and `commons` has no Robolectric. What is tested is the decision
logic against fakes — which is why step 3 is not optional, and why deletion is
the one irreversible step in the whole series.
On a real device, before step 4 ships: upgrade an install holding accounts and
confirm they all list; open one and sign; force-stop and relaunch; add and
remove an account; pair a NIP-46 signer; pay from a wallet; check the
key-backup nudge stays dismissed; confirm UI settings survive the upgrade;
open a location chat under a bunker or external signer and confirm the
throwaway identity and nickname are the same ones as before the upgrade — the
seed is the one migrated value whose loss is silent rather than visible.
Then let the cleanup run with the flag flipped, and confirm the files are gone
and everything above still holds on the next cold start.
@@ -25,10 +25,10 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -24,11 +24,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -0,0 +1,191 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.utils.Log
/**
* The narrow slice of a key store this needs.
*
* An interface rather than [SecureKeyStorage] directly so the decision logic
* below — which store wins, what happens when one fails — is testable without
* an AndroidKeyStore, which no unit test can reach.
*/
interface PrivateKeyVault {
/** The stored key, or null only when genuinely absent. Throws when the store cannot be read. */
suspend fun get(npub: String): String?
suspend fun save(
npub: String,
privKeyHex: String,
)
suspend fun delete(npub: String)
}
/** [PrivateKeyVault] over the real [SecureKeyStorage]. */
class SecureKeyStorageVault(
private val storage: SecureKeyStorage,
) : PrivateKeyVault {
override suspend fun get(npub: String): String? = storage.getPrivateKeyOrThrow(npub)
override suspend fun save(
npub: String,
privKeyHex: String,
) = storage.savePrivateKey(npub, privKeyHex)
override suspend fun delete(npub: String) {
storage.deletePrivateKey(npub)
}
}
/**
* Moves account private keys off `androidx.security.crypto` without ever
* leaving one only in a place the running build cannot read.
*
* The old home is `secret_keeper_<npub>`, an EncryptedSharedPreferences file.
* The new one is [SecureKeyStorage], which on Android is now an encrypted
* DataStore sealed by the AndroidKeyStore directly. Both are written on every
* save, and reads prefer the new store but fall back to the old one, so:
*
* - an install that has never run this build still finds its key, and is
* migrated the first time the account loads;
* - a build rolled back to reading only the old store still finds every key,
* including ones added after the upgrade;
* - a new store that cannot be read — a wiped AndroidKeyStore after a device
* credential reset, say — falls back rather than presenting the account as
* having no key, which would silently demote it to read-only.
*
* Nothing is deleted here, and the legacy *reader* is permanent — see
* [EncryptedStorage]. The migration is lazy, so an install that skips the
* release introducing this store still needs the old file readable when it
* finally arrives. What a later release can drop is the legacy **write**, once
* every key in that file has a new home; several still do not.
*
* The two stores cannot legitimately disagree: an npub is derived from its
* private key, so the key for a given npub never changes. A mismatch means
* corruption, and is resolved in favour of the older, proven store.
*/
class AccountKeyStore(
private val vault: PrivateKeyVault,
) {
companion object {
private const val TAG = "AccountKeyStore"
}
/**
* The account's private key, or null when it genuinely has none — an
* external-signer account, or a watch-only npub.
*
* @param legacyValue what the legacy store holds, read by the caller that
* already has the file open.
*/
suspend fun read(
npub: String,
legacyValue: String?,
): String? {
val fromSecure =
try {
vault.get(npub)
} catch (e: Exception) {
// Unreadable, not absent. Fall back, and do not migrate into a
// store that just failed.
Log.w(TAG, "Could not read the key store for $npub; using the legacy store", e)
return legacyValue
}
if (fromSecure != null) {
if (legacyValue != null && legacyValue != fromSecure) {
Log.e(TAG, "Key mismatch for $npub between the legacy and current stores; keeping the legacy value", null)
return legacyValue
}
return fromSecure
}
// Absent from the new store: first load since the upgrade.
if (legacyValue != null) migrate(npub, legacyValue)
return legacyValue
}
private suspend fun migrate(
npub: String,
privKeyHex: String,
) {
try {
vault.save(npub, privKeyHex)
Log.i(TAG) { "Migrated the private key for $npub into the current store" }
} catch (e: Exception) {
// The legacy store still has it and is still read, so this is
// recoverable — the next load tries again.
Log.w(TAG, "Could not migrate the private key for $npub; it stays in the legacy store", e)
}
}
/**
* Mirrors a save into the new store. The legacy write stays where it is,
* inside the caller's existing edit block, so a rollback keeps working.
*
* The three cases match the legacy write exactly, including the one that is
* easy to get wrong: with no external signer and no private key in hand,
* the legacy store *leaves the stored key alone* rather than clearing it,
* so this must not clear it either. Deleting here would drop the key on
* every save from a session that never decrypted it.
*/
suspend fun mirrorSave(
npub: String,
usesExternalSigner: Boolean,
privKeyHex: String?,
) {
try {
when {
usesExternalSigner -> vault.delete(npub)
privKeyHex != null -> vault.save(npub, privKeyHex)
else -> Unit
}
} catch (e: Exception) {
// Never fatal: the legacy store still loads the account, and the
// next save or load repairs this one.
Log.w(TAG, "Could not write the private key for $npub to the current store", e)
}
}
/**
* What the current store holds, with no fallback to the legacy value.
*
* For [LegacyPreferenceCleanup]; [read] deliberately hides this distinction.
*/
suspend fun stored(npub: String): String? = vault.get(npub)
/** Drops the key from the new store; the caller clears the legacy file itself. */
suspend fun delete(npub: String) {
try {
vault.delete(npub)
} catch (e: Exception) {
Log.w(TAG, "Could not delete the private key for $npub from the current store", e)
}
}
}
/** The production instance, over the app's [SecureKeyStorage]. */
val accountKeyStore: AccountKeyStore by lazy {
AccountKeyStore(SecureKeyStorageVault(SecureKeyStorage.create(Amethyst.instance.appContext)))
}
@@ -0,0 +1,183 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.commons.model.preferences.AccountRosterStore
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
import com.vitorpamplona.quartz.utils.Log
/**
* The slice of the roster store this needs.
*
* An interface so the fallback decisions below are testable without an
* AndroidKeyStore, which no unit test can reach.
*/
interface RosterStorage {
suspend fun hasMigrated(): Boolean
suspend fun markMigrated()
suspend fun currentAccount(): String?
suspend fun setCurrentAccount(npub: String?)
suspend fun allAccountInfoJson(): String?
suspend fun setAllAccountInfoJson(json: String?)
suspend fun clear()
}
/** [RosterStorage] over the real encrypted store. */
class EncryptedRosterStorage(
private val store: AccountRosterStore,
) : RosterStorage {
override suspend fun hasMigrated() = store.hasMigrated()
override suspend fun markMigrated() = store.markMigrated()
override suspend fun currentAccount() = store.currentAccount()
override suspend fun setCurrentAccount(npub: String?) = store.setCurrentAccount(npub)
override suspend fun allAccountInfoJson() = store.allAccountInfoJson()
override suspend fun setAllAccountInfoJson(json: String?) = store.setAllAccountInfoJson(json)
override suspend fun clear() = store.clear()
}
/**
* Moves the account index — which accounts exist, which one is in front — out
* of the global `secret_keeper` EncryptedSharedPreferences file, on the same
* terms as the keys and secrets before it: both stores written, new store
* preferred on read, nothing deleted.
*
* This one is the most consequential to get wrong. Every private key can be
* perfectly intact and, if the roster reads empty, the app still opens as a
* fresh install with no way back to the accounts that are sitting on disk.
* So a read that fails or comes back empty falls through to the legacy file
* rather than being taken at face value.
*
* The legacy reader stays for good; see [EncryptedStorage] for why a lazy
* migration cannot have its source deleted.
*/
class AccountRoster(
private val store: RosterStorage,
) {
companion object {
private const val TAG = "AccountRoster"
}
/**
* Runs the one-off copy if it has not run, and reports whether the new
* store can be trusted for this read.
*
* Returns false when anything goes wrong, which sends the caller to the
* legacy file.
*/
private suspend fun ready(
legacyCurrent: () -> String?,
legacyAll: () -> String?,
): Boolean =
try {
if (!store.hasMigrated()) {
store.setCurrentAccount(legacyCurrent())
store.setAllAccountInfoJson(legacyAll())
// Marker last: a crash midway leaves this unmigrated, so the
// next read copies again rather than trusting a half-written
// roster.
store.markMigrated()
}
true
} catch (e: Exception) {
Log.w(TAG, "Could not prepare the roster store; using the legacy file", e)
false
}
suspend fun currentAccount(
legacyCurrent: () -> String?,
legacyAll: () -> String?,
): String? {
if (!ready(legacyCurrent, legacyAll)) return legacyCurrent()
return try {
store.currentAccount() ?: legacyCurrent()
} catch (e: Exception) {
Log.w(TAG, "Could not read the current account; using the legacy file", e)
legacyCurrent()
}
}
/**
* The saved-account list as JSON.
*
* An empty or absent value falls through to the legacy file rather than
* being reported as "no accounts": the two are indistinguishable here, and
* only one of them is safe to act on.
*/
suspend fun allAccountInfoJson(
legacyCurrent: () -> String?,
legacyAll: () -> String?,
): String? {
if (!ready(legacyCurrent, legacyAll)) return legacyAll()
return try {
store.allAccountInfoJson()?.takeIf { it.isNotBlank() && it != "[]" } ?: legacyAll()
} catch (e: Exception) {
Log.w(TAG, "Could not read the saved accounts; using the legacy file", e)
legacyAll()
}
}
suspend fun mirrorCurrentAccount(npub: String?) = guard { store.setCurrentAccount(npub) }
suspend fun mirrorAllAccountInfoJson(json: String?) = guard { store.setAllAccountInfoJson(json) }
/** Matches the legacy `clear()` on the global file when the last account goes. */
suspend fun clear() = guard { store.clear() }
private suspend fun guard(block: suspend () -> Unit) {
try {
block()
} catch (e: Exception) {
// Never fatal: the legacy file is still written and still read.
Log.w(TAG, "Could not write the roster store", e)
}
}
}
val accountRoster: AccountRoster by lazy {
AccountRoster(
EncryptedRosterStorage(
AccountRosterStore(
// Through the holder rather than a DataStore built here: it is the
// one registry that knows which files already have a live store,
// and `roster` sits in the same directory as every other one.
Amethyst.instance.appStores.getDataStore(ROSTER_FILE_NAME),
SecretEncryption(),
Amethyst.instance.applicationIOScope,
),
),
)
}
private const val ROSTER_FILE_NAME = "roster"
@@ -0,0 +1,176 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets
import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecretsEncryptedStores
import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets
import com.vitorpamplona.quartz.utils.Log
import okio.Path.Companion.toOkioPath
/**
* Moves the per-account secrets — NIP-46 bunker material, wallet connection
* strings — out of the `secret_keeper_<npub>` EncryptedSharedPreferences file
* and into an encrypted DataStore, on the same terms as the private key: both
* stores written, new store preferred on read, nothing deleted.
*
* The copy is lazy rather than a DataMigration, and that is not a style
* choice. A DataMigration writes values as-is, while this store decrypts on
* read, so plaintext placed there by one cannot be read back — the attempt
* raises. `EncryptedDataStoreTest` pins that behaviour. Copying through the
* store's own `save` is what keeps the values readable.
*
* Losing these is recoverable — the user re-pairs a signer or re-adds a wallet
* — but it is not something to spend, so a read that fails falls back to the
* legacy values rather than reporting the account as having none.
*
* The legacy reader stays for good; see [EncryptedStorage] for why a lazy
* migration cannot have its source deleted.
*/
class AccountSecretsStore(
private val stores: AccountSecretsEncryptedStores,
) {
companion object {
private const val TAG = "AccountSecretsStore"
}
/**
* The account's secrets, migrating out of the legacy file on first use.
*
* @param legacy what the legacy encrypted file holds, read by the caller
* that already has it open.
*/
suspend fun read(
npub: String,
legacy: AccountSecrets,
): AccountSecrets {
val stored =
try {
stores.loadSecrets(npub)
} catch (e: Exception) {
Log.w(TAG, "Could not read the secrets store for $npub; using the legacy file", e)
return legacy
}
if (stored != null) return stored
// Not migrated yet: copy the legacy values across and use them.
mirror(npub, legacy)
return legacy
}
/** Mirrors a save into the new store. The legacy write stays where it is. */
suspend fun mirror(
npub: String,
value: AccountSecrets,
) {
try {
stores.saveSecrets(npub, value)
} catch (e: Exception) {
// Never fatal: the legacy file still has them, and the next save or
// load tries again.
Log.w(TAG, "Could not write the secrets for $npub to the current store", e)
}
}
/**
* What the current store holds, with no fallback to the legacy file.
*
* For [LegacyPreferenceCleanup], which has to tell "migrated" from
* "falling back and looking migrated" — the read above deliberately cannot.
*/
suspend fun stored(npub: String): AccountSecrets? = stores.loadSecrets(npub)
// ── the location-chat identity ────────────────────────────────────
/**
* The account's location-chat identity, migrating out of the legacy file on
* first use, on the same terms as [read].
*
* @param legacy opens and reads `secret_keeper_<pubkey hex>`. Note the
* *hex*: this group's legacy file is keyed by the signer's pubkey rather
* than the npub every other group uses, so it is a different file.
*
* A lambda, not a value, because opening that file **creates** it — an
* `EncryptedSharedPreferences` writes its Tink keyset on construction. An
* eager read would resurrect the file on the load after the cleanup
* deleted it, and would cost a Keystore-backed open per account on every
* cold start. Called only when the store has nothing yet.
*/
suspend fun readGeohashIdentity(
npub: String,
legacy: suspend () -> GeohashIdentitySecrets,
): GeohashIdentitySecrets {
val stored =
try {
stores.loadGeohashIdentity(npub)
} catch (e: Exception) {
Log.w(TAG, "Could not read the location-chat identity for $npub; using the legacy file", e)
return legacy()
}
if (stored != null) return stored
val fromLegacy = legacy()
mirrorGeohashIdentity(npub, fromLegacy)
return fromLegacy
}
/**
* What the current store holds for the location-chat identity, with no
* fallback to the legacy file — the same distinction [stored] draws, and
* for the same reader: [LegacyPreferenceCleanup] has to tell "migrated"
* from "falling back and looking migrated".
*/
suspend fun storedGeohashIdentity(npub: String): GeohashIdentitySecrets? = stores.loadGeohashIdentity(npub)
/** Mirrors a save into the new store. The legacy write stays where it is. */
suspend fun mirrorGeohashIdentity(
npub: String,
value: GeohashIdentitySecrets,
) {
try {
stores.saveGeohashIdentity(npub, value)
} catch (e: Exception) {
Log.w(TAG, "Could not write the location-chat identity for $npub to the current store", e)
}
}
suspend fun delete(npub: String) {
try {
stores.removeAccount(npub)
} catch (e: Exception) {
Log.w(TAG, "Could not drop the secrets store for $npub", e)
}
}
}
val accountSecretsStore: AccountSecretsStore by lazy {
AccountSecretsStore(
AccountSecretsEncryptedStores(
rootFilesDir = {
Amethyst.instance.appContext.filesDir
.toOkioPath()
},
scope = Amethyst.instance.applicationIOScope,
),
)
}
@@ -25,9 +25,6 @@ import android.content.ComponentCallbacks2
import android.os.Build
import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment
import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
@@ -143,13 +140,13 @@ class Amethyst : Application() {
WorkerThreadPriorityGovernor.start(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
FavoriteAppsRegistry.init(this)
instance.favoriteApps.init()
// Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions).
BrowserHistoryRegistry.init(this)
instance.browserHistory.init()
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
instance.browserIcons.init()
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
@@ -27,12 +27,27 @@ import android.os.SystemClock
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.UiSettings
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore
import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore
import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore
import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences
import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore
import com.vitorpamplona.amethyst.commons.model.preferences.TorSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics
@@ -40,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryStat
import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
@@ -52,33 +68,28 @@ 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.OkHttpWebSocket
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.state.UiSettingsState
import com.vitorpamplona.amethyst.commons.tor.TorRelayState
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_LOG_STORE
import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_SETTINGS_STORE
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.calendar.calendarReminderLogMigrations
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettingsMigrations
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
@@ -93,13 +104,11 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.amethyst.service.pow.PowJobRestorer
import com.vitorpamplona.amethyst.service.pow.PowJobStore
import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
@@ -198,6 +207,7 @@ import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okio.Path.Companion.toOkioPath
import java.io.File
class AppModules(
@@ -229,19 +239,55 @@ class AppModules(
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
/**
* The app-wide DataStore files — the ones that belong to the install rather
* than to an account. [AccountPreferenceStores] is the same idea keyed by
* npub.
*
* This replaces the `Context.preferencesDataStore` delegate these stores
* used to share. Same paths — `AppPreferenceStores.file` reproduces
* `filesDir/datastore/<name>.preferences_pb` exactly — so nothing migrates
* and a rollback finds its data where it left it. What it buys is that the
* stores themselves live in `commonMain`, where a desktop or CLI front end
* can say where its data lives instead of needing a `Context`.
*
* The shared_settings migration is attached here, to the file, because
* eight stores share it and DataStore runs a file's migrations once, on
* whichever store opens it first.
*/
val appStores by lazy {
AppPreferenceStores(
rootFilesDir = { appContext.filesDir.toOkioPath() },
migrations = { name ->
when {
name == AppPreferenceStores.SHARED_SETTINGS -> UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() }
// One file per account, so the migration is per name rather than a constant.
name.startsWith(CashuPreferences.FILE_PREFIX) ->
listOf(CashuPreferences.legacyMigration(appContext, name.removePrefix(CashuPreferences.FILE_PREFIX)))
name == CALENDAR_REMINDER_SETTINGS_STORE -> calendarReminderSettingsMigrations(appContext)
name == CALENDAR_REMINDER_LOG_STORE -> calendarReminderLogMigrations(appContext)
else -> emptyList()
}
},
)
}
/** The file UI, Tor, Namecoin, OTS and the Buzz stores all share. */
val sharedSettingsStore get() = appStores.sharedSettings()
// Pre-load both preference DataStores in parallel on IO threads.
// Both constructors use runBlocking internally, so starting them concurrently
// reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs).
private val uiPrefsDeferred =
applicationIOScope.async {
val prefs = UiSharedPreferences.uiPreferences(appContext) ?: UiSettings()
UiSharedPreferences(prefs, appContext, applicationIOScope)
val prefs = UiSharedPreferences.uiPreferences(sharedSettingsStore) ?: UiSettings()
UiSharedPreferences(prefs, sharedSettingsStore, appContext, applicationIOScope)
}
private val torPrefsDeferred =
applicationIOScope.async {
val prefs = TorSharedPreferences.torPreferences(appContext) ?: TorSettings()
TorSharedPreferences(prefs, appContext, applicationIOScope)
val prefs = TorSettingsStore.torPreferences(sharedSettingsStore) ?: TorSettings()
TorSettingsStore(prefs, sharedSettingsStore, applicationIOScope)
}
// Blocking load of UI Preferences to avoid theme/language blinking
@@ -252,20 +298,26 @@ class AppModules(
// Blocking load of Tor Settings to avoid connection leaks
val torPrefs by lazy {
Log.d("AppModules", "TorSharedPreferences Init")
Log.d("AppModules", "TorSettingsStore Init")
runBlocking { torPrefsDeferred.await() }
}
// Namecoin ElectrumX server preferences (global, like Tor settings)
val namecoinPrefs by lazy {
Log.d("AppModules", "NamecoinSharedPreferences Init")
NamecoinSharedPreferences(appContext, applicationIOScope)
Log.d("AppModules", "NamecoinSettingsStore Init")
NamecoinSettingsStore(sharedSettingsStore, applicationIOScope)
}
// OTS blockchain explorer preferences (global, like Tor settings)
//
// The blocking load is the one the store used to do inside its own
// constructor: `current` has to answer synchronously for the resolver
// builder, so somebody has to wait. It is explicit here rather than hidden
// in commonMain, which has no runBlocking to hide it behind.
val otsPrefs by lazy {
Log.d("AppModules", "OtsSharedPreferences Init")
OtsSharedPreferences(appContext, applicationIOScope)
Log.d("AppModules", "OtsSettingsStore Init")
val store = sharedSettingsStore
OtsSettingsStore(store, runBlocking { OtsSettingsStore.load(store) })
}
// App services that should be run as soon as there are subscribers to their
@@ -308,12 +360,13 @@ class AppModules(
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
val relayGroupDeletionPrefs =
RelayGroupDeletionStore(sharedSettingsStore, applicationIOScope)
// Restore + persist which drawer section headings the user has folded away, so the side menu
// opens the way they left it (device-global: a collapsed heading is a per-device view choice,
// not an account setting worth syncing, unlike the hidden rows beside it in the drawer).
val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope)
val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(sharedSettingsStore, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -811,8 +864,10 @@ class AppModules(
*/
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
// Singleton stores for napplet permissions. The holder is what enforces
// DataStore's one-instance-per-file rule now; this stays a lazy val so the
// ledger below and the broker share one object.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appStores.getDataStore("napplet_permissions"), nappletAccountScope) }
/**
* The one napplet permission ledger for the main process. Its persistent half is just the store
@@ -830,10 +885,26 @@ class AppModules(
// carry their owning account (`nip46:<signer>:<client>`) and whose sessions run for a specific
// account rather than the active one. The napplet path namespaces its own coordinate the same way
// (see NappletBroker.signerCoordinateFor) instead.
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appStores) }
// Display + relay info for connected NIP-46 remote-signer clients.
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) }
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) }
// The device-local favorite-apps list behind the bottom bar, the Favorite Apps grid and the
// browser launcher, plus the browser's visit history behind the omnibox suggestions. Both live in
// commons and take their store and scope from here — that is the whole of their Android binding.
//
// Main process only: the keyless `:napplet` sandbox never builds AppModules, so it never builds
// these either. One instance each, so DataStore only ever sees one live reader per file.
val favoriteApps by lazy { FavoriteAppsRegistry(appStores.getDataStore(FavoriteAppsRegistry.FILE_NAME), applicationIOScope) }
val browserHistory by lazy { BrowserHistoryRegistry(appStores.getDataStore(BrowserHistoryRegistry.FILE_NAME), applicationIOScope) }
// Favicons captured by the browser host, one PNG per host. Not a DataStore — it takes the directory
// to keep them in, the same way AppPreferenceStores takes rootFilesDir.
val browserIcons by lazy {
BrowserIconRegistry({ appContext.filesDir.toOkioPath() / BrowserIconRegistry.DIR }, applicationIOScope)
}
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
@@ -912,7 +983,7 @@ class AppModules(
// and every enqueue raises the shortService shield so backgrounding
// doesn't freeze a miner.
val powJobStore by lazy {
PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope)
PoWJobStore(File(appContext.filesDir, PoWJobStore.FILE_NAME), applicationIOScope)
}
val powPublishQueue by lazy {
@@ -969,11 +1040,11 @@ class AppModules(
// start — Buzz membership is server-side) and the starred channels. Per account: the
// joined set makes a relay first-party for NIP-42, and a star is personal.
startBuzzPersistence = { account ->
BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces)
BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars)
BuzzWorkspaceStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzWorkspaces)
BuzzChannelStarStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzChannelStars)
// Eager like the rest, so a held NIP-OA attestation is loaded before this account's
// first Buzz-relay AUTH rather than after it.
BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation)
BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation)
},
)
@@ -1083,7 +1154,7 @@ class AppModules(
// Local store for posts the user has scheduled to publish later. Backed by a
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
val scheduledPostStore =
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME).path)
// Organizes cache clearing
val trimmingService by
@@ -1303,7 +1374,7 @@ class AppModules(
Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)),
).conflate()
.collect {
if (CalendarReminderPrefs(appContext).isEnabled() &&
if (calendarReminderSettings().load().enabled &&
CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now())
) {
CalendarReminderWorker.schedule(appContext)
@@ -24,6 +24,39 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* The legacy encrypted preference files, and a permanent read-only migration
* source.
*
* # This class cannot be deleted
*
* Every migration in the preference layer is *lazy*: it reads the legacy store
* at the moment it runs, not when the app is installed. Nine of them come
* through here — the seven CopyOnceMigrations under LocalPreferences plus the
* key, secret and roster stores.
*
* So deleting this class does not only affect installs that have not upgraded
* yet. It strands anyone who **skips** the release that introduced the new
* stores: they move from a pre-migration build straight to a post-deletion one,
* the migration runs against a reader that no longer exists, and their keys,
* accounts, wallets and settings sit encrypted on disk with nothing able to
* read them. The app opens as a fresh install. That is not rare — auto-update
* off, the F-Droid cadence, or a restore from backup all skip releases.
*
* What *can* go, once the new path has shipped and held, is the legacy
* **writes**. Dropping those stops new data landing here while this stays able
* to read what is already here. The `androidx.security.crypto` dependency has
* to stay for as long as this does; it is an unmaintained-library risk rather
* than an active vulnerability, and a far smaller cost than stranding users.
*
* # Before any legacy file is deleted
*
* Deletion is only safe for an account whose every key has been migrated, and
* that is not yet true — see `amethyst/plans/2026-09-23-encrypted-storage-retirement.md`
* for what is still outstanding. NOSTR_PUBKEY is the one to watch: without it
* `loadAccountConfigFromEncryptedStorage` returns null and the account
* disappears whether or not its private key survived.
*/
class EncryptedStorage {
companion object {
private const val PREFERENCES_NAME = "secret_keeper"
@@ -0,0 +1,342 @@
/*
* 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
import androidx.datastore.preferences.core.Preferences
import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentityStore
import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets
import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore
import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore
import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets
import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyAccountSecretNames
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource
import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsStore
import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore
import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore
import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity
import com.vitorpamplona.quartz.utils.Log
/**
* How every key that can appear in a `secret_keeper_<npub>` file is accounted
* for.
*
* Together with [LegacyAccountSecretNames], these two lists are what let
* [LegacyPreferenceCleanup] treat any *other* key in the file as a reason not
* to delete it. `LegacyKeyCoverageTest` holds them to covering all of
* `PrefKeys`, so a key added later cannot quietly fall outside both.
*/
internal object LegacyAccountKeys {
/**
* The one-shot copies out of the account's legacy file.
*
* Each store owns the table of legacy names it came from, so the copy and
* the check that the copy happened read the same list — see [LegacyKeyTable].
*/
val tables =
listOf(
TopNavFollowListStore.legacyTable,
LatestEventCacheStore.legacyTable,
UploadSettingsStore.legacyTable,
DialogDismissalStore.legacyTable,
RelayAuthStore.legacyTable,
FeedVisibilityStore.legacyTable,
NotificationPrefsStore.legacyTable,
AccountIdentityStore.legacyTable,
)
/**
* Keys that are deliberately not carried across.
*
* Each costs something once and nothing after, and none is worth the code
* to move it: queued attestations go unpublished, the one-shot
* Global -> Curated notification rewrite runs one more time, and every feed
* reads as unread once. `use_proxy` and `proxy_port` are only ever removed,
* never read, and `tor_settings` has no reader left at all.
*/
val accepted =
setOf(
PrefKeys.PENDING_ATTESTATIONS,
PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED,
PrefKeys.LAST_READ_PER_ROUTE,
PrefKeys.USE_PROXY,
PrefKeys.PROXY_PORT,
PrefKeys.TOR_SETTINGS,
)
}
/** What [LegacyPreferenceCleanup] did, and why. */
sealed interface LegacyCleanupResult {
/** There was no legacy file for this account. */
data object NothingToDelete : LegacyCleanupResult
data object Deleted : LegacyCleanupResult
/** Nothing was touched. Each reason names one thing that would have been lost. */
data class Kept(
val reasons: List<String>,
) : LegacyCleanupResult
}
/** The per-account legacy file, as this needs it. */
interface LegacyAccountFiles {
fun source(npub: String): LegacyPreferenceSource
/**
* The account's OTHER legacy file: the location-chat identity, which lives
* in `secret_keeper_<pubkey hex>` rather than `secret_keeper_<npub>`
* because that is the key its writer passed.
*
* Separate from [source] because [delete] removes both, and a check that
* read the npub file for these keys would never find them — they are not
* in it. That mistake was made once already.
*/
fun geohashSource(npub: String): LegacyPreferenceSource
fun exists(npub: String): Boolean
/** Returns false when there was nothing to delete. */
suspend fun delete(npub: String): Boolean
}
/** What the current, encrypted stores hold for an account. */
interface MigratedSecrets {
/** Null when this account has not been copied across yet. */
suspend fun secrets(npub: String): AccountSecrets?
/** Null only when the account genuinely has no private key. Throws when the store is unreadable. */
suspend fun privateKey(npub: String): String?
/**
* The location-chat identity, or null when it has not been copied across.
*
* Its own question because it migrates out of its own file: [AccountSecrets]
* being present says nothing about whether this was carried over.
*/
suspend fun geohashIdentity(npub: String): GeohashIdentitySecrets?
}
/**
* Deletes an account's `secret_keeper_<npub>` file, but only once it can prove
* nothing in it would be lost.
*
* # Why the check is not one rule
*
* The two halves of the migration are in different states, and asking the same
* question of both would give the wrong answer for one of them.
*
* The plain per-account groups — settings, dialogs, feeds, cached events —
* stopped being written to the legacy file when they moved, so that file is a
* frozen snapshot of the day they migrated. Comparing values would flag every
* setting the user has changed since. What is actually being asked of them is
* "did the copy run", and [LegacyKeyTable.hasRun] answers it exactly:
* `CopyOnceMigration` writes the values and its marker as a single
* `Preferences`, committed atomically, so the marker cannot be set without them.
*
* The private key takes the strongest form: read it back and require it to
* equal the legacy one. That comparison stays valid forever, because an npub is
* derived from its private key, so the key for a given npub can never change.
*
* The secrets cannot be compared, and the reason is worth stating because the
* obvious reading is wrong. They *are* dual-written today — but this whole
* check only runs once [legacyWritesRetired] is true, and from that release on
* the legacy copy is frozen while the live one keeps moving. An account that
* re-pairs a bunker or adds a wallet after upgrading would then differ from the
* file forever and never have it deleted. So they are gated the same way as the
* plain groups: on the copy having run, which
* [AccountSecretsEncryptedStores.loadSecrets] reports by returning non-null
* only once its marker is set, and it writes that marker last.
*
* # Why an unrecognised key blocks
*
* A list of keys to check, maintained by hand, fails silently in the one
* direction that matters: a key added later that no migration carries. So the
* check runs the other way round — every key *in the file* must be claimed by
* a table, be one of the secrets, or be on [accepted], the short list of
* deliberate losses. Anything else stops the deletion and says so by name.
*
* # Cost
*
* [LegacyPreferenceSource.keys] goes through `EncryptedSharedPreferences.all`,
* which decrypts every value in the file — there is no keys-only API. It is
* called from the one place an account load is not already cached, so it costs
* at most once per account per process, and nothing at all while
* [legacyWritesRetired] is false.
*
* # Why deletion also waits on the legacy writes
*
* [legacyWritesRetired] is the other half. While the app still mirrors into
* this file on every save, deleting it achieves nothing — the next save
* recreates it, with a subset of what was there. Worse, it would look like it
* had worked. So the file is only removed once it is no longer being written,
* which is a separate release from this one.
*/
class LegacyPreferenceCleanup(
private val tables: List<LegacyKeyTable>,
private val accepted: Set<String>,
private val files: LegacyAccountFiles,
private val currentStore: suspend (String) -> Preferences,
private val secrets: MigratedSecrets,
private val legacyWritesRetired: Boolean,
) {
companion object {
private const val TAG = "LegacyPreferenceCleanup"
const val STILL_WRITTEN = "the legacy file is still written on every save"
}
private val claimed: Set<String> = tables.flatMapTo(mutableSetOf()) { it.legacyNames } + LegacyAccountSecretNames.all
/**
* Everything that would be lost by deleting this account's legacy file.
* Empty means nothing would be.
*
* A store that cannot be read is a reason, never a pass: the whole point is
* to be sure, and "the check itself failed" is not sure.
*/
suspend fun verify(npub: String): List<String> {
val legacy =
try {
files.source(npub)
} catch (e: Exception) {
Log.w(TAG, "Could not open the legacy file for $npub", e)
return listOf("the legacy file could not be read")
}
val reasons = mutableListOf<String>()
val present = legacy.keys()
(present - claimed - accepted).sorted().forEach {
reasons += "no migration claims '$it'"
}
val current =
try {
currentStore(npub)
} catch (e: Exception) {
Log.w(TAG, "Could not read the current store for $npub", e)
return reasons + "the current store could not be read"
}
tables.forEach { table ->
// A table whose keys the file never held has nothing to prove.
if (present.none { it in table.legacyNames }) return@forEach
if (!table.hasRun(current)) reasons += "the '${table.markerName}' copy has not run"
}
reasons += secretMismatches(npub, legacy)
return reasons
}
private suspend fun secretMismatches(
npub: String,
legacy: LegacyPreferenceSource,
): List<String> {
val reasons = mutableListOf<String>()
try {
if (secrets.secrets(npub) == null) reasons += "the secrets have not been copied across"
} catch (e: Exception) {
Log.w(TAG, "Could not read the secrets store for $npub", e)
reasons += "the secrets store could not be read"
}
val legacyKey = legacy.getString(LegacyAccountSecretNames.NOSTR_PRIVKEY)
if (legacyKey != null) {
try {
when (secrets.privateKey(npub)) {
null -> reasons += "the private key has not been copied across"
legacyKey -> Unit
else -> reasons += "the stored private key differs from the legacy file"
}
} catch (e: Exception) {
Log.w(TAG, "Could not read the key store for $npub", e)
reasons += "the key store could not be read"
}
}
reasons += geohashMismatches(npub)
return reasons
}
/**
* Whether deleting this account's `secret_keeper_<pubkey hex>` file would
* lose its location-chat identity.
*
* Read from [LegacyAccountFiles.geohashSource], not from the npub file the
* rest of [verify] walks: these two keys were never in that one. An account
* that never opened a location chat holds neither, and needs no copy.
*/
private suspend fun geohashMismatches(npub: String): List<String> {
val legacy =
try {
readLegacyGeohashIdentity(files.geohashSource(npub))
} catch (e: Exception) {
Log.w(TAG, "Could not read the location-chat identity file for $npub", e)
return listOf("the location-chat identity file could not be read")
}
if (legacy == GeohashIdentitySecrets()) return emptyList()
return try {
if (secrets.geohashIdentity(npub) == null) {
listOf("the location-chat identity has not been copied across")
} else {
emptyList()
}
} catch (e: Exception) {
Log.w(TAG, "Could not read the location-chat identity store for $npub", e)
listOf("the location-chat identity store could not be read")
}
}
/**
* Deletes the account's legacy file if — and only if — [verify] comes back
* empty and the app has stopped writing to it.
*/
suspend fun deleteIfVerified(npub: String): LegacyCleanupResult {
if (!files.exists(npub)) return LegacyCleanupResult.NothingToDelete
if (!legacyWritesRetired) return LegacyCleanupResult.Kept(listOf(STILL_WRITTEN))
val reasons = verify(npub)
if (reasons.isNotEmpty()) {
Log.i(TAG) { "Keeping the legacy file for $npub: ${reasons.joinToString("; ")}" }
return LegacyCleanupResult.Kept(reasons)
}
return try {
if (files.delete(npub)) {
Log.i(TAG) { "Deleted the migrated legacy file for $npub" }
LegacyCleanupResult.Deleted
} else {
LegacyCleanupResult.NothingToDelete
}
} catch (e: Exception) {
Log.w(TAG, "Could not delete the legacy file for $npub", e)
LegacyCleanupResult.Kept(listOf("the legacy file could not be deleted"))
}
}
}
@@ -0,0 +1,45 @@
/*
* 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
import android.content.SharedPreferences
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource
/**
* [LegacyPreferenceSource] over the `secret_keeper` files.
*
* Every getter reports absence as null rather than as a default, which
* `SharedPreferences` itself cannot do — that distinction is what keeps a
* migration from writing "false" over a key the user never set.
*/
class LegacySharedPreferences(
private val prefs: SharedPreferences,
) : LegacyPreferenceSource {
override fun keys(): Set<String> = prefs.all.keys
override fun getBoolean(name: String): Boolean? = if (prefs.contains(name)) prefs.getBoolean(name, false) else null
override fun getString(name: String): String? = prefs.getString(name, null)
// SharedPreferences hands back the live set and documents that mutating it
// corrupts the file, so this copies before anything downstream can hold it.
override fun getStringSet(name: String): Set<String>? = prefs.getStringSet(name, null)?.toSet()
}
File diff suppressed because it is too large Load Diff
@@ -1,124 +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.favorites
import android.content.Context
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
/**
* Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView
* that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own —
* Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving
* alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the
* visit. Used to decorate favorite cards and omnibox suggestion rows.
*
* Lives only in the **main process**. Bytes are persisted as one small PNG per host under
* `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of
* hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep
* `File.exists()` disk checks out of composition).
*/
object BrowserIconRegistry {
private const val DIR = "browser_icons"
private val _keys = MutableStateFlow<Set<String>>(emptySet())
/** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */
val keys: StateFlow<Set<String>> = _keys.asStateFlow()
@Volatile private var iconDir: File? = null
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
// that must not block: init() from app startup and record() from the broker's IPC handler, which
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
// UI while a favicon was saved.
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Binds the app context and indexes already-stored icons. Idempotent.
*
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
* drives recomposition.
*/
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR)
iconDir = dir
io.launch {
dir.mkdirs()
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
}
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
fun record(
host: String,
bytes: ByteArray,
) {
val dir = iconDir ?: return
if (host.isBlank() || bytes.isEmpty()) return
val key = sanitize(host)
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
// icon exists before the file backing it does.
io.launch {
try {
dir.mkdirs()
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
}
/**
* A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers
* that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key.
*/
fun iconModelFor(host: String): String? {
val dir = iconDir ?: return null
val key = sanitize(host)
if (key !in _keys.value) return null
return "file://" + File(dir, key + PNG).absolutePath
}
// Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared
// icon file, which is harmless for a decoration.
private fun sanitize(host: String): String =
host
.lowercase()
.map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' }
.joinToString("")
.take(120)
private const val PNG = ".png"
}
@@ -97,7 +97,7 @@ object FavoriteAppLauncher {
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val isFavorite = Amethyst.instance.favoriteApps.isFavorite("url:$url")
val intent =
NappletBrowserActivity
.intent(
@@ -111,7 +111,7 @@ private fun resolveIconBlob(event: Event?): IconBlob? =
/**
* A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon
* has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the
* has been captured yet. The favicon is stored by [com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry] at browse time (the
* WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache.
*
* Early-returns null when [url] is blank or has no parseable host — this early return is stable
@@ -121,8 +121,9 @@ private fun resolveIconBlob(event: Event?): IconBlob? =
@Composable
fun rememberWebAppIconModel(url: String): String? {
val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
val iconKeys by Amethyst.instance.browserIcons.keys
.collectAsStateWithLifecycle()
return remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) }
}
/**
@@ -467,17 +467,17 @@ class Account(
// redeemed by this key and the relay grants membership to it alone — and this set makes the
// relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would
// hand every other logged-in account an automatic login on a workspace it never joined.
// Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState).
// Restored/persisted per account by BuzzWorkspaceStore (see AccountCacheState).
val buzzWorkspaces = BuzzWorkspaces()
// The Buzz channels THIS account pinned. A star says which channels this user wants at the top
// of the community view, so a shared set let one account reorder and badge every other one's
// channel list. Restored/persisted per account by BuzzChannelStarPreferences.
// channel list. Restored/persisted per account by BuzzChannelStarStore.
val buzzChannelStars = BuzzChannelStars()
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
// AUTH so the relay grants virtual membership. Restored/persisted per account by
// BuzzAttestationPreferences.
// BuzzAttestationStore.
val buzzAttestation = BuzzHeldAttestations(pubKey)
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
@@ -769,7 +769,7 @@ class Account(
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
// Anonymous, per-geohash throwaway identities for Bitchat-interoperable location chats.
val geohashIdentity = GeohashChatIdentityState(signer)
val geohashIdentity = GeohashChatIdentityState(signer, scope)
val muteListDecryptionCache = MuteListDecryptionCache(signer)
val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings)
@@ -1221,7 +1221,8 @@ class AccountSettings(
* Reserve [count] consecutive NUT-13 counters for [keysetId],
* returning the first one. Caller derives `(secret, r)` from
* `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`.
* Persisted synchronously before returning — see [CashuKeysetCounterStore].
* Persisted before returning — see [CashuKeysetCounterStore]. Suspends
* because that write is what stands between a crash and a reused counter.
*
* One-time migration: when this keyset has a non-zero value in the
* legacy [cashuKeysetCounters] map (from a build that persisted
@@ -1229,7 +1230,7 @@ class AccountSettings(
* still at zero, the legacy value is copied over before we reserve
* so an upgrade doesn't reset the counter.
*/
fun reserveCashuCounters(
suspend fun reserveCashuCounters(
keysetId: String,
count: Int,
): Long {
@@ -1238,12 +1239,12 @@ class AccountSettings(
}
/** Inspect the next counter for [keysetId] without consuming any. */
fun peekCashuCounter(keysetId: String): Long {
suspend fun peekCashuCounter(keysetId: String): Long {
migrateLegacyCashuCounter(keysetId)
return cashuCounters.peek(keysetId)
}
private fun migrateLegacyCashuCounter(keysetId: String) {
private suspend fun migrateLegacyCashuCounter(keysetId: String) {
val legacy = cashuKeysetCounters[keysetId] ?: return
cashuCounters.seedIfMissing(keysetId, legacy)
}
@@ -22,13 +22,24 @@ package com.vitorpamplona.amethyst.model
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LegacySharedPreferences
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.accountSecretsStore
import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets
import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity
import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
/**
* The account's anonymous, per-geohash chat identities.
@@ -52,68 +63,122 @@ import com.vitorpamplona.quartz.utils.RandomInstance
*/
class GeohashChatIdentityState(
private val signer: NostrSigner,
private val scope: CoroutineScope,
) {
private val lock = Any()
private val cache = HashMap<String, KeyPair>()
/**
* Guards seed creation as well as the key cache: two callers racing into
* [deviceSeed] must not mint two different seeds, or the loser's cells get
* identities the next launch cannot reproduce. A [Mutex] rather than
* `synchronized`, because the store reads it protects are suspending.
*/
private val mutex = Mutex()
private val cache = ConcurrentHashMap<String, KeyPair>()
@Volatile private var cachedDeviceSeed: ByteArray? = null
/**
* The npub the current store is keyed by.
*
* The legacy file is keyed by the pubkey *hex* — the old code passed
* `signer.pubKey` where every other caller passes an npub, so the identity
* lived in `secret_keeper_<hex>`, a different file from the account's own
* `secret_keeper_<npub>`. The copy below reads that file and writes the
* npub-keyed store, which is what folds this orphan back in with the rest.
*/
private val npub by lazy { signer.pubKey.hexToByteArray().toNpub() }
@Volatile private var cachedNickname: String? = null
@Volatile private var loaded: GeohashIdentitySecrets? = null
/**
* What `secret_keeper_<pubkey hex>` holds.
*
* Only called when the store has nothing yet: opening this file creates it,
* so reading it unconditionally would resurrect it after the cleanup has
* deleted it. Touches disk; callers are off the main thread.
*/
private fun legacy(): GeohashIdentitySecrets = readLegacyGeohashIdentity(LegacySharedPreferences(Amethyst.instance.encryptedStorage(signer.pubKey)))
/**
* The stored identity, copying it out of the legacy file the first time.
*
* **Call under [mutex].** Not self-locking, because [keyPair] already holds
* the lock when it reaches here and [Mutex] is not reentrant.
*/
private suspend fun current(): GeohashIdentitySecrets {
loaded?.let { return it }
return accountSecretsStore.readGeohashIdentity(npub) { legacy() }.also { loaded = it }
}
/** Call under [mutex], for the reason [current] gives. */
private suspend fun persist(value: GeohashIdentitySecrets) {
loaded = value
accountSecretsStore.mirrorGeohashIdentity(npub, value)
}
/**
* The user's display handle for location chats: a single global nickname, persisted per account.
* Bitchat carries this as the per-message `["n", …]` tag rather than a kind-0 profile, and kind-20000
* messages are ephemeral (relays needn't store them), so the only durable home for it is the device.
* Kept in this account's encrypted storage, so it survives restarts and switches with the account.
* Empty string means "no nickname set". Reads touch disk on first call — invoke off the main thread.
* Empty string means "no nickname set".
*/
fun nickname(): String {
cachedNickname?.let { return it }
synchronized(lock) {
cachedNickname?.let { return it }
val value = Amethyst.instance.encryptedStorage(signer.pubKey).getString(PREF_NICKNAME, "") ?: ""
cachedNickname = value
return value
}
}
suspend fun nickname(): String = mutex.withLock { current().nickname ?: "" }
/** Persists the global location-chat nickname (trimmed) for this account. */
/**
* Persists the global location-chat nickname (trimmed) for this account.
*
* Fire-and-forget on the account scope, which is what the SharedPreferences
* `edit {}` this replaced already did — the caller is a click handler on the
* main thread and the write is not something it waits for.
*/
fun setNickname(value: String) {
val trimmed = value.trim()
synchronized(lock) {
cachedNickname = trimmed
Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) }
scope.launch {
// Under the lock: this is a read-modify-write of the same group
// deviceSeed() writes. Racing the first seed mint, an unlocked copy
// would persist the nickname over a null deviceSeed, putOrRemove
// would delete the seed, and every per-cell identity minted that
// session would be unreproducible on the next launch.
mutex.withLock {
persist(current().copy(nickname = trimmed))
// Mirrored, not moved: the legacy file stays readable until the
// legacy writes are retired app-wide, so a rollback keeps the handle.
// Gated on the same switch as every other mirror — otherwise flipping
// it would retire the documented four and leave this one writing.
if (!LocalPreferences.LEGACY_WRITES_RETIRED) {
Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) }
}
}
}
}
/** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached; call off the main thread. */
fun keyPair(geohash: String): KeyPair =
synchronized(lock) {
cache.getOrPut(geohash) { GeohashKeyDerivation.deriveKeyPair(seed(), geohash) }
}
/** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached. */
suspend fun keyPair(geohash: String): KeyPair {
cache[geohash]?.let { return it }
private fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed()
return mutex.withLock {
cache[geohash] ?: GeohashKeyDerivation.deriveKeyPair(seed(), geohash).also { cache[geohash] = it }
}
}
/** Call under [mutex]. */
private suspend fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed()
private fun accountPrivKey(): ByteArray? = (signer as? NostrSignerInternal)?.keyPair?.privKey
/** Random per-account seed, used only when the account key is unreachable (bunker / external signer). */
private fun deviceSeed(): ByteArray {
cachedDeviceSeed?.let { return it }
synchronized(lock) {
cachedDeviceSeed?.let { return it }
val prefs = Amethyst.instance.encryptedStorage(signer.pubKey)
val existing = prefs.getString(PREF_KEY, null)
val seed =
if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) {
existing.hexToByteArray()
} else {
val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE)
prefs.edit { putString(PREF_KEY, fresh.toHexKey()) }
fresh
}
cachedDeviceSeed = seed
return seed
/**
* Random per-account seed, used only when the account key is unreachable (bunker / external signer).
*
* Call under [mutex]: minting a second seed for an account that already has
* one would change every throwaway identity it has ever used.
*/
private suspend fun deviceSeed(): ByteArray {
val stored = current().deviceSeed
if (stored != null && stored.length == GeohashKeyDerivation.SEED_SIZE * 2) return stored.hexToByteArray()
val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE)
persist(current().copy(deviceSeed = fresh.toHexKey()))
if (!LocalPreferences.LEGACY_WRITES_RETIRED) {
Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) }
}
return fresh
}
companion object {
@@ -26,20 +26,21 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedKeyPackageBundleStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidPushStateStore
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.relayauth.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -49,6 +50,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -57,6 +59,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import okio.Path.Companion.toOkioPath
import java.io.File
class AccountCacheState(
@@ -89,6 +92,23 @@ class AccountCacheState(
/** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */
private val loadLock = Any()
/**
* One [AppPreferenceStores] per account directory, kept for the life of the
* process.
*
* [buildAccount] runs again for the same account on re-login and on cache
* races, and DataStore throws if a second instance is ever live on a file
* that already has one. Caching the holder — rather than the store — keeps
* that guarantee for every per-account store that gets added here later,
* not just the relay-auth one.
*/
private val accountStoreHolders = LargeCache<String, AppPreferenceStores>()
private fun storesFor(accountDir: File): AppPreferenceStores =
accountStoreHolders.getOrCreate(accountDir.absolutePath) {
AppPreferenceStores(rootFilesDir = { accountDir.toOkioPath() })
}
fun removeAccount(pubkey: HexKey) {
accounts.update { existingAccounts ->
val oldValue = existingAccounts[pubkey]
@@ -230,13 +250,13 @@ class AccountCacheState(
val mlsStore =
try {
Log.d("AccountCacheState") {
"Initializing AndroidMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}"
"Initializing EncryptedMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}"
}
AndroidMlsGroupStateStore(accountDir)
EncryptedMlsGroupStateStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)",
"Failed to initialize EncryptedMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)",
e,
)
InMemoryMlsGroupStateStore()
@@ -247,11 +267,11 @@ class AccountCacheState(
val marmotMessageStore =
try {
AndroidMarmotMessageStore(accountDir)
EncryptedMarmotMessageStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidMarmotMessageStore (Marmot messages will NOT persist across restarts)",
"Failed to initialize EncryptedMarmotMessageStore (Marmot messages will NOT persist across restarts)",
e,
)
null
@@ -259,11 +279,11 @@ class AccountCacheState(
val marmotKeyPackageStore =
try {
AndroidKeyPackageBundleStore(accountDir)
EncryptedKeyPackageBundleStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)",
"Failed to initialize EncryptedKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)",
e,
)
null
@@ -271,11 +291,11 @@ class AccountCacheState(
val marmotPublishObligationStore =
try {
AndroidPublishObligationStore(accountDir)
EncryptedPublishObligationStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidPublishObligationStore " +
"Failed to initialize EncryptedPublishObligationStore " +
"(a Marmot commit interrupted mid-publish will NOT be retried after a restart)",
e,
)
@@ -310,7 +330,10 @@ class AccountCacheState(
// Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one
// account never leaks into another (the store used to be a single app-wide file).
val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir)
val relayAuthPermissionStore =
DataStoreRelayAuthPermissionStore(
storesFor(accountDir).getDataStore(DataStoreRelayAuthPermissionStore.FILE_NAME),
)
return Account(
settings = accountSettings,
@@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.Log
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model.nip11RelayInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
/**
* Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g.
@@ -43,38 +43,5 @@ fun relayAdvertisesNip(
/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */
fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29")
/**
* Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc
* resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field
* NIP-29 relays publish so clients can verify their relay-signed group metadata — see
* [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading,
* or the fetch failed), so it never triggers the warning.
*/
fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null
/**
* Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29:
* "these are addressable events signed by the relay keypair directly … as stated by the NIP-11
* `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else".
*
* So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self`
* key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29
* relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we
* fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self`
* still works. A relay with neither fails. Reads only the cached NIP-11 doc ([relayInfo]); callers
* driving a live surface should warm it first and re-evaluate as it resolves.
*/
fun isRelaySignedRelayGroup(
channel: RelayGroupChannel,
relayInfo: Nip11RelayInformation,
): Boolean {
val self = relayInfo.self
return if (self != null) {
channel.event?.pubKey == self
} else {
relayInfo.supported_nips?.any { it == "29" } == true
}
}
/** [isRelaySignedRelayGroup] reading the host relay's cached NIP-11 doc (for non-Compose callers). */
fun isRelaySignedRelayGroup(channel: RelayGroupChannel): Boolean = isRelaySignedRelayGroup(channel, Amethyst.instance.nip11Cache.getFromCache(channel.groupId.relayUrl))
@@ -20,110 +20,68 @@
*/
package com.vitorpamplona.amethyst.model.nip60Cashu
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.preferences.core.longPreferencesKey
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.cashu.DataStoreCashuCounterStore
import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
import com.vitorpamplona.quartz.utils.cache.LargeCache
/**
* Per-account Cashu state that needs durable, synchronous persistence —
* separate from [com.vitorpamplona.amethyst.model.AccountSettings] which
* batches writes through a 1-second debounced StateFlow.
* Android's per-account NUT-13 counter store: the shared
* [DataStoreCashuCounterStore] over a file in the app's data directory.
*
* # Why a separate store
* Two older layers feed into it, and neither may move a counter backwards:
*
* The NUT-13 keyset counter is the critical bit. Every mint / swap /
* melt reserves counter slots, derives deterministic blinded outputs at
* those slots, sends them to the mint, and the mint signs them. The
* mint persists which (keyset, blind_message) pairs it has ever signed;
* a second request to sign the same blind_message returns HTTP 400
* "outputs already signed". So once the wallet hands a counter to the
* mint, the local counter advance MUST survive a crash — otherwise the
* next reservation pulls the same slot and the mint rejects it.
* - `cashu_prefs_<npub>` SharedPreferences, copied in full on first read by
* [CopyOnceMigration]. The copy happens inside the same atomic DataStore
* write that records it happened, so a crash cannot leave the marker set
* with the counters missing. It is a copy, not a move: the old file stays
* intact, so a rolled-back build still finds its counters.
* - `AccountSettings.cashuKeysetCounters`, an older in-settings map, still
* applied per keyset through `seedIfMissing` on every read.
*
* The default settings save path debounces writes by 1000 ms, which is
* exactly the race window between "we asked the mint to sign" and "the
* mint replied". A crash inside that window (OOM, signer dialog dismiss,
* unexpected process death) loses the counter advance and makes the
* wallet unusable. This store writes via `commit = true` so each
* reservation is durable before the function returns.
*
* # Layout
*
* One SharedPreferences file per account, named
* `cashu_prefs_<npub>.xml`. Keys are flat:
* - `counter_<keysetId>` → Long, the next free NUT-13 counter
*
* Plain (non-encrypted) prefs because keyset counters aren't secret —
* they're not the seed, they don't carry value, and a leak would only
* tell an attacker how many proofs the wallet has minted at each
* keyset (a privacy signal at most).
*
* # Migration
*
* Older builds stored counters inside `AccountSettings.cashuKeysetCounters`.
* On first read of a given keyset, callers should pre-seed the store
* from the legacy map (one-time copy) so an upgrade doesn't reset the
* counter to zero. See `AccountSettings.migrateCashuCountersTo` for
* the helper.
* Losing a counter here means restarting a keyset at zero and reusing
* indices, which costs real ecash — so nothing on this path is best-effort.
*/
class CashuPreferences(
private val prefs: SharedPreferences,
) : CashuKeysetCounterStore {
/** Inspect the next free counter for [keysetId] without advancing it. */
@Synchronized
override fun peek(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
object CashuPreferences {
private const val LEGACY_FILE_PREFIX = "cashu_prefs_"
/** The store file name for [npub], as AppPreferenceStores takes it. */
const val FILE_PREFIX = "cashu_"
fun fileName(npub: String) = FILE_PREFIX + npub
private val stores = LargeCache<String, CashuKeysetCounterStore>()
/**
* Atomically reserve [count] consecutive NUT-13 counters for
* [keysetId] and return the first reserved index. The write is
* forced to disk with `commit = true` BEFORE returning — see the
* class header for why this isn't optional.
* Per-account instance, cached: DataStore refuses two live instances over
* one file, and a second instance would defeat the single-writer
* serialisation that `reserve` depends on.
*/
@Synchronized
@SuppressLint("ApplySharedPref")
override fun reserve(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = peek(keysetId)
val next = current + count.toLong()
prefs.edit(commit = true) { putLong(counterKey(keysetId), next) }
return current
}
fun forAccount(npub: String): CashuKeysetCounterStore =
stores.getOrCreate(npub) {
DataStoreCashuCounterStore(Amethyst.instance.appStores.getDataStore(fileName(npub)))
}
/**
* Seed [keysetId]'s counter from a legacy value found in
* [AccountSettings.cashuKeysetCounters]. No-op when the store
* already has a value at or above [legacyValue] — never moves the
* counter backwards. Called once at wallet load to carry forward
* pre-migration state.
* The copy out of `cashu_prefs_<npub>`, wired to the file by AppModules
* rather than attached here.
*
* DataStore runs a file's migrations when that file is first opened, and
* the holder is what opens it, so the migration has to be registered with
* the holder or it would never run.
*/
@Synchronized
@SuppressLint("ApplySharedPref")
override fun seedIfMissing(
keysetId: String,
legacyValue: Long,
) {
if (legacyValue <= 0L) return
val current = peek(keysetId)
if (current >= legacyValue) return
prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) }
}
companion object {
private const val FILE_PREFIX = "cashu_prefs_"
private fun counterKey(keysetId: String) = "counter_$keysetId"
/** Per-account instance. [npub] keys the on-disk file so each account is isolated. */
fun forAccount(npub: String): CashuPreferences {
val context = Amethyst.instance.appContext
val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE)
return CashuPreferences(prefs)
fun legacyMigration(
context: Context,
npub: String,
) = CopyOnceMigration("migrated.cashuCounters") { out ->
val legacy = context.getSharedPreferences("$LEGACY_FILE_PREFIX$npub", Context.MODE_PRIVATE)
legacy.all.forEach { (key, value) ->
if (key.startsWith(DataStoreCashuCounterStore.COUNTER_PREFIX) && value is Long) {
out[longPreferencesKey(key)] = value
}
}
}
}
@@ -1,83 +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.model.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.quartz.utils.cache.LargeCache
import java.io.File
class AccountPreferenceStores(
val rootFilesDir: () -> File,
) {
companion object {
val defaultHomeFollowList = stringPreferencesKey("defaultHomeFollowList")
val defaultStoriesFollowList = stringPreferencesKey("defaultStoriesFollowList")
val defaultNotificationFollowList = stringPreferencesKey("defaultNotificationFollowList")
val defaultDiscoveryFollowList = stringPreferencesKey("defaultDiscoveryFollowList")
val localRelayServers = stringPreferencesKey("localRelayServers")
val defaultFileServer = stringPreferencesKey("defaultFileServer")
val latestUserMetadata = stringPreferencesKey("latestUserMetadata")
val latestContactList = stringPreferencesKey("latestContactList")
val latestDMRelayList = stringPreferencesKey("latestDMRelayList")
val latestNIP65RelayList = stringPreferencesKey("latestNIP65RelayList")
val latestSearchRelayList = stringPreferencesKey("latestSearchRelayList")
val latestBlockedRelayList = stringPreferencesKey("latestBlockedRelayList")
val latestTrustedRelayList = stringPreferencesKey("latestTrustedRelayList")
val latestMuteList = stringPreferencesKey("latestMuteList")
val latestPrivateHomeRelayList = stringPreferencesKey("latestPrivateHomeRelayList")
val latestAppSpecificData = stringPreferencesKey("latestAppSpecificData")
val latestPublicChatList = stringPreferencesKey("latestChannelList")
val latestCommunityList = stringPreferencesKey("latestCommunityList")
val latestInterestList = stringPreferencesKey("latestHashtagList")
val latestGeohashList = stringPreferencesKey("latestGeohashList")
val latestEphemeralChatList = stringPreferencesKey("latestEphemeralChatList")
val hideDeleteRequestDialog = stringPreferencesKey("hideDeleteRequestDialog")
val hideBlockAlertDialog = stringPreferencesKey("hideBlockAlertDialog")
val hideNip17WarningDialog = stringPreferencesKey("hideNip17WarningDialog")
val torSettings = stringPreferencesKey("tor_settings")
val hasDonatedInVersion = stringPreferencesKey("hasDonatedInVersion")
}
private val storeCache = LargeCache<String, DataStore<Preferences>>()
fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences")
private fun getDataStore(npub: String): DataStore<Preferences> =
storeCache.getOrCreate(npub) {
PreferenceDataStoreFactory.create(
produceFile = { file(npub) },
)
}
fun removeAccount(npub: String): Boolean {
val deleted = file(npub).delete()
storeCache.remove(npub)
return deleted
}
}
@@ -1,78 +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.model.preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CoroutineScope
import java.io.File
class AccountSecretsEncryptedStores(
val rootFilesDir: () -> File,
val scope: CoroutineScope,
) {
companion object Companion {
val encryption = KeyStoreEncryption()
val key = stringPreferencesKey("privKey")
val nwc = stringPreferencesKey("nwc")
}
private val storeCache = LargeCache<String, EncryptedDataStore>()
fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets")
private fun getDataStore(npub: String): EncryptedDataStore =
storeCache.getOrCreate(npub) {
EncryptedDataStore(
PreferenceDataStoreFactory.create(
produceFile = { file(npub) },
),
encryption,
scope = scope,
)
}
suspend fun getPrivateKey(npub: String): String? = getDataStore(npub).get(key)
suspend fun savePrivateKey(
npub: String,
value: HexKey,
) {
getDataStore(npub).save(key, value)
}
suspend fun nwc(npub: String): UpdatablePropertyFlow<Nip47WalletConnect.Nip47URI> =
getDataStore(npub).getProperty(
key = nwc,
parser = Nip47WalletConnect.Nip47URI::parser,
serializer = Nip47WalletConnect.Nip47URI::serializer,
)
fun removeAccount(npub: String): Boolean {
val deleted = file(npub).delete()
storeCache.remove(npub)
return deleted
}
}
@@ -1,108 +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.model.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import java.io.IOException
import kotlin.io.encoding.Base64
class EncryptedDataStore(
private val store: DataStore<Preferences>,
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
private val scope: CoroutineScope,
) {
private fun decode(str: String): ByteArray = Base64.decode(str)
private fun encode(bytes: ByteArray): String = Base64.encode(bytes)
private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray()))
private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString()
suspend fun remove(key: Preferences.Key<String>) {
store.edit { prefs ->
prefs.remove(key)
}
}
suspend fun save(
key: Preferences.Key<String>,
value: String,
) {
store.edit { prefs ->
prefs[key] = encrypt(value)
}
}
suspend fun get(key: Preferences.Key<String>): String? =
store.data
.catch { e ->
if (e is IOException) emit(emptyPreferences()) else throw e
}.firstOrNull()
?.get(key)
?.let { decrypt(it) }
fun <T> getProperty(
key: Preferences.Key<String>,
parser: (String) -> T,
serializer: (T) -> String,
): UpdatablePropertyFlow<T> =
UpdatablePropertyFlow(
flow =
store.data
.catch { e ->
if (e is IOException) emit(emptyPreferences()) else throw e
}.map { prefs ->
val value = prefs[key]
if (value != null) {
val decrypted = decrypt(value)
if (decrypted.isNotBlank()) {
parser(decrypted)
} else {
null
}
} else {
null
}
},
update = { newValue ->
if (newValue != null) {
val serialized = serializer(newValue)
if (serialized.isNotBlank()) {
save(key, serialized)
} else {
remove(key)
}
} else {
remove(key)
}
},
scope = scope,
)
}
@@ -1,322 +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.model.preferences
import android.app.UiModeManager
import android.content.Context
import android.os.Build
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Stable
import androidx.core.content.getSystemService
import androidx.core.os.LocaleListCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.model.AccentColorType
import com.vitorpamplona.amethyst.commons.model.BooleanType
import com.vitorpamplona.amethyst.commons.model.ConnectivityType
import com.vitorpamplona.amethyst.commons.model.FeatureSetType
import com.vitorpamplona.amethyst.commons.model.FontFamilyType
import com.vitorpamplona.amethyst.commons.model.FontSizeType
import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.UiSettings
import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
val Context.sharedPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "shared_settings")
@Stable
class UiSharedPreferences(
prefs: UiSettings,
val context: Context,
val scope: CoroutineScope,
) {
// UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences
val value = UiSettingsFlow.build(prefs)
val languageUpdate =
value.preferredLanguage
.onEach { language -> applyLanguage(language) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
val nightModeUpdate =
value.theme
.onEach { theme -> applyNightMode(theme) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
prefs.theme,
)
/**
* Mirrors the in-app theme choice into the system's *per-application* night mode, so the
* launch splash agrees with a theme that is pinned against the phone's own light/dark setting.
*
* The system composites the splash from the manifest theme before the process starts, resolving
* it against this app's configuration -- so day/night resource qualifiers alone can only ever
* follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package
* configuration override* (UiModeManagerService hands it to
* ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when
* it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from
* the next cold start onwards -- the current launch is already painted.
*
* This is deliberately [UiModeManager.setApplicationNightMode] and not
* [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device
* and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a
* silent no-op and was removed. The per-application setter is the documented app-local
* alternative and is not permission-checked; UiModeManagerService only validates the argument.
*
* MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other
* than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets
* the app fall back to the device configuration.
*
* Not deduplicated, deliberately. There is no public getter for the per-application override,
* so the only way to skip a repeat call would be to shadow it in our own store -- a cache of
* state we do not own, which goes stale silently and takes the splash with it. Re-sending the
* value on every launch is self-healing instead, and the platform already no-ops the expensive
* half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is
* unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on
* having actually changed. What remains is one Binder round trip per launch, off the main
* thread. (This is why the deduplication in applyLanguage below does not generalise here: it
* compares against getApplicationLocales(), the authoritative value, not a private copy.)
*
* MainActivity declares `uiMode` in its `configChanges`, so any change that does result is
* delivered to `onConfigurationChanged` rather than recreating the activity.
*/
private suspend fun applyNightMode(theme: ThemeType) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val mode =
when (theme) {
ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES
ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO
ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
}
try {
context.getSystemService<UiModeManager>()?.setApplicationNightMode(mode)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e)
}
}
/**
* Pushes the preferred language into AppCompat, skipping the call when the app already
* runs in that locale.
*
* On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call
* is a blocking Binder round trip into the system's LocaleManagerService, which commits a
* SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning.
* That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since
* this flow starts eagerly, the app paid it on the main thread on every launch, even when
* the locale had not changed since the previous run -- and StrictMode reported it as a
* DiskReadViolation via the Binder call.
*
* [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the
* comparison runs off the main thread. Actual changes still hop to the main thread:
* below API 33 AppCompat applies them in process by reconfiguring (and possibly
* recreating) the active activities.
*/
private suspend fun applyLanguage(language: String?) {
val newLocales = LocaleListCompat.forLanguageTags(language)
if (newLocales == AppCompatDelegate.getApplicationLocales()) return
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(newLocales)
}
}
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach {
save(it, context)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
companion object {
// loads faster when individualized
val UI_THEME = stringPreferencesKey("ui.theme")
val UI_LANGUAGE = stringPreferencesKey("ui.language")
val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images")
val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback")
val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos")
val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview")
val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars")
val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures")
val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector")
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts")
val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts")
val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab")
val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab")
val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab")
val UI_SHOW_PROFILE_BADGES = booleanPreferencesKey("ui.show_profile_badges")
val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations")
val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed")
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect")
val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color")
val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family")
val UI_FONT_SIZE = stringPreferencesKey("ui.font_size")
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
val featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED
UiSettings(
theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM,
preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null },
automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false,
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
featureSet = featureSet,
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
useTrackedBroadcasts =
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true,
showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true,
showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false,
showProfileBadges = preferences[UI_SHOW_PROFILE_BADGES] ?: true,
showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true,
showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true,
showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true,
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
suggestWorkoutsFromHealthConnect =
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE,
fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM,
fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL,
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true,
showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" }
try {
val oldVersion = LocalPreferences.loadSharedSettings()
if (oldVersion != null) {
save(oldVersion, context)
}
oldVersion
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
suspend fun save(
sharedSettings: UiSettings,
context: Context,
) {
try {
context.sharedPreferencesDataStore.edit { preferences ->
preferences[UI_THEME] = sharedSettings.theme.name
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges
preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name
preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name
preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
}
}
}
}
@@ -0,0 +1,186 @@
/*
* 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.preferences
import android.app.UiModeManager
import android.content.Context
import android.os.Build
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Stable
import androidx.core.content.getSystemService
import androidx.core.os.LocaleListCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.UiSettings
import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow
import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* The Android half of the UI settings: the flows the app observes, and the two
* platform side effects that a theme or language change has to perform.
*
* Persistence is [UiSettingsStore] in `commons`, which every front end shares.
* What stays here is the part that has no desktop equivalent — the per-app night
* mode override that the launch splash reads, and AppCompat's locale list.
*/
@Stable
class UiSharedPreferences(
prefs: UiSettings,
dataStore: DataStore<Preferences>,
val context: Context,
val scope: CoroutineScope,
) {
private val store = UiSettingsStore(dataStore)
// UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences
val value = UiSettingsFlow.build(prefs)
val languageUpdate =
value.preferredLanguage
.onEach { language -> applyLanguage(language) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
val nightModeUpdate =
value.theme
.onEach { theme -> applyNightMode(theme) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
prefs.theme,
)
/**
* Mirrors the in-app theme choice into the system's *per-application* night mode, so the
* launch splash agrees with a theme that is pinned against the phone's own light/dark setting.
*
* The system composites the splash from the manifest theme before the process starts, resolving
* it against this app's configuration -- so day/night resource qualifiers alone can only ever
* follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package
* configuration override* (UiModeManagerService hands it to
* ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when
* it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from
* the next cold start onwards -- the current launch is already painted.
*
* This is deliberately [UiModeManager.setApplicationNightMode] and not
* [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device
* and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a
* silent no-op and was removed. The per-application setter is the documented app-local
* alternative and is not permission-checked; UiModeManagerService only validates the argument.
*
* MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other
* than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets
* the app fall back to the device configuration.
*
* Not deduplicated, deliberately. There is no public getter for the per-application override,
* so the only way to skip a repeat call would be to shadow it in our own store -- a cache of
* state we do not own, which goes stale silently and takes the splash with it. Re-sending the
* value on every launch is self-healing instead, and the platform already no-ops the expensive
* half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is
* unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on
* having actually changed. What remains is one Binder round trip per launch, off the main
* thread. (This is why the deduplication in applyLanguage below does not generalise here: it
* compares against getApplicationLocales(), the authoritative value, not a private copy.)
*
* MainActivity declares `uiMode` in its `configChanges`, so any change that does result is
* delivered to `onConfigurationChanged` rather than recreating the activity.
*/
private suspend fun applyNightMode(theme: ThemeType) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val mode =
when (theme) {
ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES
ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO
ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
}
try {
context.getSystemService<UiModeManager>()?.setApplicationNightMode(mode)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e)
}
}
/**
* Pushes the preferred language into AppCompat, skipping the call when the app already
* runs in that locale.
*
* On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call
* is a blocking Binder round trip into the system's LocaleManagerService, which commits a
* SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning.
* That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since
* this flow starts eagerly, the app paid it on the main thread on every launch, even when
* the locale had not changed since the previous run -- and StrictMode reported it as a
* DiskReadViolation via the Binder call.
*
* [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the
* comparison runs off the main thread. Actual changes still hop to the main thread:
* below API 33 AppCompat applies them in process by reconfiguring (and possibly
* recreating) the active activities.
*/
private suspend fun applyLanguage(language: String?) {
val newLocales = LocaleListCompat.forLanguageTags(language)
if (newLocales == AppCompatDelegate.getApplicationLocales()) return
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(newLocales)
}
}
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach {
store.save(it)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
companion object {
suspend fun uiPreferences(dataStore: DataStore<Preferences>): UiSettings? = UiSettingsStore(dataStore).load()
}
}
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.model.privacyOptions
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.http.ProxiedSocketFactory
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.torState
import com.vitorpamplona.amethyst.commons.tor.TorRelayState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -20,19 +20,15 @@
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionStore
import kotlinx.coroutines.flow.first
private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "napplet_permissions")
/**
* Persists the standing napplet grants ([GrantState.ALLOW_ALWAYS] / [GrantState.DENY]) in a
* dedicated DataStore. Keyed by `"<coordinate>\u0000<capability>"` so a coordinate's grants can
@@ -43,9 +39,6 @@ class DataStoreNappletPermissionStore(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletPermissionStore {
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
/**
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
* every read and write to that account's namespace with no rebuild — a grant made by one account
@@ -20,17 +20,13 @@
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.napplet.NappletStorage
import kotlinx.coroutines.flow.first
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
/**
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
@@ -44,9 +40,6 @@ class DataStoreNappletStorage(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletStorage {
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
override suspend fun get(
coordinate: String,
key: String,
@@ -42,9 +42,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
@@ -90,7 +87,7 @@ class NappletBrokerService : Service() {
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
private val storage by lazy { DataStoreNappletStorage(Amethyst.instance.appStores.getDataStore("napplet_storage"), Amethyst.instance.nappletAccountScope) }
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
@@ -203,8 +200,9 @@ class NappletBrokerService : Service() {
if (msg.what == NappletIpc.MSG_RECORD_HISTORY) {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true
BrowserHistoryRegistry.init(applicationContext)
BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
val history = Amethyst.instance.browserHistory
history.init()
history.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
return true
}
@@ -213,8 +211,9 @@ class NappletBrokerService : Service() {
val data = msg.data ?: return true
val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true
val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true
BrowserIconRegistry.init(applicationContext)
BrowserIconRegistry.record(host, bytes)
val icons = Amethyst.instance.browserIcons
icons.init()
icons.record(host, bytes)
return true
}
@@ -223,12 +222,13 @@ class NappletBrokerService : Service() {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url }
FavoriteAppsRegistry.init(applicationContext)
val favorites = Amethyst.instance.favoriteApps
favorites.init()
val id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
if (favorites.isFavorite(id)) {
favorites.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
}
return true
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
@@ -62,7 +63,6 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_consent_upload
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
@@ -95,7 +95,7 @@ class NappletConsentSummary(
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
host to Amethyst.instance.browserIcons.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.Amethyst
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -32,7 +34,15 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network")
/**
* The per-napplet routing file, on the app-wide holder rather than a `Context` delegate.
* Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox.
*/
private val nappletNetworkDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("napplet_network")
/**
* Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default)
@@ -69,7 +79,7 @@ object NappletNetworkRegistry {
appContext = ctx
hydration =
scope.launch {
ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
// putIfAbsent: never clobber a choice made in this session before hydration finished.
modes.putIfAbsent(key.name, value != OPEN_WEB)
}
@@ -95,9 +105,9 @@ object NappletNetworkRegistry {
useTor: Boolean,
) {
modes[coordinate] = useTor
val ctx = appContext ?: return
appContext ?: return
scope.launch {
ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
}
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -39,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_fo
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -83,7 +83,7 @@ suspend fun buildSignerConsentInfo(
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
host to Amethyst.instance.browserIcons.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
@@ -183,7 +183,7 @@ suspend fun buildConnectInfo(
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
host to Amethyst.instance.browserIcons.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.Amethyst
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -33,7 +35,16 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "weburl_network")
/**
* The per-site routing file, on the app-wide holder rather than a `Context`
* delegate. Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox, and this registry is only touched from the browser
* chrome that runs in the main process.
*/
private val webUrlNetworkDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("weburl_network")
/**
* Per-web-client network-routing preference: whether a favorited URL / browsed site routes through
@@ -67,7 +78,7 @@ object WebAppNetworkRegistry {
appContext = ctx
hydration =
scope.launch {
ctx.webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) ->
webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) ->
// putIfAbsent: never clobber a choice made in this session before hydration finished.
modes.putIfAbsent(key.name, value != OPEN_WEB)
}
@@ -98,9 +109,9 @@ object WebAppNetworkRegistry {
) {
val host = hostKeyOf(url)
modes[host] = useTor
val ctx = appContext ?: return
appContext ?: return
scope.launch {
ctx.webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB }
webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB }
}
}
}
@@ -1,66 +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.service.calendar
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
/**
* Device-wide preferences for the calendar reminder worker.
*
* Stored at device scope (rather than per-account) because the worker that consults them runs
* globally — multiplexing per-account preferences would require account-context plumbing into
* WorkManager that the rest of the app doesn't have. A user who flips between two accounts on
* the same device shares the same lead-time and enabled-state. Per-account preferences could be
* a follow-up if anyone asks.
*/
class CalendarReminderPrefs(
context: Context,
) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
fun setEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_ENABLED, enabled) }
}
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
fun setLeadMinutes(minutes: Int) {
prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) }
}
companion object {
const val DEFAULT_LEAD_MINUTES = 15
const val DEFAULT_ENABLED = true
// Choices presented in the settings UI. Anchored to the worker cadence — lead times
// smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the
// UX shape supports without an extra "hours" picker.
val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60)
private const val PREF_NAME = "amethyst_calendar_reminder_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_LEAD_MINUTES = "lead_minutes"
}
}
@@ -1,84 +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.service.calendar
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
/**
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
* the worker that consults it runs in the app process and the dataset is tiny (≤ a few hundred
* IDs at most). Without persistence, every worker run after a restart would re-notify for the
* same upcoming event until it started, since LocalCache has no memory of past reminders.
*
* Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only
* presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just
* fired so the store doesn't grow unbounded over time.
*/
class CalendarReminderStore(
context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
/**
* Returns true when we've previously notified for this exact event-start pairing. If the
* author updates the appointment to a new start time, the stored value won't match and
* we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved
* meeting shouldn't be silently skipped.
*/
fun wasNotified(
eventId: String,
eventStartSeconds: Long,
): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds
fun markNotified(
eventId: String,
eventStartSeconds: Long,
) {
prefs.edit { putLong(keyFor(eventId), eventStartSeconds) }
}
/**
* Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called
* after each worker run so the store stays bounded — events that have long since ended
* can't fire a second reminder, so their entries are dead weight.
*/
fun forgetBefore(cutoffSeconds: Long) {
val editor = prefs.edit()
var changed = false
prefs.all.forEach { (key, value) ->
if (value is Long && value < cutoffSeconds) {
editor.remove(key)
changed = true
}
}
if (changed) editor.apply()
}
companion object {
private const val PREF_NAME = "amethyst_calendar_reminders"
private const val KEY_PREFIX = "notified:"
private fun keyFor(eventId: String) = KEY_PREFIX + eventId
}
}
@@ -0,0 +1,82 @@
/*
* 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.calendar
import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderLogStore
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettingsStore
import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
/**
* Android wiring for the two calendar-reminder stores.
*
* The store classes live in commons; only the file location and the one-off
* lift out of the legacy SharedPreferences are Android's business.
*
* Both files sit on `AppPreferenceStores` rather than a `Context` delegate.
* The names below are the delegate's names, and the holder reproduces the path
* it resolved to, so nothing migrates. The migrations move with them: DataStore
* runs a file's migrations once, when that file is first opened, so they have
* to be attached to the file by the holder rather than by whoever opens it.
*/
private const val LEGACY_SETTINGS_FILE = "amethyst_calendar_reminder_prefs"
private const val LEGACY_LOG_FILE = "amethyst_calendar_reminders"
/** Store (and file) names, as [Amethyst.appStores] keys them. */
const val CALENDAR_REMINDER_SETTINGS_STORE = "calendar_reminder_settings"
const val CALENDAR_REMINDER_LOG_STORE = "calendar_reminder_log"
/** The one-off copy of the reminder settings out of the legacy prefs file. */
fun calendarReminderSettingsMigrations(context: Context): List<DataMigration<Preferences>> =
listOf(
CopyOnceMigration("migrated.calendarReminderSettings") { out ->
val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE)
if (legacy.contains("enabled")) {
out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED)
}
if (legacy.contains("lead_minutes")) {
out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES)
}
},
)
/** The one-off copy of the fired-reminder log out of the legacy prefs file. */
fun calendarReminderLogMigrations(context: Context): List<DataMigration<Preferences>> =
listOf(
CopyOnceMigration("migrated.calendarReminderLog") { out ->
val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE)
// Values are the event-start times the reminders fired for; anything
// else in the file is not ours and is left behind.
legacy.all.forEach { (key, value) ->
if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value
}
},
)
fun calendarReminderSettings() = CalendarReminderSettingsStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_SETTINGS_STORE))
fun calendarReminderLog() = CalendarReminderLogStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_LOG_STORE))
@@ -48,7 +48,7 @@ import java.util.concurrent.TimeUnit
* to as ACCEPTED.
*
* The work is bounded — scans LocalCache (which is bounded by the relay subscription) and
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
* consults [CalendarReminderLogStore] to skip events that have already been notified for. Run as
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
*
@@ -65,8 +65,8 @@ class CalendarReminderWorker(
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) }
val prefs = CalendarReminderPrefs(applicationContext)
if (!prefs.isEnabled()) {
val settings = calendarReminderSettings().load()
if (!settings.enabled) {
Log.d(TAG) { "Reminders disabled; ending periodic chain." }
// The settings toggle re-schedules on enable; no reason to keep
// waking the process while the feature is off.
@@ -74,8 +74,8 @@ class CalendarReminderWorker(
return Result.success()
}
val now = TimeUtils.now()
val windowEnd = now + prefs.leadMinutes() * 60L
val store = CalendarReminderStore(applicationContext)
val windowEnd = now + settings.leadMinutes * 60L
val store = calendarReminderLog()
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
@@ -83,7 +83,7 @@ class CalendarReminderWorker(
// silently break notifications for account switching during the lead window.
val acceptedRsvps = acceptedRsvpsInCache()
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${settings.leadMinutes}m)" }
acceptedRsvps.forEach { rsvp ->
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.pow
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -33,14 +34,14 @@ import com.vitorpamplona.quartz.utils.Log
import java.util.UUID
/**
* Re-enqueues the mining jobs checkpointed by [PowJobStore] when an account
* Re-enqueues the mining jobs checkpointed by [PoWJobStore] when an account
* logs in, replacing the lost in-memory continuation with the headless replay
* described by each record. Restore is idempotent: the queue dedupes by job
* id, so a login flow that emits twice cannot double-mine.
*/
class PowJobRestorer(
private val queue: PoWPublishQueue,
private val store: PowJobStore,
private val store: PoWJobStore,
private val scheduledPostStore: ScheduledPostStore,
) {
suspend fun restore(account: Account) {
@@ -45,7 +45,7 @@ import kotlinx.coroutines.launch
*
* Uses the Android 14+ `shortService` type — no special permission, but a
* hard ~3 minute budget. On `onTimeout` the service exits cleanly; every
* persistable job is already checkpointed by [PowJobStore], so anything still
* persistable job is already checkpointed by [com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore], so anything still
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
@@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.resources.lightning_create_and_add_inv
import com.vitorpamplona.amethyst.commons.resources.lightning_invoice
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
@@ -98,7 +99,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
@@ -52,6 +52,12 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBuzzInviteLink
import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink
import com.vitorpamplona.amethyst.commons.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.commons.ui.components.ClickablePhone
import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink
import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayUrl
import com.vitorpamplona.amethyst.commons.ui.components.NowhereLinkCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions
@@ -1,60 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import kotlinx.coroutines.CancellationException
@Composable
fun ClickableEmail(email: String) {
val stripped = email.replaceFirst("mailto:", "")
val context = LocalContext.current
ClickableTextPrimary(
text = stripped,
onClick = { runCatching { context.sendMail(stripped) } },
)
}
fun Context.sendMail(
to: String,
subject: String? = null,
) {
try {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "vnd.android.cursor.item/email" // or "message/rfc822"
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
if (subject != null) {
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
}
startActivity(intent)
} catch (e: ActivityNotFoundException) {
// TODO: Handle case where no email app is available
} catch (t: Throwable) {
if (t is CancellationException) throw t
// TODO: Handle potential other type of exceptions
}
}
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_join
import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_subtitle
import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_invite_onl
import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_private
import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count
import com.vitorpamplona.amethyst.commons.resources.relay_group_open
import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
@@ -102,6 +102,12 @@ import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.VideoSegment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBuzzInviteLink
import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink
import com.vitorpamplona.amethyst.commons.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.commons.ui.components.ClickablePhone
import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink
import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayUrl
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.components.NowhereLinkCard
@@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -21,52 +21,11 @@
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@Composable
fun WatchScrollToTop(
feedContentState: FeedContentState,
listState: LazyListState,
) {
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && feedContentState.scrollToTopPending) {
listState.scrollToItem(index = 0)
feedContentState.sentToTop()
}
}
}
@Composable
fun WatchScrollToTop(
feedContentState: FeedContentState,
listState: LazyGridState,
) {
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && feedContentState.scrollToTopPending) {
listState.scrollToItem(index = 0)
feedContentState.sentToTop()
}
}
}
@Composable
fun WatchScrollToTop(
@@ -82,166 +41,3 @@ fun WatchScrollToTop(
}
}
}
@Composable
fun WatchScrollToTop(
videoFeedContentState: FeedContentState,
pagerState: PagerState,
) {
val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) {
pagerState.scrollToPage(page = 0)
videoFeedContentState.sentToTop()
}
}
}
/**
* Keeps the user pinned to index 0 when new items prepend to a feed, but
* only if they were already at the very top right before the update.
*
* Why this is non-trivial: every feed uses stable `key = item.idHex` in
* its lazy list, which makes Compose preserve the visual anchor across
* data changes. When N items prepend, the user's previously-visible
* top item is still on screen but its index is now N — so
* `firstVisibleItemIndex` shifts from 0 to N without any user gesture.
* A naive `if (firstVisibleItemIndex <= 1) scrollToItem(0)` check inside
* `LaunchedEffect(items.firstOrNull())` therefore fails as soon as more
* than one item arrives in the same batch.
*
* The trick: track "was at top" continuously via [snapshotFlow], but
* only flip it from true → false when [LazyListState.isScrollInProgress]
* is true (i.e. the user is actively scrolling). Compose's keyed-item
* shift after a data update does not set that flag — only real touch
* gestures and `animate*` calls do — so data-driven index shifts can
* never poison the cached value. When [firstItemKey] changes (head of
* the list moved), if the cached value is still true, snap back to 0
* with an instant (non-animated) scroll so the prepend appears as
* in-place growth rather than a visible jump-then-scroll.
*
* Most callers should not invoke this directly: [SaveableFeedContentState],
* [SaveableGridFeedContentState], and the analogous wrappers in
* `ui/screen/FeedView.kt` already apply auto-stick to every feed they
* own. Invoke the explicit overload only when the listState is
* constructed outside one of those wrappers, or when the key that
* should trigger the snap is not the default `items.list[0].idHex`
* (e.g. notifications, chats, or feeds keyed on something other than a
* Note's hex id).
*/
@Composable
fun StickToTopOnPrepend(
listState: LazyListState,
firstItemKey: Any?,
) {
stickToTopOnPrepend(
stateKey = listState,
firstItemKey = firstItemKey,
initialAtTop = {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
},
sampler = {
snapshotFlow {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
},
isScrollInProgress = { listState.isScrollInProgress },
firstVisibleItemIndex = { listState.firstVisibleItemIndex },
scrollToTop = { listState.scrollToItem(0) },
)
}
@Composable
fun StickToTopOnPrepend(
gridState: LazyGridState,
firstItemKey: Any?,
) {
stickToTopOnPrepend(
stateKey = gridState,
firstItemKey = firstItemKey,
initialAtTop = {
gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0
},
sampler = {
snapshotFlow {
gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0
}
},
isScrollInProgress = { gridState.isScrollInProgress },
firstVisibleItemIndex = { gridState.firstVisibleItemIndex },
scrollToTop = { gridState.scrollToItem(0) },
)
}
/**
* Auto-stick wired straight to a [FeedContentState]: derives the head
* key from `feedContent → Loaded.feed → list.firstOrNull()?.idHex` so
* callers don't have to collect the inner feed flow themselves. Used
* by the Saveable* wrappers; suitable for any Note-keyed feed.
*/
@Composable
fun StickToTopOnPrepend(
feedContentState: FeedContentState,
listState: LazyListState,
) {
StickToTopOnPrepend(listState, rememberFirstItemIdHex(feedContentState))
}
@Composable
fun StickToTopOnPrepend(
feedContentState: FeedContentState,
gridState: LazyGridState,
) {
StickToTopOnPrepend(gridState, rememberFirstItemIdHex(feedContentState))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? {
val flow =
remember(feedContentState) {
feedContentState.feedContent.flatMapLatest { state ->
when (state) {
is FeedState.Loaded -> state.feed.map { it.list.firstOrNull()?.idHex }
else -> flowOf(null)
}
}
}
val key by flow.collectAsStateWithLifecycle(initialValue = null)
return key
}
@Composable
private fun stickToTopOnPrepend(
stateKey: Any,
firstItemKey: Any?,
initialAtTop: () -> Boolean,
sampler: () -> Flow<Boolean>,
isScrollInProgress: () -> Boolean,
firstVisibleItemIndex: () -> Int,
scrollToTop: suspend () -> Unit,
) {
// Plain holder instead of mutableStateOf — we only read this inside
// effects, never in composition, so we don't need snapshot tracking.
// Seed from the actual restored scroll position: when the user returns
// to a feed via rememberForeverLazyListState, the saved offset is
// already in place, and a hardcoded `true` would mis-snap them to 0.
val wasAtTop = remember(stateKey) { booleanArrayOf(initialAtTop()) }
LaunchedEffect(stateKey) {
sampler().collect { atTop ->
if (atTop) {
wasAtTop[0] = true
} else if (isScrollInProgress()) {
wasAtTop[0] = false
}
}
}
LaunchedEffect(firstItemKey) {
if (firstItemKey != null && wasAtTop[0] && firstVisibleItemIndex() > 0) {
scrollToTop()
}
}
}
@@ -20,16 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
@@ -38,20 +32,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_banner
import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble
import com.vitorpamplona.quartz.utils.TimeUtils
private const val PREVIEW_AUTHOR = "This is my author"
@@ -136,43 +129,3 @@ fun ChannelNamePreview() {
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatHeaderLayout(
channelPicture: @Composable () -> Unit,
firstRow: @Composable RowScope.() -> Unit,
secondRow: @Composable RowScope.() -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Size55Modifier) { channelPicture() }
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
firstRow()
}
Spacer(modifier = Height4dpModifier)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
secondRow()
}
}
}
}
@@ -21,18 +21,11 @@
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
@@ -43,10 +36,10 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_banner
import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.commons.ui.note.LikeIcon
import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
@@ -104,47 +97,3 @@ fun LeftPictureLayoutPreviewCard() {
onBottomRow = { Text("This is my Moderator List") },
)
}
@Composable
fun LeftPictureLayout(
onImage: @Composable () -> Unit,
onTitleRow: @Composable RowScope.() -> Unit,
onDescription: @Composable () -> Unit,
onBottomRow: @Composable RowScope.() -> Unit,
imageFraction: Float = 0.25f,
) {
Row(Modifier.aspectRatio(ratio = 1 / imageFraction)) {
Column(
modifier = Modifier.fillMaxWidth(imageFraction).aspectRatio(ratio = 1f),
) {
onImage()
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
onTitleRow()
}
Row(
modifier = Modifier.fillMaxWidth().weight(1f),
) {
onDescription()
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
onBottomRow()
}
}
}
}
@@ -0,0 +1,132 @@
/*
* 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.layouts.listItem
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_banner
import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.quartz.utils.TimeUtils
private const val PREVIEW_AUTHOR = "This is my author"
private const val PREVIEW_MESSAGE = "This is a message from this person"
@Composable
@Preview
fun ChannelNamePreview() {
ThemeComparisonColumn {
Column {
ChatHeaderLayout(
channelPicture = {
Image(
painter = painterRes(R.drawable.github, 1),
contentDescription = stringRes(id = Res.string.profile_banner),
contentScale = ContentScale.FillWidth,
)
},
firstRow = {
Text(PREVIEW_AUTHOR, Modifier.weight(1f))
TimeAgo(TimeUtils.now())
},
secondRow = {
Text(PREVIEW_MESSAGE, Modifier.weight(1f))
NewItemsBubble()
},
onClick = {},
)
HorizontalDivider(thickness = DividerThickness)
SlimListItem(
headlineContent = {
Row(verticalAlignment = CenterVertically) {
Text(PREVIEW_AUTHOR, Modifier.weight(1f))
TimeAgo(TimeUtils.now())
}
},
supportingContent = {
Row(verticalAlignment = CenterVertically) {
Text(PREVIEW_MESSAGE, Modifier.weight(1f))
Spacer(modifier = Height4dpModifier)
NewItemsBubble()
}
},
leadingContent = {
Image(
painter = painterRes(R.drawable.github, 2),
contentDescription = stringRes(id = Res.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Size55Modifier,
)
},
)
HorizontalDivider(thickness = DividerThickness)
ListItem(
headlineContent = {
Row(verticalAlignment = CenterVertically) {
Text(PREVIEW_AUTHOR, Modifier.weight(1f))
TimeAgo(TimeUtils.now())
}
},
supportingContent = {
Row(verticalAlignment = CenterVertically) {
Text(PREVIEW_MESSAGE, Modifier.weight(1f))
Spacer(modifier = Height4dpModifier)
NewItemsBubble()
}
},
leadingContent = {
Image(
painter = painterRes(R.drawable.github, 2),
contentDescription = stringRes(id = Res.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Size55Modifier,
)
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
@@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
@@ -59,8 +60,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size27Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.onSurface65
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -112,7 +111,8 @@ fun AppBottomBar(
// Favorite entries in the unified list resolve to a live favorite for their icon/label and to an
// embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet
// surface), so such a tab swaps in place rather than launching an activity from the bottom row.
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favorites by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isKeyboardState by keyboardAsState()
if (isKeyboardState == KeyboardState.Closed) {
@@ -131,9 +131,10 @@ internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? =
when (fav) {
is FavoriteApp.WebApp -> {
// Captured favicons, keyed so the icon appears once the site's capture lands.
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
val iconKeys by Amethyst.instance.browserIcons.keys
.collectAsStateWithLifecycle()
remember(fav, iconKeys) {
OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
OmniboxInput.hostOf(fav.url)?.let(Amethyst.instance.browserIcons::iconModelFor)
}
}
@@ -36,9 +36,9 @@ import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
@@ -58,7 +58,8 @@ fun AppNavigationRail(
) {
val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
.collectAsStateWithLifecycle()
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favorites by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
val reselectCoordinator = LocalTabReselectCoordinator.current
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
@@ -25,8 +25,8 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility
import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.drawer_section_create
import com.vitorpamplona.amethyst.commons.resources.drawer_section_feeds
@@ -69,38 +69,6 @@ data class DrawerSection(
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
* compile error and nothing to fail a test.
*/
enum class DrawerSectionId {
YOU,
NAVIGATE,
FEEDS,
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
CREATE,
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
SYSTEM,
}
private val DrawerSectionIdsByName = DrawerSectionId.entries.associateBy { it.name }
/**
* Parses the persisted names of the headings the user has collapsed, silently dropping any this
* build doesn't know. Mirrors [com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames]:
* names rather than ordinals, so reordering this enum renames nothing by accident, and a value left
* by a build with one more section costs that heading rather than the whole read.
*
* The stored set holds the **collapsed** headings rather than the expanded ones, for the same reason
* [DrawerItemVisibility] stores the hidden rows: a heading nobody has ever collapsed simply isn't in
* the set, so a section added in a later release opens expanded for everyone with no migration.
*/
fun drawerSectionIdsFromNames(names: Collection<String>): Set<DrawerSectionId> = names.mapNotNullTo(mutableSetOf()) { DrawerSectionIdsByName[it] }
/**
* The inverse of [drawerSectionIdsFromNames]. Unlike the NavBarItem codec this returns an unsorted
* Set rather than a sorted List: the destination is a DataStore string set, whose equality is
* already order-independent, so there is no serialized form to keep deterministic.
*/
fun Set<DrawerSectionId>.toNames(): Set<String> = mapTo(mutableSetOf()) { it.name }
private val DrawerNavigateItems: List<NavBarItem> =
listOf(
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.resources.follow
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_follow
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_unfollow
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ShowUserButton
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.commons.resources.discover_follows
import com.vitorpamplona.amethyst.commons.resources.follow_sets
import com.vitorpamplona.amethyst.commons.resources.num_selected
import com.vitorpamplona.amethyst.commons.resources.select_all
import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24dp
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.user_suggestion_in_this_chat
import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.commons.ui.theme.nip05
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.zap_split_explainer
import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user
import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user_placeholder
import com.vitorpamplona.amethyst.commons.resources.zap_split_title
import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ZapSplitIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -55,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -35,14 +35,14 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -35,11 +35,11 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.ComponentCallbacks2
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
@@ -96,11 +96,13 @@ import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception
import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception_description
import com.vitorpamplona.amethyst.commons.resources.user_x_does_not_have_a_lightning_address_setup_to_receive_sats
import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.commons.service.http.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.state.UiSettingsState
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.components.toasts.ToastManager
@@ -115,7 +117,6 @@ import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
@@ -141,7 +142,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -43,17 +43,57 @@ 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.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.backup_card_accept
import com.vitorpamplona.amethyst.commons.resources.backup_card_and_added
import com.vitorpamplona.amethyst.commons.resources.backup_card_and_blocked
import com.vitorpamplona.amethyst.commons.resources.backup_card_and_followed
import com.vitorpamplona.amethyst.commons.resources.backup_card_and_joined
import com.vitorpamplona.amethyst.commons.resources.backup_card_and_muted
import com.vitorpamplona.amethyst.commons.resources.backup_card_app_settings
import com.vitorpamplona.amethyst.commons.resources.backup_card_concord_emptied
import com.vitorpamplona.amethyst.commons.resources.backup_card_concord_rewritten
import com.vitorpamplona.amethyst.commons.resources.backup_card_dm_relays_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_feeds_removed
import com.vitorpamplona.amethyst.commons.resources.backup_card_follows_dropped
import com.vitorpamplona.amethyst.commons.resources.backup_card_hashtags_unfollowed
import com.vitorpamplona.amethyst.commons.resources.backup_card_indexer_relays_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_key_package_relays_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_chats
import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_communities
import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_groups
import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_rooms
import com.vitorpamplona.amethyst.commons.resources.backup_card_mutes_visible
import com.vitorpamplona.amethyst.commons.resources.backup_card_nip65_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_key_removed
import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_key_replaced
import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_mints_removed
import com.vitorpamplona.amethyst.commons.resources.backup_card_offers_removed
import com.vitorpamplona.amethyst.commons.resources.backup_card_payment_targets_removed
import com.vitorpamplona.amethyst.commons.resources.backup_card_places_unfollowed
import com.vitorpamplona.amethyst.commons.resources.backup_card_private_outbox_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_profile_lost_fields
import com.vitorpamplona.amethyst.commons.resources.backup_card_relay_feeds_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_search_relays_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_trust_services_changed
import com.vitorpamplona.amethyst.commons.resources.backup_card_trusted_lost
import com.vitorpamplona.amethyst.commons.resources.backup_card_unblocked
import com.vitorpamplona.amethyst.commons.resources.backup_card_wallet_emptied
import com.vitorpamplona.amethyst.commons.resources.backup_card_wallet_rewritten
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_added
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_changed
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_removed
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_title
import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
@@ -79,6 +119,9 @@ 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
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
/**
* Cards at the top of Home's feed, one per open [ReplaceableBackupConflict]: the user
@@ -125,34 +168,34 @@ private fun headlineOf(
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 ContactListDiff -> plural(Res.plurals.backup_card_follows_dropped, diff.follows.removed.size)
is MuteListDiff -> plural(Res.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size)
is MetadataDiff -> plural(Res.plurals.backup_card_profile_lost_fields, counts.removed)
is AdvertisedRelayListDiff -> plural(Res.plurals.backup_card_nip65_lost, diff.relays.removed.size)
is RelayListDiff -> relayListHeadline(conflict.eventType, diff.relays.removed.size)
is PublicChatListDiff -> 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 InterestListDiff -> 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 PublicChatListDiff -> plural(Res.plurals.backup_card_leave_chats, diff.channels.removed.size)
is CommunityListDiff -> plural(Res.plurals.backup_card_leave_communities, diff.communities.removed.size)
is EphemeralChatListDiff -> plural(Res.plurals.backup_card_leave_rooms, diff.rooms.removed.size)
is SimpleGroupListDiff -> plural(Res.plurals.backup_card_leave_groups, diff.groups.removed.size)
is FavoriteAlgoFeedsListDiff -> plural(Res.plurals.backup_card_feeds_removed, diff.feeds.removed.size)
is InterestListDiff -> plural(Res.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size)
is GeohashListDiff -> plural(Res.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size)
is TrustProviderListDiff -> plural(Res.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)
diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(Res.string.backup_card_nutzap_key_removed)
diff.p2pkPubkey != null -> stringRes(Res.string.backup_card_nutzap_key_replaced)
else -> plural(Res.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)
is PaymentTargetsDiff -> plural(Res.plurals.backup_card_payment_targets_removed, diff.targets.removed.size)
is Bolt12OfferListDiff -> plural(Res.plurals.backup_card_offers_removed, diff.offers.removed.size)
is CashuWalletDiff -> encryptedHeadline(Res.string.backup_card_wallet_emptied, Res.string.backup_card_wallet_rewritten, diff.wallet)
is ConcordCommunityListDiff -> encryptedHeadline(Res.string.backup_card_concord_emptied, Res.string.backup_card_concord_rewritten, diff.communities)
is AppSpecificDataDiff -> stringRes(Res.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)))
if (specific.isNullOrEmpty()) return stringRes(Res.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.
@@ -160,11 +203,11 @@ private fun headlineOf(
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 PublicChatListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> R.plurals.backup_card_and_joined
conflict.eventType == BackupEventType.BLOCKED_RELAYS -> R.plurals.backup_card_and_blocked
else -> R.plurals.backup_card_and_added
diff is ContactListDiff -> Res.plurals.backup_card_and_followed
diff is MuteListDiff -> Res.plurals.backup_card_and_muted
diff is PublicChatListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> Res.plurals.backup_card_and_joined
conflict.eventType == BackupEventType.BLOCKED_RELAYS -> Res.plurals.backup_card_and_blocked
else -> Res.plurals.backup_card_and_added
}
return pluralStringResource(tail, added, specific, added)
}
@@ -208,7 +251,7 @@ private fun relayListHasHeadline(type: BackupEventType): Boolean =
/** The count's plural, or "" when nothing of that kind was removed. */
@Composable
private fun plural(
id: Int,
id: PluralStringResource,
count: Int,
): String = if (count > 0) pluralStringResource(id, count, count) else ""
@@ -218,21 +261,21 @@ private fun relayListHeadline(
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)
BackupEventType.DM_RELAYS -> plural(Res.plurals.backup_card_dm_relays_lost, removed)
BackupEventType.KEY_PACKAGE_RELAYS -> plural(Res.plurals.backup_card_key_package_relays_lost, removed)
BackupEventType.SEARCH_RELAYS -> plural(Res.plurals.backup_card_search_relays_lost, removed)
BackupEventType.INDEXER_RELAYS -> plural(Res.plurals.backup_card_indexer_relays_lost, removed)
BackupEventType.RELAY_FEEDS -> plural(Res.plurals.backup_card_relay_feeds_lost, removed)
BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(Res.plurals.backup_card_private_outbox_lost, removed)
BackupEventType.TRUSTED_RELAYS -> plural(Res.plurals.backup_card_trusted_lost, removed)
BackupEventType.BLOCKED_RELAYS -> plural(Res.plurals.backup_card_unblocked, removed)
else -> ""
}
@Composable
private fun encryptedHeadline(
emptied: Int,
rewritten: Int,
emptied: StringResource,
rewritten: StringResource,
change: ContentChange,
): String = if (change == ContentChange.CLEARED) stringRes(emptied) else stringRes(rewritten)
@@ -262,7 +305,7 @@ private fun LeadConflictCard(
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)),
stringRes(Res.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
@@ -296,9 +339,9 @@ private fun LeadConflictCard(
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)
if (counts.removed > 0) StatusTag(stringRes(Res.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed)
if (counts.added > 0) StatusTag(stringRes(Res.string.backup_conflict_count_added, counts.added.toString()), tones.added)
if (counts.changed > 0) StatusTag(stringRes(Res.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed)
}
}
@@ -342,6 +385,6 @@ private fun AcceptButton(onAccept: () -> Unit) {
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Text(stringRes(R.string.backup_card_accept), fontWeight = FontWeight.SemiBold)
Text(stringRes(Res.string.backup_card_accept), fontWeight = FontWeight.SemiBold)
}
}
@@ -62,7 +62,6 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
@@ -70,6 +69,67 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.backup_entry_hashtag
import com.vitorpamplona.amethyst.commons.resources.backup_entry_mint
import com.vitorpamplona.amethyst.commons.resources.backup_entry_nutzap_key
import com.vitorpamplona.amethyst.commons.resources.backup_entry_person
import com.vitorpamplona.amethyst.commons.resources.backup_entry_profile_field
import com.vitorpamplona.amethyst.commons.resources.backup_entry_thread
import com.vitorpamplona.amethyst.commons.resources.backup_entry_word
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_about
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_banner
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_birthday
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_bot
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_clink_offer
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_display_name
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud06
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud16
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_name
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_nip05
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_picture
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_pronouns
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_website
import com.vitorpamplona.amethyst.commons.resources.backup_review_demoted_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_count
import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_gone
import com.vitorpamplona.amethyst.commons.resources.backup_review_inbox
import com.vitorpamplona.amethyst.commons.resources.backup_review_inbox_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_review_joined
import com.vitorpamplona.amethyst.commons.resources.backup_review_kept_count
import com.vitorpamplona.amethyst.commons.resources.backup_review_key_removed
import com.vitorpamplona.amethyst.commons.resources.backup_review_key_replaced
import com.vitorpamplona.amethyst.commons.resources.backup_review_key_warning
import com.vitorpamplona.amethyst.commons.resources.backup_review_left
import com.vitorpamplona.amethyst.commons.resources.backup_review_left_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_names_and_more
import com.vitorpamplona.amethyst.commons.resources.backup_review_new
import com.vitorpamplona.amethyst.commons.resources.backup_review_new_count
import com.vitorpamplona.amethyst.commons.resources.backup_review_new_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_newly_muted
import com.vitorpamplona.amethyst.commons.resources.backup_review_not_set
import com.vitorpamplona.amethyst.commons.resources.backup_review_nothing_here
import com.vitorpamplona.amethyst.commons.resources.backup_review_outbox
import com.vitorpamplona.amethyst.commons.resources.backup_review_outbox_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_review_private_added_body
import com.vitorpamplona.amethyst.commons.resources.backup_review_private_added_title
import com.vitorpamplona.amethyst.commons.resources.backup_review_private_changed_body
import com.vitorpamplona.amethyst.commons.resources.backup_review_private_changed_title
import com.vitorpamplona.amethyst.commons.resources.backup_review_private_wiped_body
import com.vitorpamplona.amethyst.commons.resources.backup_review_private_wiped_title
import com.vitorpamplona.amethyst.commons.resources.backup_review_renamed
import com.vitorpamplona.amethyst.commons.resources.backup_review_renamed_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_saved
import com.vitorpamplona.amethyst.commons.resources.backup_review_search_people
import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_dropped
import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_edited
import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_new
import com.vitorpamplona.amethyst.commons.resources.backup_review_units_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_unmuted
import com.vitorpamplona.amethyst.commons.resources.backup_review_unmuted_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_review_was_named
import com.vitorpamplona.amethyst.commons.resources.backup_review_words_and_hashtags
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
@@ -111,6 +171,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import org.jetbrains.compose.resources.StringResource
private const val GRID_COLUMNS = 4
private const val PEOPLE_PREVIEW = 6
@@ -168,7 +229,7 @@ private fun LazyListScope.followListItems(
item(key = "follow-hero", contentType = "hero") {
val tones = conflictTones()
Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
HeroCounts(stringRes(R.string.backup_review_saved), saved, stringRes(R.string.backup_review_new), new)
HeroCounts(stringRes(Res.string.backup_review_saved), saved, stringRes(Res.string.backup_review_new), new)
SplitBar(
listOf(
BarSegment(saved - dropped, tones.kept),
@@ -177,9 +238,9 @@ private fun LazyListScope.followListItems(
),
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
LegendItem(tones.kept, stringRes(R.string.backup_review_kept_count, (saved - dropped).toString()))
LegendItem(tones.removed, stringRes(R.string.backup_review_dropped_count, dropped.toString()))
LegendItem(tones.added, stringRes(R.string.backup_review_new_count, gained.toString()))
LegendItem(tones.kept, stringRes(Res.string.backup_review_kept_count, (saved - dropped).toString()))
LegendItem(tones.removed, stringRes(Res.string.backup_review_dropped_count, dropped.toString()))
LegendItem(tones.added, stringRes(Res.string.backup_review_new_count, gained.toString()))
}
}
}
@@ -188,9 +249,9 @@ private fun LazyListScope.followListItems(
ReviewTabs(
labels =
listOf(
stringRes(R.string.backup_review_tab_dropped, dropped.toString()),
stringRes(R.string.backup_review_tab_new, gained.toString()),
stringRes(R.string.backup_review_tab_edited, edited.toString()),
stringRes(Res.string.backup_review_tab_dropped, dropped.toString()),
stringRes(Res.string.backup_review_tab_new, gained.toString()),
stringRes(Res.string.backup_review_tab_edited, edited.toString()),
),
selected = ui.tab,
onSelect = {
@@ -223,7 +284,7 @@ private fun LazyListScope.followListItems(
if (filtered.isEmpty()) {
item(key = "follow-empty", contentType = "note") {
DetailText(stringRes(R.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp))
DetailText(stringRes(Res.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp))
}
}
}
@@ -359,7 +420,7 @@ private fun PeopleSearch(
singleLine = true,
shape = RoundedCornerShape(14.dp),
leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null) },
placeholder = { Text(stringRes(R.string.backup_review_search_people, count.toString())) },
placeholder = { Text(stringRes(Res.string.backup_review_search_people, count.toString())) },
)
}
@@ -388,15 +449,15 @@ private fun LazyListScope.muteListItems(
Icon(symbol = MaterialSymbols.Shield, contentDescription = null, tint = tones.removed, modifier = Modifier.size(44.dp))
Column {
Text(
stringRes(R.string.backup_review_unmuted, removed.size.toString()),
stringRes(Res.string.backup_review_unmuted, removed.size.toString()),
fontSize = 30.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.Bold,
color = tones.removed,
)
Text(stringRes(R.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
Text(stringRes(Res.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
if (added.isNotEmpty()) {
Text(stringRes(R.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added)
Text(stringRes(Res.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added)
}
}
}
@@ -406,17 +467,17 @@ private fun LazyListScope.muteListItems(
item(key = "mute-tiles", contentType = "tiles") {
val tones = conflictTones()
Row(Pad.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatTile("−${people.size}", stringRes(R.string.backup_entry_person), tones.removed, Modifier.weight(1f))
StatTile("−${words.size}", stringRes(R.string.backup_entry_word), tones.removed, Modifier.weight(1f))
StatTile("−${hashtags.size}", stringRes(R.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f))
StatTile("−${threads.size}", stringRes(R.string.backup_entry_thread), tones.removed, Modifier.weight(1f))
StatTile("−${people.size}", stringRes(Res.string.backup_entry_person), tones.removed, Modifier.weight(1f))
StatTile("−${words.size}", stringRes(Res.string.backup_entry_word), tones.removed, Modifier.weight(1f))
StatTile("−${hashtags.size}", stringRes(Res.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f))
StatTile("−${threads.size}", stringRes(Res.string.backup_entry_thread), tones.removed, Modifier.weight(1f))
}
}
if (people.isNotEmpty()) {
item(key = "mute-people", contentType = "facepile") {
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionCaption(stringRes(R.string.backup_entry_person))
SectionCaption(stringRes(Res.string.backup_entry_person))
FacePile(people, accountViewModel) { ui.showAllPeople = !ui.showAllPeople }
}
}
@@ -427,7 +488,7 @@ private fun LazyListScope.muteListItems(
item(key = "mute-words", contentType = "pills") {
val tones = conflictTones()
Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionCaption(stringRes(R.string.backup_review_words_and_hashtags))
SectionCaption(stringRes(Res.string.backup_review_words_and_hashtags))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
(words.map { "\"$it\"" } + hashtags).forEach { StruckPill(it, tones.removed) }
}
@@ -436,7 +497,7 @@ private fun LazyListScope.muteListItems(
}
if (threads.isNotEmpty()) {
item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_thread), Pad.padding(top = 18.dp)) }
item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_entry_thread), Pad.padding(top = 18.dp)) }
items(threads, key = { "mute-thread-$it" }, contentType = { "note" }) { id ->
LoadNote(id) { note ->
if (note != null) {
@@ -463,7 +524,7 @@ private fun FacePile(
val names = people.take(2).map { observedName(it, accountViewModel) }
val summary =
if (people.size > 2) {
stringRes(R.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString())
stringRes(Res.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString())
} else {
names.joinToString(", ")
}
@@ -564,9 +625,9 @@ internal fun PrivateItemsCard(
val tones = conflictTones()
val (title, body) =
when (change) {
ContentChange.CLEARED -> R.string.backup_review_private_wiped_title to R.string.backup_review_private_wiped_body
ContentChange.ADDED -> R.string.backup_review_private_added_title to R.string.backup_review_private_added_body
else -> R.string.backup_review_private_changed_title to R.string.backup_review_private_changed_body
ContentChange.CLEARED -> Res.string.backup_review_private_wiped_title to Res.string.backup_review_private_wiped_body
ContentChange.ADDED -> Res.string.backup_review_private_added_title to Res.string.backup_review_private_added_body
else -> Res.string.backup_review_private_changed_title to Res.string.backup_review_private_changed_body
}
Row(
modifier
@@ -592,28 +653,28 @@ internal fun PrivateItemsCard(
// ---------------------------------------------------------------------------------------
private class ProfileField(
val labelRes: Int,
val labelRes: StringResource,
val change: ValueChange<String>,
val isImage: Boolean = false,
)
private fun profileFields(diff: MetadataDiff): List<ProfileField> =
listOfNotNull(
diff.picture?.let { ProfileField(R.string.backup_profile_field_picture, it, isImage = true) },
diff.banner?.let { ProfileField(R.string.backup_profile_field_banner, it, isImage = true) },
diff.name?.let { ProfileField(R.string.backup_profile_field_name, it) },
diff.displayName?.let { ProfileField(R.string.backup_profile_field_display_name, it) },
diff.about?.let { ProfileField(R.string.backup_profile_field_about, it) },
diff.nip05?.let { ProfileField(R.string.backup_profile_field_nip05, it) },
diff.website?.let { ProfileField(R.string.backup_profile_field_website, it) },
diff.lud16?.let { ProfileField(R.string.backup_profile_field_lud16, it) },
diff.lud06?.let { ProfileField(R.string.backup_profile_field_lud06, it) },
diff.clinkOffer?.let { ProfileField(R.string.backup_profile_field_clink_offer, it) },
diff.pronouns?.let { ProfileField(R.string.backup_profile_field_pronouns, it) },
diff.bot?.let { ProfileField(R.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) },
diff.picture?.let { ProfileField(Res.string.backup_profile_field_picture, it, isImage = true) },
diff.banner?.let { ProfileField(Res.string.backup_profile_field_banner, it, isImage = true) },
diff.name?.let { ProfileField(Res.string.backup_profile_field_name, it) },
diff.displayName?.let { ProfileField(Res.string.backup_profile_field_display_name, it) },
diff.about?.let { ProfileField(Res.string.backup_profile_field_about, it) },
diff.nip05?.let { ProfileField(Res.string.backup_profile_field_nip05, it) },
diff.website?.let { ProfileField(Res.string.backup_profile_field_website, it) },
diff.lud16?.let { ProfileField(Res.string.backup_profile_field_lud16, it) },
diff.lud06?.let { ProfileField(Res.string.backup_profile_field_lud06, it) },
diff.clinkOffer?.let { ProfileField(Res.string.backup_profile_field_clink_offer, it) },
diff.pronouns?.let { ProfileField(Res.string.backup_profile_field_pronouns, it) },
diff.bot?.let { ProfileField(Res.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) },
diff.birthday?.let {
ProfileField(
R.string.backup_profile_field_birthday,
Res.string.backup_profile_field_birthday,
ValueChange(
it.before?.let { b -> listOfNotNull(b.year, b.month, b.day).joinToString("-") },
it.after?.let { a -> listOfNotNull(a.year, a.month, a.day).joinToString("-") },
@@ -636,8 +697,8 @@ private fun LazyListScope.profileItems(
item(key = "profile-cards", contentType = "profile-cards") {
val tones = conflictTones()
Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MiniProfileCard(saved, pubKey, stringRes(R.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f))
MiniProfileCard(incoming, pubKey, stringRes(R.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f))
MiniProfileCard(saved, pubKey, stringRes(Res.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f))
MiniProfileCard(incoming, pubKey, stringRes(Res.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f))
}
}
@@ -656,7 +717,7 @@ private fun LazyListScope.profileItems(
}
}
// Linked identities and fields this app doesn't model use the generic sections.
genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_profile_field)), accountViewModel, nav)
genericDiffItems(buildRows(presentation, skipGroups = setOf(Res.string.backup_entry_profile_field)), accountViewModel, nav)
}
@Composable
@@ -735,10 +796,10 @@ private fun FlowRowDiff(
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), itemVerticalAlignment = Alignment.CenterVertically) {
if (before != null) DiffChip(clip(before), tones.removed, struck = true)
when {
after == null -> StatusTag(stringRes(R.string.backup_review_gone), tones.removed)
after == null -> StatusTag(stringRes(Res.string.backup_review_gone), tones.removed)
before == null -> {
DiffChip(clip(after), tones.added, struck = false)
StatusTag(stringRes(R.string.backup_review_new_tag), tones.added)
StatusTag(stringRes(Res.string.backup_review_new_tag), tones.added)
}
else -> {
Text("→", color = MaterialTheme.colorScheme.placeholderText)
@@ -779,7 +840,7 @@ private fun ImageThumb(
contentAlignment = Alignment.Center,
) {
if (url == null) {
Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color)
Text(stringRes(Res.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color)
} else {
MyAsyncImage(
imageUrl = url,
@@ -843,14 +904,14 @@ private fun LazyListScope.nip65Items(
val outbox = lane(saved, new, writes)
val inbox = lane(saved, new, reads)
laneItems("outbox", stringRes = R.string.backup_review_outbox, subRes = R.string.backup_review_outbox_explainer, rows = outbox, nav = nav)
laneItems("inbox", stringRes = R.string.backup_review_inbox, subRes = R.string.backup_review_inbox_explainer, rows = inbox, nav = nav)
laneItems("outbox", stringRes = Res.string.backup_review_outbox, subRes = Res.string.backup_review_outbox_explainer, rows = outbox, nav = nav)
laneItems("inbox", stringRes = Res.string.backup_review_inbox, subRes = Res.string.backup_review_inbox_explainer, rows = inbox, nav = nav)
}
private fun LazyListScope.laneItems(
key: String,
stringRes: Int,
subRes: Int,
stringRes: StringResource,
subRes: StringResource,
rows: List<LaneRow>,
nav: INav,
) {
@@ -873,9 +934,9 @@ private fun LaneRelayRow(
val (color, tag) =
when (row.state) {
LaneState.KEPT -> tones.kept to null
LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag
LaneState.ADDED -> tones.added to R.string.backup_review_new_tag
LaneState.DEMOTED -> tones.changed to R.string.backup_review_demoted_tag
LaneState.DROPPED -> tones.removed to Res.string.backup_review_dropped_tag
LaneState.ADDED -> tones.added to Res.string.backup_review_new_tag
LaneState.DEMOTED -> tones.changed to Res.string.backup_review_demoted_tag
}
Row(
Pad
@@ -933,12 +994,12 @@ private fun LazyListScope.nutzapItems(
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(symbol = MaterialSymbols.Key, contentDescription = null, tint = tones.changed)
Text(stringRes(if (key.after == null) R.string.backup_review_key_removed else R.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall)
Text(stringRes(if (key.after == null) Res.string.backup_review_key_removed else Res.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
KeyColumn(key.before, stringRes(R.string.backup_review_saved), tones.kept)
KeyColumn(key.before, stringRes(Res.string.backup_review_saved), tones.kept)
Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(horizontal = 18.dp))
KeyColumn(key.after, stringRes(R.string.backup_review_new), tones.changed)
KeyColumn(key.after, stringRes(Res.string.backup_review_new), tones.changed)
}
if (key.after != null) {
Row(
@@ -950,7 +1011,7 @@ private fun LazyListScope.nutzapItems(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp))
Text(stringRes(R.string.backup_review_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed)
Text(stringRes(Res.string.backup_review_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed)
}
}
}
@@ -976,10 +1037,10 @@ private fun LazyListScope.nutzapItems(
} + diff.mints.added.map { Triple(it.mintUrl, it.units.joinToString(), LaneState.ADDED) }
if (mintRows.isNotEmpty()) {
item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) }
item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) }
items(mintRows, key = { "mint-" + it.first }, contentType = { "mint" }) { (url, units, state) -> MintRow(url, units, state) }
}
genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_mint, R.string.backup_entry_nutzap_key)), accountViewModel, nav)
genericDiffItems(buildRows(presentation, skipGroups = setOf(Res.string.backup_entry_mint, Res.string.backup_entry_nutzap_key)), accountViewModel, nav)
}
@Composable
@@ -993,7 +1054,7 @@ private fun KeyColumn(
KeyFingerprint(hex, color)
} else {
Box(Modifier.size(96.dp).clip(RoundedCornerShape(18.dp)).border(2.dp, color, RoundedCornerShape(18.dp)), contentAlignment = Alignment.Center) {
Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color)
Text(stringRes(Res.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color)
}
}
StatusTag(label, color)
@@ -1010,9 +1071,9 @@ private fun MintRow(
val (color, tag) =
when (state) {
LaneState.KEPT -> MaterialTheme.colorScheme.onSurface to null
LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag
LaneState.ADDED -> tones.added to R.string.backup_review_new_tag
LaneState.DEMOTED -> tones.changed to R.string.backup_review_units_tag
LaneState.DROPPED -> tones.removed to Res.string.backup_review_dropped_tag
LaneState.ADDED -> tones.added to Res.string.backup_review_new_tag
LaneState.DEMOTED -> tones.changed to Res.string.backup_review_units_tag
}
Row(
Pad
@@ -1083,21 +1144,21 @@ private fun LazyListScope.groupItems(
StatTile(
diff.groups.removed.size
.toString(),
stringRes(R.string.backup_review_left),
stringRes(Res.string.backup_review_left),
tones.removed,
Modifier.weight(1f),
)
StatTile(
diff.groups.added.size
.toString(),
stringRes(R.string.backup_review_joined),
stringRes(Res.string.backup_review_joined),
tones.added,
Modifier.weight(1f),
)
StatTile(
diff.groups.changed.size
.toString(),
stringRes(R.string.backup_review_renamed),
stringRes(Res.string.backup_review_renamed),
tones.changed,
Modifier.weight(1f),
)
@@ -1165,7 +1226,7 @@ private fun GroupTileContent(
Column {
Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
tile.oldName?.let { stringRes(R.string.backup_review_was_named, it) } ?: tile.group.relayUrl
tile.oldName?.let { stringRes(Res.string.backup_review_was_named, it) } ?: tile.group.relayUrl
.removePrefix("wss://")
.removeSuffix("/"),
style = MaterialTheme.typography.labelSmall,
@@ -1176,9 +1237,9 @@ private fun GroupTileContent(
}
}
when (tile.state) {
LaneState.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd))
LaneState.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd))
LaneState.DEMOTED -> StatusTag(stringRes(R.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd))
LaneState.DROPPED -> StatusTag(stringRes(Res.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd))
LaneState.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd))
LaneState.DEMOTED -> StatusTag(stringRes(Res.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd))
LaneState.KEPT -> {}
}
}
@@ -48,13 +48,11 @@ 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
@@ -63,6 +61,53 @@ import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflic
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.backup_review_allowed_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_concord_body
import com.vitorpamplona.amethyst.commons.resources.backup_review_demoted_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_added
import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_changed
import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_cleared
import com.vitorpamplona.amethyst.commons.resources.backup_review_feed
import com.vitorpamplona.amethyst.commons.resources.backup_review_hashtags_you_follow
import com.vitorpamplona.amethyst.commons.resources.backup_review_joined
import com.vitorpamplona.amethyst.commons.resources.backup_review_kept
import com.vitorpamplona.amethyst.commons.resources.backup_review_left
import com.vitorpamplona.amethyst.commons.resources.backup_review_left_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_listed_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_moved_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_new
import com.vitorpamplona.amethyst.commons.resources.backup_review_new_count_caption
import com.vitorpamplona.amethyst.commons.resources.backup_review_new_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_newly_blocked
import com.vitorpamplona.amethyst.commons.resources.backup_review_no_longer_blocked
import com.vitorpamplona.amethyst.commons.resources.backup_review_offer
import com.vitorpamplona.amethyst.commons.resources.backup_review_offers_gone
import com.vitorpamplona.amethyst.commons.resources.backup_review_payment_targets_gone
import com.vitorpamplona.amethyst.commons.resources.backup_review_payments_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_review_places_gone
import com.vitorpamplona.amethyst.commons.resources.backup_review_places_you_follow
import com.vitorpamplona.amethyst.commons.resources.backup_review_receiving_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_saved
import com.vitorpamplona.amethyst.commons.resources.backup_review_searching_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_service
import com.vitorpamplona.amethyst.commons.resources.backup_review_service_followers
import com.vitorpamplona.amethyst.commons.resources.backup_review_service_rank
import com.vitorpamplona.amethyst.commons.resources.backup_review_service_topics
import com.vitorpamplona.amethyst.commons.resources.backup_review_services_changed
import com.vitorpamplona.amethyst.commons.resources.backup_review_services_changed_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_review_still_blocked
import com.vitorpamplona.amethyst.commons.resources.backup_review_storing_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_topics_gone
import com.vitorpamplona.amethyst.commons.resources.backup_review_trust_warning
import com.vitorpamplona.amethyst.commons.resources.backup_review_trusted_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_unblocked
import com.vitorpamplona.amethyst.commons.resources.backup_review_unblocked_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_review_untrusted_tag
import com.vitorpamplona.amethyst.commons.resources.backup_review_wallet_body
import com.vitorpamplona.amethyst.commons.resources.backup_type_communities
import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
@@ -118,6 +163,9 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEve
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
/**
* Per-event layouts for the account's other lists: each relay list framed by what it is
@@ -142,8 +190,8 @@ internal fun LazyListScope.listDiffItems(
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)
is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, Res.string.backup_review_wallet_body)
is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, Res.string.backup_review_concord_body)
else -> return false
}
return true
@@ -193,15 +241,15 @@ private fun fateColor(fate: ItemFate): Color {
private fun <T> LazyListScope.countTiles(
items: List<Pair<T, ItemFate>>,
removedCaption: Int,
addedCaption: Int,
removedCaption: StringResource,
addedCaption: StringResource,
) {
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))
StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(Res.string.backup_review_kept), tones.kept, Modifier.weight(1f))
}
}
}
@@ -218,20 +266,20 @@ private fun LazyListScope.privateItemsCard(change: ContentChange) {
/** 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,
val keptTag: StringResource,
val droppedTag: StringResource = Res.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)
BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, Res.string.backup_review_receiving_tag)
BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, Res.string.backup_review_listed_tag)
BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, Res.string.backup_review_searching_tag)
BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, Res.string.backup_review_listed_tag)
BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, Res.string.backup_review_listed_tag)
BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, Res.string.backup_review_storing_tag)
BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, Res.string.backup_review_trusted_tag, Res.string.backup_review_untrusted_tag)
else -> RelayFraming(MaterialSymbols.Dns, Res.string.backup_review_listed_tag)
}
private fun savedRelaysOf(event: Event): List<NormalizedRelayUrl> =
@@ -277,8 +325,8 @@ private fun RelayFateRow(
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
ItemFate.ADDED -> Res.string.backup_review_new_tag
ItemFate.CHANGED -> Res.string.backup_review_demoted_tag
}
Row(
Pad
@@ -320,14 +368,14 @@ private fun LazyListScope.blockedRelayItems(
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)
Text(stringRes(Res.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed)
Text(stringRes(Res.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)) }
item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(Res.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(
@@ -346,12 +394,12 @@ private fun LazyListScope.blockedRelayItems(
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)
StatusTag(stringRes(Res.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)) }
item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(Res.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)
}
@@ -359,7 +407,7 @@ private fun LazyListScope.blockedRelayItems(
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))
SectionCaption(stringRes(Res.string.backup_review_still_blocked))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
stillBlocked.forEach { url ->
Row(
@@ -392,7 +440,7 @@ private fun <T> LazyListScope.spaceGrid(
key: (T) -> String,
content: @Composable (T, ItemFate, Modifier) -> Unit,
) {
countTiles(items, R.string.backup_review_left, R.string.backup_review_joined)
countTiles(items, Res.string.backup_review_left, Res.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)) }
@@ -436,9 +484,9 @@ private fun SpaceTile(
}
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.DROPPED -> Res.string.backup_review_left_tag to tones.removed
ItemFate.ADDED -> Res.string.backup_review_new_tag to tones.added
ItemFate.CHANGED -> Res.string.backup_review_moved_tag to tones.changed
ItemFate.KEPT -> null
}
tag?.let { (res, color) -> StatusTag(stringRes(res), color, Modifier.align(Alignment.TopEnd)) }
@@ -459,7 +507,7 @@ private fun LazyListScope.publicChatItems(
val current = state?.channel ?: channel
SpaceTile(
name = current.toBestDisplayName(),
subtitle = stringRes(R.string.backup_type_public_chats),
subtitle = stringRes(Res.string.backup_type_public_chats),
robot = tag.eventId,
picture = channel.profilePicture(),
fate = fate,
@@ -525,7 +573,7 @@ private fun AddressableSpaceTile(
}
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),
subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) Res.string.backup_review_feed else Res.string.backup_type_communities),
robot = address.toValue(),
picture = picture,
fate = fate,
@@ -543,7 +591,7 @@ private fun LazyListScope.ephemeralRoomItems(
) {
val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty()
val rooms = fates(saved, diff.rooms) { it }
countTiles(rooms, R.string.backup_review_left, R.string.backup_review_joined)
countTiles(rooms, Res.string.backup_review_left, Res.string.backup_review_joined)
items(rooms, key = { "room-" + it.first.toKey() }, contentType = { "room-row" }) { (room, fate) -> RoomRow(room, fate, nav) }
privateItemsCard(diff.privateItems)
}
@@ -582,8 +630,8 @@ private fun RoomRow(
)
}
when (fate) {
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), color)
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
ItemFate.DROPPED -> StatusTag(stringRes(Res.string.backup_review_left_tag), color)
ItemFate.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), color)
else -> {}
}
}
@@ -602,11 +650,11 @@ private fun LazyListScope.topicItems(
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)
countTiles(topics, if (isPlace) Res.string.backup_review_places_gone else Res.string.backup_review_topics_gone, Res.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))
SectionCaption(stringRes(if (isPlace) Res.string.backup_review_places_you_follow else Res.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)
@@ -706,15 +754,15 @@ private fun LazyListScope.trustProviderItems(
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)
Text(stringRes(Res.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
Text(stringRes(Res.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 ->
listOf(Res.string.backup_review_service, Res.string.backup_review_saved, Res.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)
}
}
@@ -732,7 +780,7 @@ private fun LazyListScope.trustProviderItems(
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)
Text(stringRes(Res.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed)
}
}
privateItemsCard(diff.privateItems)
@@ -771,9 +819,9 @@ private fun ServiceTableRow(
@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)
"rank" -> stringRes(Res.string.backup_review_service_rank)
"followers" -> stringRes(Res.string.backup_review_service_followers)
"t" -> stringRes(Res.string.backup_review_service_topics)
else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
@@ -826,7 +874,7 @@ private fun LazyListScope.paymentTargetItems(
) {
val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty()
val targets = fates(saved, diff.targets) { it }
moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_payment_targets_gone)
moneyHero(targets.count { it.second == ItemFate.DROPPED }, Res.plurals.backup_review_payment_targets_gone)
items(targets, key = { "target-" + it.first.type + ":" + it.first.authority }, contentType = { "money-row" }) { (target, fate) -> PaymentTargetRow(target, fate) }
}
@@ -836,15 +884,15 @@ private fun LazyListScope.offerItems(
) {
val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty()
val offers = fates(saved, diff.offers) { it }
moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_offers_gone)
moneyHero(offers.count { it.second == ItemFate.DROPPED }, Res.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)
MoneyRow(stringRes(Res.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate)
}
}
private fun LazyListScope.moneyHero(
dropped: Int,
headline: Int,
headline: PluralStringResource,
) {
item(key = "money-hero", contentType = "hero") {
val tones = conflictTones()
@@ -856,7 +904,7 @@ private fun LazyListScope.moneyHero(
}
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)
Text(stringRes(Res.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
}
}
@@ -902,8 +950,8 @@ private fun MoneyRow(
)
}
when (fate) {
ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_dropped_tag), color)
ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color)
ItemFate.DROPPED -> StatusTag(stringRes(Res.string.backup_review_dropped_tag), color)
ItemFate.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), color)
else -> {}
}
}
@@ -916,15 +964,15 @@ private fun MoneyRow(
private fun LazyListScope.encryptedOnlyItems(
icon: MaterialSymbol,
change: ContentChange,
body: Int,
body: StringResource,
) {
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
ContentChange.CLEARED -> tones.removed to Res.string.backup_review_encrypted_cleared
ContentChange.ADDED -> tones.added to Res.string.backup_review_encrypted_added
else -> tones.changed to Res.string.backup_review_encrypted_changed
}
Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
TintedPanel(color.copy(alpha = 0.12f)) {
@@ -23,9 +23,98 @@ 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.LocalResources
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_read_only
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_read_write
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_write_only
import com.vitorpamplona.amethyst.commons.resources.backup_entry_algo_feed
import com.vitorpamplona.amethyst.commons.resources.backup_entry_chat_room
import com.vitorpamplona.amethyst.commons.resources.backup_entry_community
import com.vitorpamplona.amethyst.commons.resources.backup_entry_hashtag
import com.vitorpamplona.amethyst.commons.resources.backup_entry_identity_claim
import com.vitorpamplona.amethyst.commons.resources.backup_entry_location
import com.vitorpamplona.amethyst.commons.resources.backup_entry_mint
import com.vitorpamplona.amethyst.commons.resources.backup_entry_nutzap_key
import com.vitorpamplona.amethyst.commons.resources.backup_entry_offer
import com.vitorpamplona.amethyst.commons.resources.backup_entry_other
import com.vitorpamplona.amethyst.commons.resources.backup_entry_payment_target
import com.vitorpamplona.amethyst.commons.resources.backup_entry_person
import com.vitorpamplona.amethyst.commons.resources.backup_entry_profile_field
import com.vitorpamplona.amethyst.commons.resources.backup_entry_public_chat
import com.vitorpamplona.amethyst.commons.resources.backup_entry_relay
import com.vitorpamplona.amethyst.commons.resources.backup_entry_relay_group
import com.vitorpamplona.amethyst.commons.resources.backup_entry_thread
import com.vitorpamplona.amethyst.commons.resources.backup_entry_trust_provider
import com.vitorpamplona.amethyst.commons.resources.backup_entry_word
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_about
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_banner
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_birthday
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_bot
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_clink_offer
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_display_name
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud06
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud16
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_name
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_nip05
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_picture
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_pronouns
import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_website
import com.vitorpamplona.amethyst.commons.resources.backup_type_app_settings
import com.vitorpamplona.amethyst.commons.resources.backup_type_app_settings_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_blocked_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_blocked_relays_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_bolt12_offers
import com.vitorpamplona.amethyst.commons.resources.backup_type_bolt12_offers_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_cashu_wallet
import com.vitorpamplona.amethyst.commons.resources.backup_type_cashu_wallet_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_communities
import com.vitorpamplona.amethyst.commons.resources.backup_type_communities_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_concord_communities
import com.vitorpamplona.amethyst.commons.resources.backup_type_concord_communities_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_dm_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_dm_relays_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_ephemeral_chats
import com.vitorpamplona.amethyst.commons.resources.backup_type_ephemeral_chats_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_favorite_algo_feeds
import com.vitorpamplona.amethyst.commons.resources.backup_type_favorite_algo_feeds_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_follow_list
import com.vitorpamplona.amethyst.commons.resources.backup_type_follow_list_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_geohashes
import com.vitorpamplona.amethyst.commons.resources.backup_type_geohashes_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_hashtags
import com.vitorpamplona.amethyst.commons.resources.backup_type_hashtags_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_indexer_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_indexer_relays_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_key_package_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_key_package_relays_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_mute_list
import com.vitorpamplona.amethyst.commons.resources.backup_type_mute_list_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_nutzap_info
import com.vitorpamplona.amethyst.commons.resources.backup_type_nutzap_info_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_other
import com.vitorpamplona.amethyst.commons.resources.backup_type_other_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_outbox_inbox_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_outbox_inbox_relays_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_payment_targets
import com.vitorpamplona.amethyst.commons.resources.backup_type_payment_targets_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_private_outbox_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_private_outbox_relays_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_profile
import com.vitorpamplona.amethyst.commons.resources.backup_type_profile_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats
import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_feeds
import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_feeds_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_groups
import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_groups_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_search_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_search_relays_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers
import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays_explainer
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
@@ -61,6 +150,7 @@ 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
import org.jetbrains.compose.resources.StringResource
private const val MAX_VALUE_LENGTH = 80
@@ -168,7 +258,7 @@ sealed interface ReviewItem {
/** One labelled group of differences (people, relays, profile fields…) of a conflict. */
@Immutable
class DiffGroup(
val label: Int,
val label: StringResource,
val removed: List<ReviewItem>,
val added: List<ReviewItem>,
val changed: List<ReviewItem>,
@@ -189,7 +279,7 @@ class DiffPresentation(
/** A group from a [ListDiff] of an event's parsed items. */
private fun <T> listGroup(
label: Int,
label: StringResource,
diff: ListDiff<T>,
item: (T) -> ReviewItem,
change: (ItemChange<T>) -> ReviewItem,
@@ -197,7 +287,7 @@ private fun <T> listGroup(
/** A group whose items can't change, only be added or removed (hashtags, plain relays…). */
private fun <T> listGroup(
label: Int,
label: StringResource,
diff: ListDiff<T>,
item: (T) -> ReviewItem,
) = listGroup(label, diff, item) { item(it.after) }
@@ -212,7 +302,7 @@ private inline fun <reified T> ListDiff<*>.only() =
/** A group from single-valued fields: a field set only after is added, only before is removed. */
private fun fieldGroup(
label: Int,
label: StringResource,
fields: List<Pair<String, ValueChange<String>?>>,
): DiffGroup {
val removed = mutableListOf<ReviewItem>()
@@ -242,14 +332,53 @@ private fun arrow(
*/
@Composable
fun rememberPresentation(diff: EventDiff): DiffPresentation {
val resources = LocalResources.current
return remember(diff, resources) { presentationOf(diff) { resources.getString(it) } }
// Compose resources resolve only in composition (or suspended), so the labels
// [presentationOf] writes into item text are resolved here and handed in. Only the
// diff types that write labels get any, so the rest pay for no resource reads. The
// map compares by value: a recomposition with the same locale keeps the cached result.
val labels = labelsWrittenBy(diff).associateWith { stringRes(it) }
return remember(diff, labels) { presentationOf(diff) { labels.getValue(it) } }
}
/** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */
/** Every label [presentationOf] resolves through its `str` parameter for [diff]. */
internal fun labelsWrittenBy(diff: EventDiff): List<StringResource> =
when (diff) {
is MetadataDiff -> PROFILE_FIELD_LABELS
is AdvertisedRelayListDiff -> RELAY_MARKER_LABELS
else -> emptyList()
}
private val PROFILE_FIELD_LABELS =
listOf(
Res.string.backup_profile_field_name,
Res.string.backup_profile_field_display_name,
Res.string.backup_profile_field_about,
Res.string.backup_profile_field_picture,
Res.string.backup_profile_field_banner,
Res.string.backup_profile_field_website,
Res.string.backup_profile_field_nip05,
Res.string.backup_profile_field_lud16,
Res.string.backup_profile_field_lud06,
Res.string.backup_profile_field_clink_offer,
Res.string.backup_profile_field_pronouns,
Res.string.backup_profile_field_birthday,
Res.string.backup_profile_field_bot,
)
private val RELAY_MARKER_LABELS =
listOf(
Res.string.backup_conflict_relay_read_write,
Res.string.backup_conflict_relay_read_only,
Res.string.backup_conflict_relay_write_only,
)
/**
* Turns each event's own diff into typed, labelled groups. [str] resolves the labels; a
* label it asks for must be in [labelsWrittenBy] for that diff type.
*/
fun presentationOf(
diff: EventDiff,
str: (Int) -> String,
str: (StringResource) -> String,
): DiffPresentation {
val (groups, content) =
when (diff) {
@@ -258,31 +387,31 @@ fun presentationOf(
val bot = diff.bot?.let { ValueChange(it.before?.toString(), it.after?.toString()) }
listOf(
fieldGroup(
R.string.backup_entry_profile_field,
Res.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,
str(Res.string.backup_profile_field_name) to diff.name,
str(Res.string.backup_profile_field_display_name) to diff.displayName,
str(Res.string.backup_profile_field_about) to diff.about,
str(Res.string.backup_profile_field_picture) to diff.picture,
str(Res.string.backup_profile_field_banner) to diff.banner,
str(Res.string.backup_profile_field_website) to diff.website,
str(Res.string.backup_profile_field_nip05) to diff.nip05,
str(Res.string.backup_profile_field_lud16) to diff.lud16,
str(Res.string.backup_profile_field_lud06) to diff.lud06,
str(Res.string.backup_profile_field_clink_offer) to diff.clinkOffer,
str(Res.string.backup_profile_field_pronouns) to diff.pronouns,
str(Res.string.backup_profile_field_birthday) to birthday,
str(Res.string.backup_profile_field_bot) to bot,
),
),
listGroup(
R.string.backup_entry_identity_claim,
Res.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,
Res.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)) },
@@ -292,7 +421,7 @@ fun presentationOf(
is ContactListDiff ->
listOf(
listGroup(
R.string.backup_entry_person,
Res.string.backup_entry_person,
diff.follows,
{ ReviewItem.Person(it.pubKey, it.petname) },
{
@@ -308,32 +437,32 @@ fun presentationOf(
) 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) }),
listGroup(Res.string.backup_entry_person, diff.publicMutes.only<UserTag>(), { ReviewItem.Person(it.pubKey) }),
listGroup(Res.string.backup_entry_word, diff.publicMutes.only<WordTag>(), { ReviewItem.Text("\"" + it.word + "\"") }),
listGroup(Res.string.backup_entry_hashtag, diff.publicMutes.only<HashtagTag>(), { ReviewItem.Text("#" + it.hashtag) }),
listGroup(Res.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),
AdvertisedRelayType.BOTH to str(Res.string.backup_conflict_relay_read_write),
AdvertisedRelayType.READ to str(Res.string.backup_conflict_relay_read_only),
AdvertisedRelayType.WRITE to str(Res.string.backup_conflict_relay_write_only),
)
listOf(
listGroup(
R.string.backup_entry_relay,
Res.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 RelayListDiff -> listOf(listGroup(Res.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays
is PublicChatListDiff ->
listOf(
listGroup(
R.string.backup_entry_public_chat,
Res.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)) },
@@ -342,28 +471,28 @@ fun presentationOf(
is CommunityListDiff ->
listOf(
listGroup(
R.string.backup_entry_community,
Res.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 InterestListDiff -> 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 InterestListDiff -> listOf(listGroup(Res.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems
is GeohashListDiff -> listOf(listGroup(Res.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems
is FavoriteAlgoFeedsListDiff ->
listOf(
listGroup(
R.string.backup_entry_algo_feed,
Res.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 EphemeralChatListDiff -> listOf(listGroup(Res.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems
is SimpleGroupListDiff ->
listOf(
listGroup(
R.string.backup_entry_relay_group,
Res.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)) },
@@ -372,7 +501,7 @@ fun presentationOf(
is TrustProviderListDiff ->
listOf(
listGroup(
R.string.backup_entry_trust_provider,
Res.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)) },
@@ -381,19 +510,19 @@ fun presentationOf(
is NutzapInfoDiff ->
listOf(
listGroup(
R.string.backup_entry_mint,
Res.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) }),
listGroup(Res.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)) }),
listGroup(Res.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 Bolt12OfferListDiff -> listOf(listGroup(Res.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
@@ -404,13 +533,13 @@ fun presentationOf(
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)
if (change == null) return DiffGroup(Res.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))))
before != null && after == null -> DiffGroup(Res.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none)
before == null && after != null -> DiffGroup(Res.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none)
else -> DiffGroup(Res.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after))))
}
}
@@ -422,64 +551,64 @@ fun clip(text: String?): String {
return if (oneLine.length > MAX_VALUE_LENGTH) oneLine.take(MAX_VALUE_LENGTH) + "…" else oneLine
}
fun eventTypeName(type: BackupEventType): Int =
fun eventTypeName(type: BackupEventType): StringResource =
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
BackupEventType.PROFILE -> Res.string.backup_type_profile
BackupEventType.FOLLOW_LIST -> Res.string.backup_type_follow_list
BackupEventType.MUTE_LIST -> Res.string.backup_type_mute_list
BackupEventType.OUTBOX_INBOX_RELAYS -> Res.string.backup_type_outbox_inbox_relays
BackupEventType.DM_RELAYS -> Res.string.backup_type_dm_relays
BackupEventType.KEY_PACKAGE_RELAYS -> Res.string.backup_type_key_package_relays
BackupEventType.SEARCH_RELAYS -> Res.string.backup_type_search_relays
BackupEventType.INDEXER_RELAYS -> Res.string.backup_type_indexer_relays
BackupEventType.RELAY_FEEDS -> Res.string.backup_type_relay_feeds
BackupEventType.BLOCKED_RELAYS -> Res.string.backup_type_blocked_relays
BackupEventType.TRUSTED_RELAYS -> Res.string.backup_type_trusted_relays
BackupEventType.PRIVATE_OUTBOX_RELAYS -> Res.string.backup_type_private_outbox_relays
BackupEventType.APP_SETTINGS -> Res.string.backup_type_app_settings
BackupEventType.PUBLIC_CHATS -> Res.string.backup_type_public_chats
BackupEventType.COMMUNITIES -> Res.string.backup_type_communities
BackupEventType.HASHTAGS -> Res.string.backup_type_hashtags
BackupEventType.GEOHASHES -> Res.string.backup_type_geohashes
BackupEventType.FAVORITE_ALGO_FEEDS -> Res.string.backup_type_favorite_algo_feeds
BackupEventType.EPHEMERAL_CHATS -> Res.string.backup_type_ephemeral_chats
BackupEventType.RELAY_GROUPS -> Res.string.backup_type_relay_groups
BackupEventType.CONCORD_COMMUNITIES -> Res.string.backup_type_concord_communities
BackupEventType.TRUST_PROVIDERS -> Res.string.backup_type_trust_providers
BackupEventType.CASHU_WALLET -> Res.string.backup_type_cashu_wallet
BackupEventType.NUTZAP_INFO -> Res.string.backup_type_nutzap_info
BackupEventType.PAYMENT_TARGETS -> Res.string.backup_type_payment_targets
BackupEventType.BOLT12_OFFERS -> Res.string.backup_type_bolt12_offers
BackupEventType.OTHER -> Res.string.backup_type_other
}
fun eventTypeExplainer(type: BackupEventType): Int =
fun eventTypeExplainer(type: BackupEventType): StringResource =
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
BackupEventType.PROFILE -> Res.string.backup_type_profile_explainer
BackupEventType.FOLLOW_LIST -> Res.string.backup_type_follow_list_explainer
BackupEventType.MUTE_LIST -> Res.string.backup_type_mute_list_explainer
BackupEventType.OUTBOX_INBOX_RELAYS -> Res.string.backup_type_outbox_inbox_relays_explainer
BackupEventType.DM_RELAYS -> Res.string.backup_type_dm_relays_explainer
BackupEventType.KEY_PACKAGE_RELAYS -> Res.string.backup_type_key_package_relays_explainer
BackupEventType.SEARCH_RELAYS -> Res.string.backup_type_search_relays_explainer
BackupEventType.INDEXER_RELAYS -> Res.string.backup_type_indexer_relays_explainer
BackupEventType.RELAY_FEEDS -> Res.string.backup_type_relay_feeds_explainer
BackupEventType.BLOCKED_RELAYS -> Res.string.backup_type_blocked_relays_explainer
BackupEventType.TRUSTED_RELAYS -> Res.string.backup_type_trusted_relays_explainer
BackupEventType.PRIVATE_OUTBOX_RELAYS -> Res.string.backup_type_private_outbox_relays_explainer
BackupEventType.APP_SETTINGS -> Res.string.backup_type_app_settings_explainer
BackupEventType.PUBLIC_CHATS -> Res.string.backup_type_public_chats_explainer
BackupEventType.COMMUNITIES -> Res.string.backup_type_communities_explainer
BackupEventType.HASHTAGS -> Res.string.backup_type_hashtags_explainer
BackupEventType.GEOHASHES -> Res.string.backup_type_geohashes_explainer
BackupEventType.FAVORITE_ALGO_FEEDS -> Res.string.backup_type_favorite_algo_feeds_explainer
BackupEventType.EPHEMERAL_CHATS -> Res.string.backup_type_ephemeral_chats_explainer
BackupEventType.RELAY_GROUPS -> Res.string.backup_type_relay_groups_explainer
BackupEventType.CONCORD_COMMUNITIES -> Res.string.backup_type_concord_communities_explainer
BackupEventType.TRUST_PROVIDERS -> Res.string.backup_type_trust_providers_explainer
BackupEventType.CASHU_WALLET -> Res.string.backup_type_cashu_wallet_explainer
BackupEventType.NUTZAP_INFO -> Res.string.backup_type_nutzap_info_explainer
BackupEventType.PAYMENT_TARGETS -> Res.string.backup_type_payment_targets_explainer
BackupEventType.BOLT12_OFFERS -> Res.string.backup_type_bolt12_offers_explainer
BackupEventType.OTHER -> Res.string.backup_type_other_explainer
}
@@ -59,11 +59,34 @@ 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.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.backup_action_block_again
import com.vitorpamplona.amethyst.commons.resources.backup_action_keep_count
import com.vitorpamplona.amethyst.commons.resources.backup_action_rejoin
import com.vitorpamplona.amethyst.commons.resources.backup_action_remute
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_count
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_feeds
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_places
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_profile
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_providers
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_relays
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_topics
import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_wallet
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_added
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_changed
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_keep_new
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_added
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_changed
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_cleared
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_removed
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_restore_mine
import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app
import com.vitorpamplona.amethyst.commons.resources.backup_review_title_cleared
import com.vitorpamplona.amethyst.commons.resources.backup_review_title_updated
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
@@ -97,22 +120,23 @@ 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
import org.jetbrains.compose.resources.StringResource
/** A row of the review list, flattened so hundreds of entries scroll lazily. */
@Immutable
internal sealed interface ReviewRow {
class Section(
val titleRes: Int,
val titleRes: StringResource,
val count: Int,
val tone: Tone,
) : ReviewRow
class Note(
val textRes: Int,
val textRes: StringResource,
) : ReviewRow
class Group(
val labelRes: Int,
val labelRes: StringResource,
) : ReviewRow
class Entry(
@@ -124,15 +148,15 @@ internal enum class Tone { REMOVED, ADDED, CHANGED }
internal fun buildRows(
presentation: DiffPresentation,
skipGroups: Set<Int> = emptySet(),
skipGroups: Set<StringResource> = emptySet(),
): List<ReviewRow> {
val rows = mutableListOf<ReviewRow>()
fun section(
titleRes: Int,
titleRes: StringResource,
count: Int,
tone: Tone,
contentNote: Int?,
contentNote: StringResource?,
items: (DiffGroup) -> List<ReviewItem>,
) {
if (count == 0 && contentNote == null) return
@@ -149,9 +173,9 @@ internal fun buildRows(
}
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 }
section(Res.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, Res.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed }
section(Res.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, Res.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added }
section(Res.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, Res.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed }
return rows
}
@@ -268,7 +292,7 @@ internal fun LazyListScope.genericDiffItems(
}
@Composable
internal fun GroupLabel(labelRes: Int) {
internal fun GroupLabel(labelRes: StringResource) {
Text(
text = stringRes(labelRes),
style = MaterialTheme.typography.labelLarge,
@@ -339,7 +363,7 @@ 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)
return stringRes(if (emptied) Res.string.backup_review_title_cleared else Res.string.backup_review_title_updated, name)
}
/** The two choices, worded for what they do to this event: "Keep 120" / "Restore 523". */
@@ -348,48 +372,48 @@ private fun actionLabels(conflict: ReplaceableBackupConflict): Pair<String, Stri
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())
stringRes(Res.string.backup_action_keep_count, new.toString()) to stringRes(Res.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)
stringRes(Res.string.backup_conflict_keep_new) to
if (removed > 0) stringRes(Res.string.backup_action_remute, removed.toString()) else stringRes(Res.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)
stringRes(Res.string.backup_conflict_keep_new) to
if (removed > 0) stringRes(Res.string.backup_action_rejoin, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine)
}
is MetadataDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_profile)
is MetadataDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.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(Res.string.backup_conflict_keep_new) to
stringRes(
R.string.backup_action_block_again,
Res.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)
stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_relays)
}
is AdvertisedRelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays)
is AdvertisedRelayListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_relays)
is PublicChatListDiff -> 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 InterestListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics)
is FavoriteAlgoFeedsListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_feeds)
is InterestListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.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)
is GeohashListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_places)
is TrustProviderListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_providers)
is CashuWalletDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_wallet)
else -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.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)
stringRes(Res.string.backup_conflict_keep_new) to
if (removed > 0) stringRes(Res.string.backup_action_rejoin, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine)
/** When the other app changed it, and what the event is for. */
@Composable
@@ -399,7 +423,7 @@ private fun BackupConflictIntro(
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
text = stringRes(Res.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)),
style = MaterialTheme.typography.labelLarge,
color = conflictTones().removed,
)
@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryEntry
import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
@@ -101,11 +102,7 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -154,9 +151,12 @@ private fun BrowserLauncher(
) {
val context = LocalContext.current
val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading)
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val history by Amethyst.instance.browserHistory.history
.collectAsStateWithLifecycle()
val iconKeys by Amethyst.instance.browserIcons.keys
.collectAsStateWithLifecycle()
// Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of
// showing "isn't loaded yet" until the user happens to visit the nsite/napplet feed.
@@ -235,10 +235,10 @@ private fun BrowserLauncher(
label: String,
) {
val id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
if (Amethyst.instance.favoriteApps.isFavorite(id)) {
Amethyst.instance.favoriteApps.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
}
}
@@ -290,7 +290,7 @@ private fun BrowserLauncher(
historyUrls = historyUrls,
onOpen = { open(it.url) },
onToggleFavorite = { toggleFavorite(it.url, it.label) },
onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) },
onRemoveFromHistory = { Amethyst.instance.browserHistory.remove(it) },
modifier = contentModifier,
)
else -> {
@@ -306,11 +306,11 @@ private fun BrowserLauncher(
nsites = followedNsites,
napplets = followedNapplets,
onOpenApp = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) },
onRemoveApp = { FavoriteAppsRegistry.remove(it.id) },
onAddApp = { FavoriteAppsRegistry.add(it) },
onRemoveApp = { Amethyst.instance.favoriteApps.remove(it.id) },
onAddApp = { Amethyst.instance.favoriteApps.add(it) },
onOpenUrl = { open(it) },
onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) },
onRemoveRecent = { BrowserHistoryRegistry.remove(it) },
onRemoveRecent = { Amethyst.instance.browserHistory.remove(it) },
modifier = contentModifier,
)
}
@@ -571,7 +571,7 @@ private fun SuggestedRow(
onClick: () -> Unit,
onAddFavorite: () -> Unit,
) {
val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(BrowserIconRegistry::iconModelFor) }
val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(Amethyst.instance.browserIcons::iconModelFor) }
Row(
modifier =
Modifier
@@ -798,7 +798,7 @@ private fun SiteIcon(
iconKeys: Set<String>,
modifier: Modifier = Modifier,
) {
val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
val model = remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) }
val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public
val tint = MaterialTheme.colorScheme.onSurfaceVariant
if (model == null) {
@@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -110,7 +109,8 @@ private fun EmbeddedWebAppTab(
// can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
@@ -147,10 +147,10 @@ private fun EmbeddedWebAppTab(
isFavorite = isFavorite,
onFavorite = {
val favId = "url:$currentUrl"
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
if (Amethyst.instance.favoriteApps.isFavorite(favId)) {
Amethyst.instance.favoriteApps.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
}
},
// NIP-07 grants for a plain web client are keyed per visited origin as `browser:<origin>`
@@ -181,7 +181,7 @@ fun AgentAttestationScreen(
* Agent-side: paste an `auth` tag an owner issued to this account's key. [parseHeldAttestation]
* turns it into a typed failure the field can show, and [BuzzHeldAttestations.put] re-checks the
* signature before storing, so the auth coordinator attaches it when this account AUTHs to a Buzz
* relay. Persisted across restarts, per account, by `BuzzAttestationPreferences`.
* relay. Persisted across restarts, per account, by `BuzzAttestationStore`.
*/
@Composable
private fun HoldAttestationSection(
@@ -64,13 +64,13 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_export_event
import com.vitorpamplona.amethyst.commons.resources.calendar_export_share_title
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -47,11 +47,11 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_title
import com.vitorpamplona.amethyst.commons.resources.calendar_section_past
import com.vitorpamplona.amethyst.commons.resources.calendar_section_upcoming
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -34,14 +34,15 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_enabled_subtitle
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_enabled_title
@@ -54,20 +55,28 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarReminderSettingsScreen(nav: INav) {
val context = LocalContext.current
val prefs = remember { CalendarReminderPrefs(context) }
var enabled by remember { mutableStateOf(prefs.isEnabled()) }
var leadMinutes by remember { mutableIntStateOf(prefs.leadMinutes()) }
val scope = rememberCoroutineScope()
val store = remember { calendarReminderSettings() }
// DataStore reads are suspend, so the first frame renders the defaults and
// the stored values arrive right after. Collecting the flow rather than
// reading once also keeps the screen correct if the worker path or another
// screen changes a value while this one is open.
val settings by store.flow.collectAsStateWithLifecycle(CalendarReminderSettings())
val enabled = settings.enabled
val leadMinutes = settings.leadMinutes
Scaffold(
topBar = {
@@ -92,8 +101,7 @@ fun CalendarReminderSettingsScreen(nav: INav) {
description = Res.string.calendar_reminder_settings_enabled_subtitle,
checked = enabled,
onCheckedChange = {
enabled = it
prefs.setEnabled(it)
scope.launch { store.setEnabled(it) }
// Cancel eagerly on disable so the periodic worker stops waking the
// process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP
// observer in AppModules re-schedules on the next relevant RSVP too.
@@ -110,15 +118,14 @@ fun CalendarReminderSettingsScreen(nav: INav) {
title = stringRes(Res.string.calendar_reminder_settings_lead_title),
description = stringRes(Res.string.calendar_reminder_settings_lead_subtitle),
) {
val choices = CalendarReminderPrefs.LEAD_TIME_CHOICES
val choices = CalendarReminderSettings.LEAD_TIME_CHOICES
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
choices.forEachIndexed { index, choice ->
SegmentedButton(
selected = choice == leadMinutes,
enabled = enabled,
onClick = {
leadMinutes = choice
prefs.setLeadMinutes(choice)
scope.launch { store.setLeadMinutes(choice) }
},
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
icon = {},
@@ -42,12 +42,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_composer_hint
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder
@@ -233,7 +234,7 @@ internal fun CordnComposer(
shape = EditFieldBorder,
placeholder = {
Text(
text = stringRes(R.string.cordn_composer_hint),
text = stringRes(Res.string.cordn_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -55,11 +55,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator
import com.vitorpamplona.amethyst.commons.cordn.GroupExposure
@@ -69,6 +67,55 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_none
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen_on
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_unheard
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_hide_older
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_relays_more
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_all
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_fewer
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_older
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_stale_note
import com.vitorpamplona.amethyst.commons.resources.cordn_create_action
import com.vitorpamplona.amethyst.commons.resources.cordn_create_action_invite
import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin_count
import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin_only_me_on
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_change
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_discover
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_hide
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_new
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_pubkey
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_relays
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_all
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_partial
import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_unknown
import com.vitorpamplona.amethyst.commons.resources.cordn_create_description
import com.vitorpamplona.amethyst.commons.resources.cordn_create_egalitarian
import com.vitorpamplona.amethyst.commons.resources.cordn_create_egalitarian_note
import com.vitorpamplona.amethyst.commons.resources.cordn_create_explainer
import com.vitorpamplona.amethyst.commons.resources.cordn_create_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_create_name
import com.vitorpamplona.amethyst.commons.resources.cordn_create_no_coordinator
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_explainer
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_link
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_no_welcome
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_open
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_skipped
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_title
import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_waiting
import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_none
import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_admins
import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_name
import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_people
import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_where
import com.vitorpamplona.amethyst.commons.resources.cordn_create_title
import com.vitorpamplona.amethyst.commons.resources.cordn_create_unreachable_count
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail
import com.vitorpamplona.amethyst.commons.resources.cordn_member_count
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.model.cordn.CordnCoverage
@@ -131,7 +178,7 @@ fun CordnCreateGroupScreen(
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back))
}
},
title = { Text(stringRes(R.string.cordn_create_title)) },
title = { Text(stringRes(Res.string.cordn_create_title)) },
)
},
) { padding ->
@@ -139,14 +186,20 @@ fun CordnCreateGroupScreen(
// The app's own empty state, centred and titled, rather than a
// sentence stranded in the top-left corner.
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
title = stringRes(Res.string.cordn_group_unavailable),
description = stringRes(Res.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
val known by runtime.coordinators.collectAsStateWithLifecycle()
// Collected, not read as `.value` below: the exposure card counts the
// groups this coordinator already carries, and reading the flow inside
// composition would leave that count frozen at whatever it was when the
// screen opened — the one number on the card that can change while you
// are looking at it, since creating a group is what changes it.
val rooms by runtime.groups.all.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val me = accountViewModel.account.signer.pubKey
@@ -158,14 +211,14 @@ fun CordnCreateGroupScreen(
val draft = rememberCordnGroupDraft(accountViewModel)
var creation by remember { mutableStateOf<CordnGroupCreation?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val failureFallback = stringRes(R.string.cordn_create_failed)
val failureFallback = stringRes(Res.string.cordn_create_failed)
var busy by remember { mutableStateOf(false) }
// Coordinators this account has never used, from their CEP-6
// announcements. Not added to the account by looking: picking one here
// is what commits to it, and `createGroup` opens the session.
var discovering by remember { mutableStateOf(false) }
val discoverFailed = stringRes(R.string.cordn_coordinators_discover_failed)
val discoverFailed = stringRes(Res.string.cordn_coordinators_discover_failed)
// Offers this account already holds are not offers; they are the choices
// above, and listing them twice would let the same coordinator be picked
@@ -216,29 +269,29 @@ fun CordnCreateGroupScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringRes(R.string.cordn_create_explainer),
text = stringRes(Res.string.cordn_create_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StepHeading(1, stringRes(R.string.cordn_create_step_name))
StepHeading(1, stringRes(Res.string.cordn_create_step_name))
OutlinedTextField(
value = draft.name,
onValueChange = { draft.name = it },
label = { Text(stringRes(R.string.cordn_create_name)) },
label = { Text(stringRes(Res.string.cordn_create_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = draft.description,
onValueChange = { draft.description = it },
label = { Text(stringRes(R.string.cordn_create_description)) },
label = { Text(stringRes(Res.string.cordn_create_description)) },
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
StepHeading(2, stringRes(R.string.cordn_create_step_people))
StepHeading(2, stringRes(Res.string.cordn_create_step_people))
RosterSummary(
roster = draft.roster,
@@ -248,7 +301,7 @@ fun CordnCreateGroupScreen(
nav = nav,
)
StepHeading(3, stringRes(R.string.cordn_create_step_where))
StepHeading(3, stringRes(Res.string.cordn_create_step_where))
// Collapsed to the one coordinator that will be used, because with a
// draft.roster in hand there is usually nothing left to decide. The whole
@@ -320,9 +373,9 @@ fun CordnCreateGroupScreen(
TextButton(onClick = { draft.showAllLive = !draft.showAllLive }) {
Text(
if (draft.showAllLive) {
stringRes(R.string.cordn_coordinators_show_fewer)
stringRes(Res.string.cordn_coordinators_show_fewer)
} else {
stringRes(R.string.cordn_coordinators_show_all, live.size)
stringRes(Res.string.cordn_coordinators_show_all, live.size)
},
)
}
@@ -332,9 +385,9 @@ fun CordnCreateGroupScreen(
TextButton(onClick = { draft.showStale = !draft.showStale }) {
Text(
if (draft.showStale) {
stringRes(R.string.cordn_coordinators_hide_older)
stringRes(Res.string.cordn_coordinators_hide_older)
} else {
stringRes(R.string.cordn_coordinators_show_older, stale.size)
stringRes(Res.string.cordn_coordinators_show_older, stale.size)
},
)
}
@@ -342,7 +395,7 @@ fun CordnCreateGroupScreen(
if (draft.showStale && stale.isNotEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_stale_note),
text = stringRes(Res.string.cordn_coordinators_stale_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -366,7 +419,7 @@ fun CordnCreateGroupScreen(
}
CoordinatorChoice(
label = stringRes(R.string.cordn_create_coordinator_new),
label = stringRes(Res.string.cordn_create_coordinator_new),
pubKey = null,
selected = draft.selected == null,
onSelect = {
@@ -396,20 +449,20 @@ fun CordnCreateGroupScreen(
},
enabled = !discovering && !busy,
) {
Text(stringRes(R.string.cordn_create_coordinator_discover))
Text(stringRes(Res.string.cordn_create_coordinator_discover))
}
draft.discovered?.takeIf { offers.isEmpty() }?.let { result ->
Text(
text =
if (result.unreachable.isEmpty()) {
stringRes(R.string.cordn_coordinators_discover_none)
stringRes(Res.string.cordn_coordinators_discover_none)
} else {
// "Nobody is announcing" and "we were not told" are
// different answers and only one of them is final.
// Reporting the first for the second sends someone
// off to paste a pubkey by hand over a timeout.
stringRes(R.string.cordn_coordinators_discover_unheard, result.unreachable.size)
stringRes(Res.string.cordn_coordinators_discover_unheard, result.unreachable.size)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -427,7 +480,7 @@ fun CordnCreateGroupScreen(
draft.userPicked = true
error = null
},
label = { Text(stringRes(R.string.cordn_create_coordinator_pubkey)) },
label = { Text(stringRes(Res.string.cordn_create_coordinator_pubkey)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -438,7 +491,7 @@ fun CordnCreateGroupScreen(
draft.userPicked = true
error = null
},
label = { Text(stringRes(R.string.cordn_create_coordinator_relays)) },
label = { Text(stringRes(Res.string.cordn_create_coordinator_relays)) },
// A coordinator has no address beyond its pubkey (§8.5), so
// the relays are how it is reached and more than one is
// ordinary. One per line rather than comma-separated,
@@ -450,7 +503,7 @@ fun CordnCreateGroupScreen(
}
}
StepHeading(4, stringRes(R.string.cordn_create_step_admins))
StepHeading(4, stringRes(Res.string.cordn_create_step_admins))
// spec/01.md §5.3: leaving the admin list empty is not "set it up
// later", it is choosing draft.egalitarian permanently -- so it is offered
@@ -466,15 +519,15 @@ fun CordnCreateGroupScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(stringRes(R.string.cordn_create_egalitarian), style = MaterialTheme.typography.bodyMedium)
Text(stringRes(Res.string.cordn_create_egalitarian), style = MaterialTheme.typography.bodyMedium)
Text(
text =
if (draft.egalitarian) {
stringRes(R.string.cordn_create_egalitarian_note)
stringRes(Res.string.cordn_create_egalitarian_note)
} else if (draft.coAdmins.isEmpty()) {
stringRes(R.string.cordn_create_admin_only_me_on)
stringRes(Res.string.cordn_create_admin_only_me_on)
} else {
pluralStringRes(LocalContext.current, R.plurals.cordn_create_admin_count, draft.coAdmins.size + 1, draft.coAdmins.size + 1)
pluralStringRes(Res.plurals.cordn_create_admin_count, draft.coAdmins.size + 1, draft.coAdmins.size + 1)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -491,10 +544,7 @@ fun CordnCreateGroupScreen(
// serves for this account: the disclosure is about what
// one coordinator can correlate, so a second group on
// the same one widens it.
linkedGroupCount =
1 +
runtime.groups.all.value
.count { room -> room.coordinatorPubKey == it.pubKey },
linkedGroupCount = 1 + rooms.count { room -> room.coordinatorPubKey == it.pubKey },
joinedFromShareLink = false,
publishedKeyPackage = false,
encryptionPinned = true,
@@ -558,9 +608,9 @@ fun CordnCreateGroupScreen(
) {
Text(
if (draft.roster.isEmpty()) {
stringRes(R.string.cordn_create_action)
stringRes(Res.string.cordn_create_action)
} else {
pluralStringRes(LocalContext.current, R.plurals.cordn_create_action_invite, draft.roster.size, draft.roster.size)
pluralStringRes(Res.plurals.cordn_create_action_invite, draft.roster.size, draft.roster.size)
},
)
}
@@ -688,9 +738,9 @@ private fun resolvedName(
@Composable
private fun announcedLabel(announcedAt: Long): String =
if (TimeUtils.now() - announcedAt > TimeUtils.ONE_MONTH) {
stringRes(R.string.cordn_coordinators_discover_seen_on, timeAgoNoDot(announcedAt).trim())
stringRes(Res.string.cordn_coordinators_discover_seen_on, timeAgoNoDot(announcedAt).trim())
} else {
stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(announcedAt).trim())
stringRes(Res.string.cordn_coordinators_discover_seen, timeAgoNoDot(announcedAt).trim())
}
/** The hosts it answers on, the first two and a count of the rest. */
@@ -702,7 +752,7 @@ private fun relayLabel(relays: List<NormalizedRelayUrl>): String? {
return if (hosts.size <= 2) {
shown
} else {
stringRes(R.string.cordn_coordinators_relays_more, shown, hosts.size - 2)
stringRes(Res.string.cordn_coordinators_relays_more, shown, hosts.size - 2)
}
}
@@ -788,7 +838,7 @@ private fun RosterSummary(
) {
if (roster.isEmpty()) {
Text(
text = stringRes(R.string.cordn_create_people_none),
text = stringRes(Res.string.cordn_create_people_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -801,7 +851,7 @@ private fun RosterSummary(
}
Column(Modifier.weight(1f)) {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, roster.size + 1, roster.size + 1),
text = pluralStringRes(Res.plurals.cordn_member_count, roster.size + 1, roster.size + 1),
style = MaterialTheme.typography.bodyMedium,
)
// Counted over coordinators that answered, so an unreachable one
@@ -809,7 +859,7 @@ private fun RosterSummary(
val unreachable = roster.count { member -> coverage.values.none { it.answered && member in it.reachable } }
if (unreachable > 0 && coverage.values.any { it.answered }) {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_create_unreachable_count, unreachable, unreachable),
text = pluralStringRes(Res.plurals.cordn_create_unreachable_count, unreachable, unreachable),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
@@ -852,7 +902,7 @@ private fun CoordinatorSummary(
) {
if (config == null) {
Text(
text = stringRes(R.string.cordn_create_no_coordinator),
text = stringRes(Res.string.cordn_create_no_coordinator),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -876,9 +926,9 @@ private fun CoordinatorSummary(
Text(
text =
when {
coverage == null || !coverage.answered -> stringRes(R.string.cordn_create_coverage_unknown)
reached == rosterSize -> stringRes(R.string.cordn_create_coverage_all, rosterSize)
else -> stringRes(R.string.cordn_create_coverage_partial, reached ?: 0, rosterSize)
coverage == null || !coverage.answered -> stringRes(Res.string.cordn_create_coverage_unknown)
reached == rosterSize -> stringRes(Res.string.cordn_create_coverage_all, rosterSize)
else -> stringRes(Res.string.cordn_create_coverage_partial, reached ?: 0, rosterSize)
},
style = MaterialTheme.typography.labelSmall,
color =
@@ -896,9 +946,9 @@ private fun CoordinatorSummary(
TextButton(onClick = onToggle) {
Text(
if (expanded) {
stringRes(R.string.cordn_create_coordinator_hide)
stringRes(Res.string.cordn_create_coordinator_hide)
} else {
stringRes(R.string.cordn_create_coordinator_change)
stringRes(Res.string.cordn_create_coordinator_change)
},
)
}
@@ -931,11 +981,11 @@ private fun CordnCreationOutcome(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.cordn_create_outcome_title)) },
title = { Text(stringRes(Res.string.cordn_create_outcome_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringRes(R.string.cordn_create_outcome_explainer),
text = stringRes(Res.string.cordn_create_outcome_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -957,10 +1007,10 @@ private fun CordnCreationOutcome(
Text(
text =
when {
outcome == null -> stringRes(R.string.cordn_create_outcome_skipped)
outcome.sent -> stringRes(R.string.cordn_create_outcome_waiting)
outcome.joinedWithoutWelcome -> stringRes(R.string.cordn_create_outcome_no_welcome)
else -> outcome.failure?.message ?: stringRes(R.string.cordn_create_outcome_failed)
outcome == null -> stringRes(Res.string.cordn_create_outcome_skipped)
outcome.sent -> stringRes(Res.string.cordn_create_outcome_waiting)
outcome.joinedWithoutWelcome -> stringRes(Res.string.cordn_create_outcome_no_welcome)
else -> outcome.failure?.message ?: stringRes(Res.string.cordn_create_outcome_failed)
},
style = MaterialTheme.typography.labelSmall,
color =
@@ -976,12 +1026,12 @@ private fun CordnCreationOutcome(
// open themselves -- which needs the group to exist,
// and now it does.
if (outcome == null) {
TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_link)) }
TextButton(onClick = open) { Text(stringRes(Res.string.cordn_create_outcome_link)) }
}
}
}
}
},
confirmButton = { TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_open)) } },
confirmButton = { TextButton(onClick = open) { Text(stringRes(Res.string.cordn_create_outcome_open)) } },
)
}
@@ -47,15 +47,31 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.User
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_create_add_member
import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin
import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_done
import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_empty
import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_note
import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_count
import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_none
import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_unknown
import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_people
import com.vitorpamplona.amethyst.commons.resources.cordn_create_you
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail
import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_none
import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_placeholder
import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package
import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member
import com.vitorpamplona.amethyst.commons.resources.cordn_member_count
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
@@ -101,12 +117,12 @@ fun CordnCreateMembersScreen(
val draft = rememberCordnGroupDraft(accountViewModel)
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.cordn_create_step_people), nav) },
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_create_step_people), nav) },
) { padding ->
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
title = stringRes(Res.string.cordn_group_unavailable),
description = stringRes(Res.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
@@ -137,7 +153,7 @@ fun CordnCreateMembersScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringRes(R.string.cordn_create_people_note),
text = stringRes(Res.string.cordn_create_people_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -148,8 +164,8 @@ fun CordnCreateMembersScreen(
search = it
if (it.length > 2) userSuggestions.processCurrentWord(it) else userSuggestions.reset()
},
label = { Text(stringRes(R.string.cordn_create_add_member)) },
placeholder = { Text(stringRes(R.string.cordn_info_add_member_placeholder)) },
label = { Text(stringRes(Res.string.cordn_create_add_member)) },
placeholder = { Text(stringRes(Res.string.cordn_info_add_member_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -168,7 +184,7 @@ fun CordnCreateMembersScreen(
modifier = SuggestionListDefaultHeightChat,
onEmpty = {
Text(
text = stringRes(R.string.cordn_info_add_member_none),
text = stringRes(Res.string.cordn_info_add_member_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@@ -182,7 +198,7 @@ fun CordnCreateMembersScreen(
if (user.pubkeyHex in reachable) {
Icon(
symbol = MaterialSymbols.Key,
contentDescription = stringRes(R.string.cordn_info_has_key_package),
contentDescription = stringRes(Res.string.cordn_info_has_key_package),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
@@ -194,7 +210,7 @@ fun CordnCreateMembersScreen(
}) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = stringRes(R.string.cordn_create_add_member),
contentDescription = stringRes(Res.string.cordn_create_add_member),
tint = MaterialTheme.colorScheme.primary,
)
}
@@ -206,7 +222,7 @@ fun CordnCreateMembersScreen(
HorizontalDivider()
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, draft.roster.size + 1, draft.roster.size + 1),
text = pluralStringRes(Res.plurals.cordn_member_count, draft.roster.size + 1, draft.roster.size + 1),
style = MaterialTheme.typography.titleSmall,
)
@@ -230,7 +246,7 @@ fun CordnCreateMembersScreen(
if (draft.roster.isEmpty()) {
Text(
text = stringRes(R.string.cordn_create_people_empty),
text = stringRes(Res.string.cordn_create_people_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -240,7 +256,7 @@ fun CordnCreateMembersScreen(
onClick = { nav.popBack() },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Text(stringRes(R.string.cordn_create_people_done))
Text(stringRes(Res.string.cordn_create_people_done))
}
}
}
@@ -263,14 +279,14 @@ private fun CreatorRow(
Column(Modifier.weight(1f)) {
Text(observeUserNameByHex(me, accountViewModel), style = MaterialTheme.typography.bodyMedium)
Text(
text = stringRes(R.string.cordn_create_you),
text = stringRes(Res.string.cordn_create_you),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!egalitarian) {
Text(
text = stringRes(R.string.cordn_create_admin),
text = stringRes(Res.string.cordn_create_admin),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
@@ -304,9 +320,9 @@ private fun MemberRow(
Text(
text =
when {
answered == 0 -> stringRes(R.string.cordn_create_reach_unknown)
reaching > 0 -> pluralStringRes(LocalContext.current, R.plurals.cordn_create_reach_count, reaching, reaching)
else -> stringRes(R.string.cordn_create_reach_none)
answered == 0 -> stringRes(Res.string.cordn_create_reach_unknown)
reaching > 0 -> pluralStringRes(Res.plurals.cordn_create_reach_count, reaching, reaching)
else -> stringRes(Res.string.cordn_create_reach_none)
},
style = MaterialTheme.typography.labelSmall,
color =
@@ -323,7 +339,7 @@ private fun MemberRow(
if (!egalitarian) {
TextButton(onClick = onToggleAdmin) {
Text(
text = stringRes(R.string.cordn_create_admin),
text = stringRes(Res.string.cordn_create_admin),
style = MaterialTheme.typography.labelMedium,
color = if (isAdmin) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -333,7 +349,7 @@ private fun MemberRow(
IconButton(onClick = onRemove) {
Icon(
symbol = MaterialSymbols.PersonRemove,
contentDescription = stringRes(R.string.cordn_info_remove_member),
contentDescription = stringRes(Res.string.cordn_info_remove_member),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -69,7 +69,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -78,10 +77,33 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_action_editing
import com.vitorpamplona.amethyst.commons.resources.cordn_action_unpin
import com.vitorpamplona.amethyst.commons.resources.cordn_chat_empty_description
import com.vitorpamplona.amethyst.commons.resources.cordn_chat_empty_title
import com.vitorpamplona.amethyst.commons.resources.cordn_group_info
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.resources.cordn_media_download_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_media_no_server
import com.vitorpamplona.amethyst.commons.resources.cordn_media_unreadable
import com.vitorpamplona.amethyst.commons.resources.cordn_media_upload_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_member_count
import com.vitorpamplona.amethyst.commons.resources.cordn_message_deleted
import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_by
import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_count
import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_next
import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_previous
import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_show_all
import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_title
import com.vitorpamplona.amethyst.commons.resources.cordn_send_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_send_no_session
import com.vitorpamplona.amethyst.commons.resources.cordn_voice_record
import com.vitorpamplona.amethyst.commons.resources.cordn_voice_stop
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
@@ -152,7 +174,7 @@ fun CordnGroupChatScreen(
// (it was forgotten, or never opened) and not an error to throw at the
// user as a blank screen.
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) {
Text(stringRes(R.string.cordn_group_unavailable), style = MaterialTheme.typography.bodyLarge)
Text(stringRes(Res.string.cordn_group_unavailable), style = MaterialTheme.typography.bodyLarge)
}
return
}
@@ -209,9 +231,9 @@ private fun CordnGroupChat(
// and simply never arrived.
var sendError by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val uploadFailed = stringRes(R.string.cordn_media_upload_failed)
val sendFailed = stringRes(R.string.cordn_send_failed)
val noSession = stringRes(R.string.cordn_send_no_session)
val uploadFailed = stringRes(Res.string.cordn_media_upload_failed)
val sendFailed = stringRes(Res.string.cordn_send_failed)
val noSession = stringRes(Res.string.cordn_send_no_session)
fun manager() =
accountViewModel.account.cordnRuntime
@@ -359,8 +381,8 @@ private fun CordnGroupChat(
// action that actually needed it.
if (rows.isEmpty()) {
EmptyState(
title = stringRes(R.string.cordn_chat_empty_title),
description = stringRes(R.string.cordn_chat_empty_description),
title = stringRes(Res.string.cordn_chat_empty_title),
description = stringRes(Res.string.cordn_chat_empty_description),
modifier = Modifier.weight(1f),
)
}
@@ -548,7 +570,7 @@ private fun CordnGroupChat(
}
if (editing != null) {
ComposerBanner(
label = stringRes(R.string.cordn_action_editing),
label = stringRes(Res.string.cordn_action_editing),
onCancel = {
editing = null
room.draft.value = ""
@@ -712,7 +734,7 @@ private fun PinnedRibbon(
IconButton(onClick = { index = (at - 1 + pinned.size) % pinned.size }, modifier = Modifier.size(28.dp)) {
Icon(
symbol = MaterialSymbols.AutoMirrored.KeyboardArrowLeft,
contentDescription = stringRes(R.string.cordn_pinned_previous),
contentDescription = stringRes(Res.string.cordn_pinned_previous),
modifier = Modifier.size(18.dp),
)
}
@@ -724,7 +746,7 @@ private fun PinnedRibbon(
IconButton(onClick = { index = (at + 1) % pinned.size }, modifier = Modifier.size(28.dp)) {
Icon(
symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight,
contentDescription = stringRes(R.string.cordn_pinned_next),
contentDescription = stringRes(Res.string.cordn_pinned_next),
modifier = Modifier.size(18.dp),
)
}
@@ -733,7 +755,7 @@ private fun PinnedRibbon(
IconButton(onClick = { showingAll = true }, modifier = Modifier.size(28.dp)) {
Icon(
symbol = MaterialSymbols.AutoMirrored.List,
contentDescription = stringRes(R.string.cordn_pinned_show_all),
contentDescription = stringRes(Res.string.cordn_pinned_show_all),
modifier = Modifier.size(18.dp),
)
}
@@ -786,7 +808,7 @@ private fun PinnedLine(
// points at.
text =
if (annotations.isDeleted(message.envelope.id)) {
stringRes(R.string.cordn_message_deleted)
stringRes(Res.string.cordn_message_deleted)
} else {
annotations.contentOf(message.envelope.id).orEmpty()
},
@@ -822,7 +844,7 @@ private fun AllPinnedDialog(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(MaterialSymbols.PushPin, contentDescription = null, modifier = Modifier.size(18.dp))
Text(
text = stringRes(R.string.cordn_pinned_title),
text = stringRes(Res.string.cordn_pinned_title),
modifier = Modifier.padding(start = 8.dp),
)
}
@@ -830,7 +852,7 @@ private fun AllPinnedDialog(
text = {
Column {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_pinned_count, pinned.size, pinned.size),
text = pluralStringRes(Res.plurals.cordn_pinned_count, pinned.size, pinned.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -861,7 +883,7 @@ private fun AllPinnedDialog(
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
TextButton(onClick = { onUnpin(message) }) {
Text(stringRes(R.string.cordn_action_unpin), style = MaterialTheme.typography.labelSmall)
Text(stringRes(Res.string.cordn_action_unpin), style = MaterialTheme.typography.labelSmall)
}
}
@@ -869,7 +891,7 @@ private fun AllPinnedDialog(
Text(
text =
stringRes(
R.string.cordn_pinned_by,
Res.string.cordn_pinned_by,
observeUserNameByHex(pinnedBy, accountViewModel),
),
style = MaterialTheme.typography.labelSmall,
@@ -880,7 +902,7 @@ private fun AllPinnedDialog(
Text(
text =
if (annotations.isDeleted(message.envelope.id)) {
stringRes(R.string.cordn_message_deleted)
stringRes(Res.string.cordn_message_deleted)
} else {
annotations.contentOf(message.envelope.id).orEmpty()
},
@@ -944,7 +966,7 @@ private fun CordnChatTopBar(
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (members.isNotEmpty()) {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, members.size, members.size),
text = pluralStringRes(Res.plurals.cordn_member_count, members.size, members.size),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -963,7 +985,7 @@ private fun CordnChatTopBar(
// GroupAdd rather than Info, as in Marmot: the same screen, and the thing
// people come to it for is adding someone.
IconButton(onClick = onInfo) {
Icon(MaterialSymbols.GroupAdd, contentDescription = stringRes(R.string.cordn_group_info))
Icon(MaterialSymbols.GroupAdd, contentDescription = stringRes(Res.string.cordn_group_info))
}
},
)
@@ -995,10 +1017,10 @@ private suspend fun sendAttachment(
// a reason a person can act on.
val session =
accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session))
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session))
val group =
session.manager.group(room.gid)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session))
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session))
// The gallery inside the dialog can delete what was picked, which leaves an empty
// orchestrator rather than a null one. canPost() now refuses that, but indexing it
@@ -1052,14 +1074,14 @@ private suspend fun sendAttachment(
val name = withContext(Dispatchers.IO) { resolveDisplayName(context, uri) }
val bytes =
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(finalUri)?.use { it.readBytes() } }
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_unreadable))
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_unreadable))
// Null means the chosen host has no base URL, which is a setting the person can
// change — the one failure here that is entirely actionable.
val tag =
CordnMediaService(accountViewModel.account)
.upload(group, bytes, mime, name, context, state.selectedServer.baseUrl)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_no_server))
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_no_server))
// Into the room as well, for the same reason every other send is: an
// attachment of your own echoes back as an Echo and would otherwise be
@@ -1140,7 +1162,7 @@ internal fun CordnAttachment(
if (mediaKey == null) {
Text(
text = stringRes(R.string.cordn_media_download_failed),
text = stringRes(Res.string.cordn_media_download_failed),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
@@ -1254,7 +1276,7 @@ internal fun VoiceNoteButton(
) {
Icon(
symbol = if (recording) MaterialSymbols.Stop else MaterialSymbols.Mic,
contentDescription = stringRes(if (recording) R.string.cordn_voice_stop else R.string.cordn_voice_record),
contentDescription = stringRes(if (recording) Res.string.cordn_voice_stop else Res.string.cordn_voice_record),
// Matches the attach icon beside it; red only while recording,
// which is the one state worth pulling the eye.
tint = if (recording) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.placeholderText,
@@ -1284,10 +1306,10 @@ private suspend fun sendVoiceNote(
// note that goes nowhere and says nothing is the same bug twice.
val session =
accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session))
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session))
val group =
session.manager.group(room.gid)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session))
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session))
try {
val bytes = withContext(Dispatchers.IO) { recording.file.readBytes() }
@@ -1304,7 +1326,7 @@ private suspend fun sendVoiceNote(
// is what makes the bubble match the preview.
waveform = recording.amplitudes,
)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_no_server))
?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_no_server))
// Into the room as well, for the same reason every other send is: an
// attachment of your own echoes back as an Echo and would otherwise be
// invisible to the person who sent it.
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import android.content.Context
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
@@ -58,12 +57,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CordnGroupException
import com.vitorpamplona.amethyst.commons.cordn.GroupExposure
import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard
@@ -76,8 +73,58 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.copy_npub_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_action_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_coordinator_silent
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_key_package
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_self_remove
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_welcome
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_not_a_member
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_someone
import com.vitorpamplona.amethyst.commons.resources.cordn_admin_wrong_key_package
import com.vitorpamplona.amethyst.commons.resources.cordn_create_description
import com.vitorpamplona.amethyst.commons.resources.cordn_create_name
import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_close
import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_open
import com.vitorpamplona.amethyst.commons.resources.cordn_group_info
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member
import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_none
import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_note
import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_placeholder
import com.vitorpamplona.amethyst.commons.resources.cordn_info_admin_badge
import com.vitorpamplona.amethyst.commons.resources.cordn_info_admins_kept
import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator
import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator_key
import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator_nprofile
import com.vitorpamplona.amethyst.commons.resources.cordn_info_copy_nprofile
import com.vitorpamplona.amethyst.commons.resources.cordn_info_edit_details
import com.vitorpamplona.amethyst.commons.resources.cordn_info_egalitarian
import com.vitorpamplona.amethyst.commons.resources.cordn_info_epoch
import com.vitorpamplona.amethyst.commons.resources.cordn_info_gid
import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package
import com.vitorpamplona.amethyst.commons.resources.cordn_info_members
import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_confirm_body
import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_confirm_title
import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member
import com.vitorpamplona.amethyst.commons.resources.cordn_info_save
import com.vitorpamplona.amethyst.commons.resources.cordn_info_technical
import com.vitorpamplona.amethyst.commons.resources.cordn_member_count
import com.vitorpamplona.amethyst.commons.resources.cordn_requests_accept
import com.vitorpamplona.amethyst.commons.resources.cordn_requests_admin_only
import com.vitorpamplona.amethyst.commons.resources.cordn_requests_check
import com.vitorpamplona.amethyst.commons.resources.cordn_requests_decline
import com.vitorpamplona.amethyst.commons.resources.cordn_requests_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_requests_none
import com.vitorpamplona.amethyst.commons.resources.cordn_requests_title
import com.vitorpamplona.amethyst.commons.resources.cordn_send_no_session
import com.vitorpamplona.amethyst.commons.resources.cordn_share_copied
import com.vitorpamplona.amethyst.commons.resources.cordn_share_copy
import com.vitorpamplona.amethyst.commons.resources.cordn_share_explainer
import com.vitorpamplona.amethyst.commons.resources.cordn_share_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
@@ -124,8 +171,8 @@ fun CordnGroupInfoScreen(
// The app's own empty state, centred and titled, rather than a
// sentence stranded in the top-left corner.
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
title = stringRes(Res.string.cordn_group_unavailable),
description = stringRes(Res.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
}
@@ -151,7 +198,7 @@ private fun InfoTopBar(
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back))
}
},
title = { Text(stringRes(R.string.cordn_group_info)) },
title = { Text(stringRes(Res.string.cordn_group_info)) },
actions = actions,
)
}
@@ -208,8 +255,8 @@ private fun CordnGroupInfo(
DisposableEffect(Unit) {
onDispose { userSuggestions.reset() }
}
val failed = stringRes(R.string.cordn_admin_action_failed)
val noSession = stringRes(R.string.cordn_send_no_session)
val failed = stringRes(Res.string.cordn_admin_action_failed)
val noSession = stringRes(Res.string.cordn_send_no_session)
// Computed, never asserted: hardcoding these made the card look like a
// disclosure while reporting the same four values for every group.
@@ -236,8 +283,6 @@ private fun CordnGroupInfo(
* roster, and a rename stayed the old name. The runtime's own methods pair
* each commit with that refresh.
*/
val context = LocalContext.current
fun runAdmin(
who: String? = null,
block: suspend (CordnRuntime) -> Unit,
@@ -257,7 +302,7 @@ private fun CordnGroupInfo(
// and gids in full, so the screen that had just shown a face
// answered with 64 hex characters.
Log.w("CordnGroupInfo", "admin action failed in ${room.gid}: ${e.message}", e)
adminError = adminFailureText(context, e, who, failed)
adminError = adminFailureText(e, who, failed)
} finally {
busy = false
}
@@ -274,7 +319,7 @@ private fun CordnGroupInfo(
IconButton(onClick = { renaming = true }, enabled = !busy) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription = stringRes(R.string.cordn_info_edit_details),
contentDescription = stringRes(Res.string.cordn_info_edit_details),
)
}
}
@@ -303,7 +348,7 @@ private fun CordnGroupInfo(
}
// A subtitle, not a labelled row above a list that is itself the count.
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, members.size, members.size),
text = pluralStringRes(Res.plurals.cordn_member_count, members.size, members.size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
@@ -321,7 +366,7 @@ private fun CordnGroupInfo(
// ContextVM addresses it with ordinary p-tags, so it has a kind 0 like
// anyone else and the app can already resolve it to a name, an avatar
// and a profile to tap through to.
Text(stringRes(R.string.cordn_info_coordinator), style = MaterialTheme.typography.titleMedium)
Text(stringRes(Res.string.cordn_info_coordinator), style = MaterialTheme.typography.titleMedium)
Row(
Modifier.fillMaxWidth().padding(top = 8.dp),
@@ -347,7 +392,7 @@ private fun CordnGroupInfo(
IconButton(onClick = { showExposure = true }, enabled = exposure != null) {
Icon(
symbol = MaterialSymbols.Info,
contentDescription = stringRes(R.string.cordn_exposure_open),
contentDescription = stringRes(Res.string.cordn_exposure_open),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -355,7 +400,7 @@ private fun CordnGroupInfo(
HorizontalDivider(Modifier.padding(vertical = 16.dp))
Text(stringRes(R.string.cordn_info_members), style = MaterialTheme.typography.titleMedium)
Text(stringRes(Res.string.cordn_info_members), style = MaterialTheme.typography.titleMedium)
// Said once, and only when it is true. An empty admin set is not "none
// configured" -- spec/01.md makes it permanently egalitarian, so the
@@ -364,7 +409,7 @@ private fun CordnGroupInfo(
// a count of them adds nothing.
if (admins.isEmpty()) {
Text(
text = stringRes(R.string.cordn_info_egalitarian),
text = stringRes(Res.string.cordn_info_egalitarian),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp, bottom = 4.dp),
@@ -385,7 +430,7 @@ private fun CordnGroupInfo(
)
if (member in admins) {
Text(
text = stringRes(R.string.cordn_info_admin_badge),
text = stringRes(Res.string.cordn_info_admin_badge),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -398,7 +443,7 @@ private fun CordnGroupInfo(
IconButton(onClick = { removing = member }, enabled = !busy) {
Icon(
symbol = MaterialSymbols.PersonRemove,
contentDescription = stringRes(R.string.cordn_info_remove_member),
contentDescription = stringRes(Res.string.cordn_info_remove_member),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
@@ -476,9 +521,9 @@ private fun CordnGroupInfo(
// would be a setting nobody should be nudged into.
AlertDialog(
onDismissRequest = { removing = null },
title = { Text(stringRes(R.string.cordn_info_remove_confirm_title)) },
title = { Text(stringRes(Res.string.cordn_info_remove_confirm_title)) },
text = {
Text(stringRes(R.string.cordn_info_remove_confirm_body, removingName))
Text(stringRes(Res.string.cordn_info_remove_confirm_body, removingName))
},
confirmButton = {
TextButton(onClick = {
@@ -486,7 +531,7 @@ private fun CordnGroupInfo(
runAdmin(removingName) { it.removeMember(coordinatorPubKey, room.gid, target) }
}) {
Text(
text = stringRes(R.string.cordn_info_remove_member),
text = stringRes(Res.string.cordn_info_remove_member),
color = MaterialTheme.colorScheme.error,
)
}
@@ -518,7 +563,7 @@ private fun CordnGroupInfo(
text = { CordnExposureCard(it) },
confirmButton = {
TextButton(onClick = { showExposure = false }) {
Text(stringRes(R.string.cordn_exposure_close))
Text(stringRes(Res.string.cordn_exposure_close))
}
},
)
@@ -577,15 +622,15 @@ private fun CordnAddMember(
OutlinedTextField(
value = search,
onValueChange = onSearchChange,
label = { Text(stringRes(R.string.cordn_info_add_member)) },
placeholder = { Text(stringRes(R.string.cordn_info_add_member_placeholder)) },
label = { Text(stringRes(Res.string.cordn_info_add_member)) },
placeholder = { Text(stringRes(Res.string.cordn_info_add_member_placeholder)) },
singleLine = true,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = stringRes(R.string.cordn_info_add_member_note),
text = stringRes(Res.string.cordn_info_add_member_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
@@ -601,7 +646,7 @@ private fun CordnAddMember(
modifier = SuggestionListDefaultHeightChat,
onEmpty = {
Text(
text = stringRes(R.string.cordn_info_add_member_none),
text = stringRes(Res.string.cordn_info_add_member_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@@ -612,7 +657,7 @@ private fun CordnAddMember(
if (reachable?.contains(user.pubkeyHex) == true) {
Icon(
symbol = MaterialSymbols.Key,
contentDescription = stringRes(R.string.cordn_info_has_key_package),
contentDescription = stringRes(Res.string.cordn_info_has_key_package),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
@@ -620,7 +665,7 @@ private fun CordnAddMember(
IconButton(onClick = { onInvite(user) }) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = stringRes(R.string.cordn_info_add_member),
contentDescription = stringRes(Res.string.cordn_info_add_member),
tint = MaterialTheme.colorScheme.primary,
)
}
@@ -643,20 +688,19 @@ private fun CordnAddMember(
* better than silence — and the caller has logged the throwable in full either
* way.
*/
private fun adminFailureText(
context: Context,
private suspend fun adminFailureText(
e: Throwable,
who: String?,
fallback: String,
): String {
val name = who ?: stringRes(context, R.string.cordn_admin_someone)
val name = who ?: loadStringRes(Res.string.cordn_admin_someone)
return when (e) {
is CordnGroupException ->
when (e.reason) {
CordnGroupException.Reason.NO_KEY_PACKAGE -> stringRes(context, R.string.cordn_admin_no_key_package, name)
CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER -> stringRes(context, R.string.cordn_admin_wrong_key_package, name)
CordnGroupException.Reason.NO_WELCOME -> stringRes(context, R.string.cordn_admin_no_welcome, name)
CordnGroupException.Reason.NOT_A_MEMBER -> stringRes(context, R.string.cordn_admin_not_a_member, name)
CordnGroupException.Reason.NO_KEY_PACKAGE -> loadStringRes(Res.string.cordn_admin_no_key_package, name)
CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER -> loadStringRes(Res.string.cordn_admin_wrong_key_package, name)
CordnGroupException.Reason.NO_WELCOME -> loadStringRes(Res.string.cordn_admin_no_welcome, name)
CordnGroupException.Reason.NOT_A_MEMBER -> loadStringRes(Res.string.cordn_admin_not_a_member, name)
CordnGroupException.Reason.OTHER -> e.message ?: fallback
}
@@ -664,14 +708,14 @@ private fun adminFailureText(
// roster and the button disagreed rather than that anyone tried.
is IllegalArgumentException ->
if (e.message?.contains("self-removal") == true) {
stringRes(context, R.string.cordn_admin_no_self_remove)
loadStringRes(Res.string.cordn_admin_no_self_remove)
} else {
e.message ?: fallback
}
// A coordinator that does not answer is the single commonest failure
// here and says nothing useful in its own words.
is TimeoutCancellationException -> stringRes(context, R.string.cordn_admin_coordinator_silent)
is TimeoutCancellationException -> loadStringRes(Res.string.cordn_admin_coordinator_silent)
else -> e.message ?: fallback
}
@@ -701,7 +745,7 @@ private fun TechnicalDetails(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.cordn_info_technical),
text = stringRes(Res.string.cordn_info_technical),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
@@ -725,22 +769,22 @@ private fun TechnicalDetails(
val coordinator = remember(coordinatorPubKey) { LocalCache.getOrCreateUser(coordinatorPubKey) }
CopyableKeyRow(
label = stringRes(R.string.cordn_info_coordinator_key),
label = stringRes(Res.string.cordn_info_coordinator_key),
shown = coordinator.pubkeyDisplayHex(),
copied = coordinator.pubkeyNpub(),
copyDescription = stringRes(Res.string.copy_npub_to_clipboard),
)
CopyableKeyRow(
label = stringRes(R.string.cordn_info_coordinator_nprofile),
label = stringRes(Res.string.cordn_info_coordinator_nprofile),
shown = coordinator.toNProfile().toShortDisplay(6),
copied = coordinator.toNProfile(),
copyDescription = stringRes(R.string.cordn_info_copy_nprofile),
copyDescription = stringRes(Res.string.cordn_info_copy_nprofile),
)
SelectionContainer {
Column {
InfoRow(stringRes(R.string.cordn_info_gid), gid)
InfoRow(stringRes(R.string.cordn_info_epoch), epoch.toString())
InfoRow(stringRes(Res.string.cordn_info_gid), gid)
InfoRow(stringRes(Res.string.cordn_info_epoch), epoch.toString())
}
}
}
@@ -835,7 +879,7 @@ private fun JoinRequests(
) {
val runtime = accountViewModel.account.cordnRuntime ?: return
val scope = rememberCoroutineScope()
val failed = stringRes(R.string.cordn_requests_failed)
val failed = stringRes(Res.string.cordn_requests_failed)
var requests by remember { mutableStateOf<List<JoinRequest>?>(null) }
var busy by remember { mutableStateOf(false) }
@@ -853,15 +897,15 @@ private fun JoinRequests(
}
}
Text(stringRes(R.string.cordn_requests_title), style = MaterialTheme.typography.titleMedium)
Text(stringRes(Res.string.cordn_requests_title), style = MaterialTheme.typography.titleMedium)
Text(
text = stringRes(R.string.cordn_requests_admin_only),
text = stringRes(Res.string.cordn_requests_admin_only),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { scope.launch { reload() } }, enabled = !busy) {
Text(stringRes(R.string.cordn_requests_check))
Text(stringRes(Res.string.cordn_requests_check))
}
error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) }
@@ -870,7 +914,7 @@ private fun JoinRequests(
if (loaded != null && !busy) {
if (loaded.isEmpty()) {
Text(
text = stringRes(R.string.cordn_requests_none),
text = stringRes(Res.string.cordn_requests_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -899,7 +943,7 @@ private fun JoinRequests(
reload()
}
}) {
Text(stringRes(R.string.cordn_requests_accept))
Text(stringRes(Res.string.cordn_requests_accept))
}
TextButton(onClick = {
scope.launch {
@@ -911,7 +955,7 @@ private fun JoinRequests(
reload()
}
}) {
Text(stringRes(R.string.cordn_requests_decline))
Text(stringRes(Res.string.cordn_requests_decline))
}
}
}
@@ -949,9 +993,9 @@ private fun ShareGroup(
val clipboard = LocalClipboardManager.current
var copied by remember { mutableStateOf(false) }
Text(stringRes(R.string.cordn_share_title), style = MaterialTheme.typography.titleMedium)
Text(stringRes(Res.string.cordn_share_title), style = MaterialTheme.typography.titleMedium)
Text(
text = stringRes(R.string.cordn_share_explainer),
text = stringRes(Res.string.cordn_share_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -968,7 +1012,7 @@ private fun ShareGroup(
},
modifier = Modifier.padding(top = 8.dp),
) {
Text(stringRes(if (copied) R.string.cordn_share_copied else R.string.cordn_share_copy))
Text(stringRes(if (copied) Res.string.cordn_share_copied else Res.string.cordn_share_copy))
}
}
@@ -993,26 +1037,26 @@ private fun EditGroupDetailsDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.cordn_info_edit_details)) },
title = { Text(stringRes(Res.string.cordn_info_edit_details)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = draftName,
onValueChange = { draftName = it },
label = { Text(stringRes(R.string.cordn_create_name)) },
label = { Text(stringRes(Res.string.cordn_create_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = draftDescription,
onValueChange = { draftDescription = it },
label = { Text(stringRes(R.string.cordn_create_description)) },
label = { Text(stringRes(Res.string.cordn_create_description)) },
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
if (admins.isNotEmpty()) {
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_info_admins_kept, admins.size, admins.size),
text = pluralStringRes(Res.plurals.cordn_info_admins_kept, admins.size, admins.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1024,7 +1068,7 @@ private fun EditGroupDetailsDialog(
onClick = { onSave(draftName.trim(), draftDescription.trim()) },
enabled = draftName.isNotBlank(),
) {
Text(stringRes(R.string.cordn_info_save))
Text(stringRes(Res.string.cordn_info_save))
}
},
dismissButton = {
@@ -41,10 +41,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth
import com.vitorpamplona.amethyst.commons.cordn.ui.CoordinatorHealthRow
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -52,6 +50,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_count
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_down_note
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_manage
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none_detail
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_start
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -106,14 +112,14 @@ fun CordnGroupListScreen(
IconButton(onClick = { nav.nav(Route.CordnCoordinators) }) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = stringRes(R.string.cordn_groups_manage),
contentDescription = stringRes(Res.string.cordn_groups_manage),
modifier = Modifier.size(22.dp),
)
}
IconButton(onClick = { nav.nav(Route.CordnCreateGroup) }) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.cordn_groups_start),
contentDescription = stringRes(Res.string.cordn_groups_start),
modifier = Modifier.size(24.dp),
)
}
@@ -122,8 +128,8 @@ fun CordnGroupListScreen(
) { padding ->
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
title = stringRes(Res.string.cordn_group_unavailable),
description = stringRes(Res.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
@@ -148,8 +154,8 @@ fun CordnGroupListScreen(
if (sections.isEmpty()) {
EmptyState(
title = stringRes(R.string.cordn_groups_none),
description = stringRes(R.string.cordn_groups_none_detail),
title = stringRes(Res.string.cordn_groups_none),
description = stringRes(Res.string.cordn_groups_none_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
@@ -230,7 +236,7 @@ private fun CoordinatorSection(
},
)
Text(
text = pluralStringRes(LocalContext.current, R.plurals.cordn_groups_count, groupCount, groupCount),
text = pluralStringRes(Res.plurals.cordn_groups_count, groupCount, groupCount),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -248,7 +254,7 @@ private fun CoordinatorSection(
verticalAlignment = Alignment.Top,
) {
Text(
text = stringRes(R.string.cordn_groups_down_note),
text = stringRes(Res.string.cordn_groups_down_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -48,15 +48,30 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable
import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_accept
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_decline
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_decline_warning
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_explainer
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_load_failed
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_members
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_members_more
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_no_coordinators
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_none
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_refresh
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_skipped
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_title
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_unreachable
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_via
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.model.cordn.CordnInvitation
@@ -68,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorI
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
/**
* Invitations to cordn groups, waiting to be answered.
@@ -105,7 +121,7 @@ fun CordnInvitationsScreen(
var invitations by remember { mutableStateOf<CordnInvitations?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val loadFailed = stringRes(R.string.cordn_invitations_load_failed)
val loadFailed = stringRes(Res.string.cordn_invitations_load_failed)
suspend fun reload() {
busy = true
@@ -129,10 +145,10 @@ fun CordnInvitationsScreen(
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back))
}
},
title = { Text(stringRes(R.string.cordn_invitations_title)) },
title = { Text(stringRes(Res.string.cordn_invitations_title)) },
actions = {
IconButton(onClick = { scope.launch { reload() } }, enabled = !busy) {
Icon(MaterialSymbols.Refresh, contentDescription = stringRes(R.string.cordn_invitations_refresh))
Icon(MaterialSymbols.Refresh, contentDescription = stringRes(Res.string.cordn_invitations_refresh))
}
},
)
@@ -149,14 +165,20 @@ fun CordnInvitationsScreen(
) {
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
title = stringRes(Res.string.cordn_group_unavailable),
description = stringRes(Res.string.cordn_group_unavailable_detail),
)
return@Column
}
// Collected rather than read as `.value` further down: the empty
// state says either "you have no coordinators" or "nobody has
// invited you", and adding a coordinator in another tab has to
// change which of those two this screen is showing.
val coordinators by runtime.coordinators.collectAsStateWithLifecycle()
Text(
text = stringRes(R.string.cordn_invitations_explainer),
text = stringRes(Res.string.cordn_invitations_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -205,7 +227,7 @@ fun CordnInvitationsScreen(
NoticeCard(
title =
stringRes(
R.string.cordn_invitations_unreachable,
Res.string.cordn_invitations_unreachable,
coordinatorDisplayName(it.coordinator.pubKey, it.coordinator.label, accountViewModel),
),
detail = it.reason,
@@ -218,7 +240,7 @@ fun CordnInvitationsScreen(
// friend swears they sent simply does not exist here.
loaded.skipped.forEach {
NoticeCard(
title = stringRes(R.string.cordn_invitations_skipped),
title = stringRes(Res.string.cordn_invitations_skipped),
detail = it.reason,
isError = false,
)
@@ -227,10 +249,10 @@ fun CordnInvitationsScreen(
if (loaded.isEmpty) {
Text(
text =
if (runtime.coordinators.value.isEmpty()) {
stringRes(R.string.cordn_invitations_no_coordinators)
if (coordinators.isEmpty()) {
stringRes(Res.string.cordn_invitations_no_coordinators)
} else {
stringRes(R.string.cordn_invitations_none)
stringRes(Res.string.cordn_invitations_none)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -269,7 +291,7 @@ private fun InvitationCard(
// "Already in it", never "invited you" — see the screen KDoc.
Text(
text = pluralStringResource(R.plurals.cordn_invitations_members, welcome.members.size, welcome.members.size),
text = pluralStringResource(Res.plurals.cordn_invitations_members, welcome.members.size, welcome.members.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -293,7 +315,7 @@ private fun InvitationCard(
}
if (welcome.members.size > MEMBER_FACES) {
Text(
text = stringRes(R.string.cordn_invitations_members_more, welcome.members.size - MEMBER_FACES),
text = stringRes(Res.string.cordn_invitations_members_more, welcome.members.size - MEMBER_FACES),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -322,7 +344,7 @@ private fun InvitationCard(
size = 20.dp,
) { name ->
Text(
text = stringRes(R.string.cordn_invitations_via, name),
text = stringRes(Res.string.cordn_invitations_via, name),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -333,11 +355,11 @@ private fun InvitationCard(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = onAccept) { Text(stringRes(R.string.cordn_invitations_accept)) }
TextButton(onClick = onDecline) { Text(stringRes(R.string.cordn_invitations_decline)) }
Button(onClick = onAccept) { Text(stringRes(Res.string.cordn_invitations_accept)) }
TextButton(onClick = onDecline) { Text(stringRes(Res.string.cordn_invitations_decline)) }
}
Text(
text = stringRes(R.string.cordn_invitations_decline_warning),
text = stringRes(Res.string.cordn_invitations_decline_warning),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -55,7 +55,6 @@ import androidx.compose.ui.text.font.FontWeight
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.chats.ui.ChatDivisor
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.cordn.CordnMentions
@@ -67,6 +66,24 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.copied_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.copy_text
import com.vitorpamplona.amethyst.commons.resources.cordn_action_delete
import com.vitorpamplona.amethyst.commons.resources.cordn_action_edit
import com.vitorpamplona.amethyst.commons.resources.cordn_action_go_to_message
import com.vitorpamplona.amethyst.commons.resources.cordn_action_pin
import com.vitorpamplona.amethyst.commons.resources.cordn_action_reply
import com.vitorpamplona.amethyst.commons.resources.cordn_action_unpin
import com.vitorpamplona.amethyst.commons.resources.cordn_chat_unread_divider
import com.vitorpamplona.amethyst.commons.resources.cordn_chat_yesterday
import com.vitorpamplona.amethyst.commons.resources.cordn_delete_confirm_body
import com.vitorpamplona.amethyst.commons.resources.cordn_delete_confirm_title
import com.vitorpamplona.amethyst.commons.resources.cordn_delivery_accepted
import com.vitorpamplona.amethyst.commons.resources.cordn_message_deleted
import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_coordinator
import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_cursor
import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_sent_at
import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_title
import com.vitorpamplona.amethyst.commons.resources.cordn_message_edited
import com.vitorpamplona.amethyst.commons.resources.cordn_reactions_title
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.resources.today
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
@@ -292,7 +309,7 @@ private fun CordnBubbleContents(
when {
text == null ->
Text(
text = stringRes(R.string.cordn_message_deleted),
text = stringRes(Res.string.cordn_message_deleted),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -448,7 +465,7 @@ private fun CordnReactionDetailSheet(
) {
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
Text(
text = stringRes(R.string.cordn_reactions_title),
text = stringRes(Res.string.cordn_reactions_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
)
@@ -519,7 +536,7 @@ internal fun CordnQuotedMessage(
val body =
if (annotations.isDeleted(parent.envelope.id)) {
stringRes(R.string.cordn_message_deleted)
stringRes(Res.string.cordn_message_deleted)
} else {
annotations.contentOf(parent.envelope.id).orEmpty()
}
@@ -575,7 +592,7 @@ private fun CordnQuoteActionSheet(
Column(Modifier.padding(bottom = 24.dp)) {
TileRow {
if (onGoToMessage != null) {
ActionTile(MaterialSymbols.ArrowUpward, stringRes(R.string.cordn_action_go_to_message)) {
ActionTile(MaterialSymbols.ArrowUpward, stringRes(Res.string.cordn_action_go_to_message)) {
onGoToMessage()
onDismiss()
}
@@ -594,7 +611,7 @@ private fun CordnQuoteActionSheet(
*/
@Composable
internal fun UnreadDivider() {
ChatDivisor(stringRes(R.string.cordn_chat_unread_divider), MaterialTheme.colorScheme.primary)
ChatDivisor(stringRes(Res.string.cordn_chat_unread_divider), MaterialTheme.colorScheme.primary)
}
/**
@@ -618,7 +635,7 @@ private fun CordnMessageFooter(
if (isPinned) {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = stringRes(R.string.cordn_action_pin),
contentDescription = stringRes(Res.string.cordn_action_pin),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary,
)
@@ -627,7 +644,7 @@ private fun CordnMessageFooter(
if (isEdited) {
Text(
text = stringRes(R.string.cordn_message_edited),
text = stringRes(Res.string.cordn_message_edited),
fontSize = Font12SP,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
@@ -658,7 +675,7 @@ private fun CordnMessageFooter(
Spacer(StdHorzSpacer)
Icon(
symbol = MaterialSymbols.Done,
contentDescription = stringRes(R.string.cordn_delivery_accepted),
contentDescription = stringRes(Res.string.cordn_delivery_accepted),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.allGoodColor,
)
@@ -711,18 +728,18 @@ private fun CordnMessageDetailsSheet(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringRes(R.string.cordn_message_details_title),
text = stringRes(Res.string.cordn_message_details_title),
style = MaterialTheme.typography.titleSmall,
)
DetailLine(stringRes(R.string.cordn_message_details_sent_at), sentAt)
DetailLine(stringRes(R.string.cordn_message_details_cursor), message.cursor.toString())
DetailLine(stringRes(Res.string.cordn_message_details_sent_at), sentAt)
DetailLine(stringRes(Res.string.cordn_message_details_cursor), message.cursor.toString())
DetailLine(
stringRes(R.string.cordn_message_details_coordinator),
stringRes(Res.string.cordn_message_details_coordinator),
coordinatorDisplayName(coordinatorPubKey, label = null, accountViewModel = accountViewModel),
)
if (isMine) {
Text(
text = stringRes(R.string.cordn_delivery_accepted),
text = stringRes(Res.string.cordn_delivery_accepted),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.allGoodColor,
)
@@ -797,10 +814,10 @@ private fun CordnMessageActionSheet(
if (confirmingDelete) {
QuickActionAlertDialog(
title = stringRes(R.string.cordn_delete_confirm_title),
textContent = stringRes(R.string.cordn_delete_confirm_body),
title = stringRes(Res.string.cordn_delete_confirm_title),
textContent = stringRes(Res.string.cordn_delete_confirm_body),
buttonIcon = MaterialSymbols.Delete,
buttonText = stringRes(R.string.cordn_action_delete),
buttonText = stringRes(Res.string.cordn_action_delete),
onClickDoOnce = performDelete,
onClickDontShowAgain = {
accountViewModel.account.settings.setHideDeleteRequestDialog()
@@ -843,7 +860,7 @@ private fun CordnMessageActionSheet(
TileRow {
if (isLive) {
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.cordn_action_reply)) {
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(Res.string.cordn_action_reply)) {
onReply()
onDismiss()
}
@@ -853,7 +870,7 @@ private fun CordnMessageActionSheet(
// message rather than only on your own.
ActionTile(
MaterialSymbols.PushPin,
stringRes(if (isPinned) R.string.cordn_action_unpin else R.string.cordn_action_pin),
stringRes(if (isPinned) Res.string.cordn_action_unpin else Res.string.cordn_action_pin),
) {
onTogglePin()
onDismiss()
@@ -863,11 +880,11 @@ private fun CordnMessageActionSheet(
// anyone else. Hiding them here is the same rule, stated where it stops
// being a surprise.
if (isMine && isLive) {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.cordn_action_edit)) {
ActionTile(MaterialSymbols.Edit, stringRes(Res.string.cordn_action_edit)) {
onEdit()
onDismiss()
}
ActionTile(MaterialSymbols.Delete, stringRes(R.string.cordn_action_delete), isDestructive = true) {
ActionTile(MaterialSymbols.Delete, stringRes(Res.string.cordn_action_delete), isDestructive = true) {
// A withdrawal cannot be taken back and the coordinator keeps
// the ciphertext either way, so it is worth one question —
// unless the account has already opted out of being asked.
@@ -989,7 +1006,7 @@ internal fun DaySeparator(
val label =
when (day) {
today -> stringRes(Res.string.today)
today.minusDays(1) -> stringRes(R.string.cordn_chat_yesterday)
today.minusDays(1) -> stringRes(Res.string.cordn_chat_yesterday)
// Year included only when it is not this one: printing 2026 on every
// divider all year is noise.
else ->
@@ -27,8 +27,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -74,6 +74,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.looksLikeNonNip29Relay
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_create_title
import com.vitorpamplona.amethyst.commons.resources.buzz_community_add_people
@@ -103,8 +105,6 @@ import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count
import com.vitorpamplona.amethyst.commons.resources.relay_group_parent_desc
@@ -80,7 +81,6 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.looksLikeNonNip29Relay
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_group_relay_not_nip29
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -106,7 +106,9 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_server_label
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
import com.vitorpamplona.amethyst.commons.resources.unpin_conversation
import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
@@ -118,7 +120,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.newItemBubbleModifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
@@ -126,7 +127,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
@@ -1377,8 +1377,3 @@ private fun TimeAgo(channelLastTime: Long?) {
color = MaterialTheme.colorScheme.grayText,
)
}
@Composable
fun NewItemsBubble() {
Box(MaterialTheme.colorScheme.newItemBubbleModifier)
}
@@ -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.amethyst.ui.screen.loggedIn.chess
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.vitorpamplona.amethyst.Amethyst
/**
* Where Android keeps the dismissed-chess-games store.
*
* A new file rather than a migration of `chess_dismissed_games`: the dismissed
* list is a convenience, and chess has few enough users that carrying the old
* data over is not worth the code. Anyone who had dismissed a game sees it once
* more and dismisses it again.
*/
internal val chessDismissedGamesData: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("chess_dismissed_games_v2")
@@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStorage
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessLobbyLogic
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus
@@ -61,7 +61,7 @@ class ChessViewModelNew(
private val publisher = AndroidChessPublisher(account)
private val fetcher = AndroidRelayFetcher(account)
private val metadataProvider = AndroidMetadataProvider()
private val dismissedStorage = ChessDismissedGamesStorage.create(application)
private val dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData)
// Shared business logic (creates its own ChessLobbyState internally)
private val logic =
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.Mute
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chat_about_topic
import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding
@@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.note.LikeReaction
@@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.Mute
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.community_about_topic
import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.note.LikeReaction
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing5dp
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.nip05
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage

Some files were not shown because too many files have changed in this diff Show More