mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+107
-42
@@ -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 {
|
||||
|
||||
+38
-15
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-34
@@ -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))
|
||||
|
||||
+47
-89
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-83
@@ -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
|
||||
}
|
||||
}
|
||||
-78
@@ -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
|
||||
}
|
||||
}
|
||||
-108
@@ -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,
|
||||
)
|
||||
}
|
||||
-322
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
-7
@@ -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)
|
||||
}
|
||||
|
||||
+15
-5
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-66
@@ -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"
|
||||
}
|
||||
}
|
||||
-84
@@ -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
|
||||
}
|
||||
}
|
||||
+82
@@ -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))
|
||||
+6
-6
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-50
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-52
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-33
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+87
-44
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+129
-68
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
+102
-54
@@ -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)) {
|
||||
|
||||
+237
-108
@@ -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
|
||||
}
|
||||
|
||||
+55
-31
@@ -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,
|
||||
)
|
||||
|
||||
+16
-16
@@ -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) {
|
||||
|
||||
+5
-5
@@ -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>`
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+18
-11
@@ -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 = {},
|
||||
|
||||
+3
-2
@@ -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,
|
||||
)
|
||||
},
|
||||
|
||||
+105
-55
@@ -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)) } },
|
||||
)
|
||||
}
|
||||
|
||||
+37
-21
@@ -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,
|
||||
)
|
||||
|
||||
+50
-28
@@ -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.
|
||||
|
||||
+103
-59
@@ -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 = {
|
||||
|
||||
+16
-10
@@ -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,
|
||||
)
|
||||
|
||||
+41
-19
@@ -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,
|
||||
)
|
||||
|
||||
+39
-22
@@ -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 ->
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-7
@@ -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)
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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")
|
||||
+2
-2
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user