mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge branch 'main' into feat/desktop-relay-latency-health
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
name: Import macOS Developer ID certificate
|
||||
description: >
|
||||
Import a Developer ID Application certificate (base64-encoded .p12) into a
|
||||
throwaway keychain so codesign/jpackage can find it during the job. Soft:
|
||||
when no certificate is supplied it is a no-op and reports signing=false, so
|
||||
callers build UNSIGNED artifacts exactly as before.
|
||||
|
||||
inputs:
|
||||
certificate-p12-base64:
|
||||
description: Base64 of the Developer ID Application .p12 (cert + private key)
|
||||
required: true
|
||||
certificate-password:
|
||||
description: Password used when the .p12 was exported
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
signing:
|
||||
description: "'true' if a certificate was imported, else 'false'"
|
||||
value: ${{ steps.import.outputs.signing }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: import
|
||||
shell: bash
|
||||
env:
|
||||
CERT_P12: ${{ inputs.certificate-p12-base64 }}
|
||||
CERT_PASSWORD: ${{ inputs.certificate-password }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CERT_P12:-}" ]]; then
|
||||
echo "::notice::No macOS signing certificate configured — artifacts will be UNSIGNED."
|
||||
echo "signing=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db"
|
||||
KEYCHAIN_PWD="$(openssl rand -base64 24)"
|
||||
CERT_PATH="$RUNNER_TEMP/developer_id.p12"
|
||||
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||
echo "$CERT_P12" | base64 --decode > "$CERT_PATH"
|
||||
security import "$CERT_PATH" -P "$CERT_PASSWORD" \
|
||||
-k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign
|
||||
# Let codesign use the private key without an interactive UI prompt.
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
|
||||
# Prepend our keychain to the user search list so codesign sees it.
|
||||
security list-keychains -d user -s "$KEYCHAIN" \
|
||||
$(security list-keychains -d user | sed -e 's/[\"[:space:]]//g')
|
||||
rm -f "$CERT_PATH"
|
||||
echo "signing=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -101,8 +101,29 @@ jobs:
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
# macOS only: import the Developer ID Application cert into a throwaway
|
||||
# keychain so jpackage's codesign pass can find it. Soft — if the
|
||||
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
|
||||
# provisioned) the DMG is built UNSIGNED, exactly as before. notarytool
|
||||
# runs as part of the gradle task when the identity env is exported below.
|
||||
- name: Import Apple Developer ID certificate (macOS leg, if configured)
|
||||
if: matrix.family == 'macos'
|
||||
id: mac_keychain
|
||||
uses: ./.github/actions/import-macos-cert
|
||||
with:
|
||||
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Build desktop artifacts
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
env:
|
||||
# Empty on non-macOS legs and on the macOS leg when no cert is
|
||||
# configured — the gradle macOS{} block skips signing when the
|
||||
# identity is blank.
|
||||
AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_SIGN_IDENTITY || '' }}
|
||||
AMETHYST_NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
|
||||
AMETHYST_NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
|
||||
AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
|
||||
with:
|
||||
max_attempts: 2
|
||||
timeout_minutes: 15
|
||||
@@ -212,7 +233,7 @@ jobs:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -261,6 +282,91 @@ jobs:
|
||||
timeout_minutes: 15
|
||||
command: ./gradlew --no-daemon :cli:${{ matrix.tasks }}
|
||||
|
||||
# amy is headless: the Compose UI render stack (skiko + its native dylibs,
|
||||
# foundation/material/material3/ui/animation) must never reach the CLI
|
||||
# image. cli/build.gradle.kts excludes it from runtimeClasspath; this
|
||||
# guards against a transitive dep silently dragging it back (size + macOS
|
||||
# notarization-surface regression). compose.runtime is CLI-safe and stays.
|
||||
- name: Assert no Compose UI in the amy image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LIB="cli/build/install/amy/lib"
|
||||
leak="$(ls "$LIB" | grep -iE 'skiko|foundation(-layout)?-desktop|material3?-desktop|material-ripple|ui-desktop|animation(-core)?-desktop' || true)"
|
||||
if [ -n "$leak" ]; then
|
||||
echo "::error::Compose UI render stack leaked into the amy CLI image:"
|
||||
echo "$leak" | sed 's/^/ /'
|
||||
echo "Exclude it in cli/build.gradle.kts (configurations.runtimeClasspath)."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: no skiko / Compose UI render jars in the amy image ($(du -sh "$LIB" | cut -f1))."
|
||||
|
||||
# macOS only: import the Developer ID cert (no-op without the secret) so
|
||||
# the next step can codesign the jlink image. The jvm bundle for
|
||||
# Homebrew-core is NOT signed here — Homebrew strips quarantine itself.
|
||||
- name: Import Apple Developer ID certificate (macOS leg, if configured)
|
||||
if: matrix.family == 'macos'
|
||||
id: mac_keychain
|
||||
uses: ./.github/actions/import-macos-cert
|
||||
with:
|
||||
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
|
||||
# Codesign + notarize the macOS jlink image (amy-<ver>-macos-arm64.tar.gz)
|
||||
# for users who download it directly. A loose tarball can't be stapled
|
||||
# (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization
|
||||
# online on first run. Runs before "Collect" so the tarred image is signed.
|
||||
- name: Sign + notarize amy image (macOS leg, if configured)
|
||||
if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true'
|
||||
env:
|
||||
SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }}
|
||||
NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
|
||||
NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
|
||||
NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="cli/build/amy-image/amy"
|
||||
ENTITLEMENTS="cli/packaging/macos/amy.entitlements"
|
||||
# Sign every Mach-O binary in the bundled JRE. Each is signed
|
||||
# independently (no enclosing .app seals them), so order is irrelevant.
|
||||
# Executables get the hardened-runtime entitlements; dylibs don't.
|
||||
while IFS= read -r f; do
|
||||
case "$(file -b "$f")" in
|
||||
*Mach-O*executable*)
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;;
|
||||
*Mach-O*)
|
||||
codesign --force --options runtime --timestamp \
|
||||
--sign "$SIGN_IDENTITY" "$f" ;;
|
||||
esac
|
||||
done < <(find "$IMG" -type f)
|
||||
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
|
||||
# Notarize: zip the signed image, submit, wait for Apple's verdict.
|
||||
# The notary service recursively inspects the lib/*.jar files, so any
|
||||
# unsigned Mach-O embedded in them (secp256k1/jna/sqlite/skiko natives)
|
||||
# can come back Invalid. Surface the per-file log so the first real run
|
||||
# is diagnostic rather than a bare failure.
|
||||
ZIP="$RUNNER_TEMP/amy-notarize.zip"
|
||||
OUT="$RUNNER_TEMP/notary-submit.json"
|
||||
ditto -c -k --keepParent "$IMG" "$ZIP"
|
||||
if ! xcrun notarytool submit "$ZIP" \
|
||||
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
|
||||
--team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then
|
||||
echo "::warning::notarytool submit exited non-zero"
|
||||
fi
|
||||
cat "$OUT"
|
||||
STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)"
|
||||
SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)"
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "::error::Notarization status: $STATUS"
|
||||
if [ -n "$SUBMISSION_ID" ]; then
|
||||
echo "----- notary log -----"
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
|
||||
--team-id "$NOTARY_TEAM_ID" || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# jpackage pins libicu to the build host's version (libicu74 on
|
||||
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
|
||||
- name: Relax libicu dependency in .deb
|
||||
@@ -277,6 +383,23 @@ jobs:
|
||||
source scripts/asset-name.sh
|
||||
collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
|
||||
|
||||
# Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it
|
||||
# cannot use the jlink tarball above (bundled runtime) nor build from
|
||||
# source (its sandbox blocks Gradle's Maven downloads). installDist
|
||||
# (bin/amy + lib/*.jar, no runtime/) is exactly that bundle. It is pure
|
||||
# JVM bytecode, so one platform-independent asset serves every OS; we cut
|
||||
# it on the linux leg only. amyImage depends on installDist, so the
|
||||
# cli/build/install/amy tree already exists here.
|
||||
- name: Package no-JRE jvm bundle for Homebrew (linux leg only)
|
||||
if: matrix.family == 'linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
SRC="cli/build/install/amy"
|
||||
test -x "$SRC/bin/amy"
|
||||
( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib )
|
||||
echo "Collected: dist/amy-${VER}-jvm.tar.gz"
|
||||
|
||||
- name: Enforce CLI size budget (200 MB per asset)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
+111
-5
@@ -250,13 +250,58 @@ provided automatically; everything else you set yourself.)
|
||||
| `SONATYPE_PASSWORD` | Maven Central user token password | Same |
|
||||
| `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) |
|
||||
| `SIGNING_PASSWORD` | Passphrase for that GPG key | Same |
|
||||
| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** and the macOS **amy** jlink tarball |
|
||||
| `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain |
|
||||
| `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with |
|
||||
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
|
||||
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
|
||||
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
|
||||
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
|
||||
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
|
||||
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
|
||||
|
||||
Note the **two distinct signing identities** people often conflate:
|
||||
Note the **three distinct signing identities** people often conflate:
|
||||
`SIGNING_KEY` + `KEY_*` is the **Android keystore**; `SIGNING_PRIVATE_KEY` +
|
||||
`SIGNING_PASSWORD` is the **GPG key** for Maven Central. They are unrelated.
|
||||
`SIGNING_PASSWORD` is the **GPG key** for Maven Central; `MAC_CERTIFICATE_*` +
|
||||
`MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS
|
||||
desktop DMG. They are unrelated — each comes from a different authority.
|
||||
|
||||
The macOS signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is unset
|
||||
the release workflow still builds the DMG **and** the macOS `amy` tarball, just
|
||||
**unsigned** (the previous behavior). Provision all six to switch signing +
|
||||
notarization on for both. Obtaining them requires Apple Developer Program
|
||||
membership ($99/yr). The same one certificate signs both artifacts.
|
||||
|
||||
The macOS `amy` tarball is the jlink image (bundled JRE), so signing it means
|
||||
codesigning every Mach-O binary in that runtime with hardened-runtime
|
||||
entitlements (`cli/packaging/macos/amy.entitlements` — needed so the JVM can
|
||||
load the secp256k1 native library it extracts at runtime). A loose `.tar.gz`
|
||||
cannot be **stapled** (Apple's `stapler` only handles `.app`/`.dmg`/`.pkg`), so
|
||||
Gatekeeper verifies notarization **online** on first run — fine for a CLI.
|
||||
Note the Homebrew-core jvm bundle (`amy-<version>-jvm.tar.gz`) is **not** signed:
|
||||
Homebrew removes the quarantine attribute on its own downloads.
|
||||
|
||||
> **Validated (Developer ID `D77MCV9NZ7`):** signing every Mach-O in the bundled
|
||||
> JRE with hardened runtime + `amy.entitlements` lets `amy init` derive a key via
|
||||
> secp256k1 with no library-validation crash. Dropping `disable-library-validation`
|
||||
> reproduces `UnsatisfiedLinkError: … different Team IDs` on the runtime-extracted
|
||||
> `libsecp256k1-jni.dylib` — so that entitlement is load-bearing, not decorative.
|
||||
>
|
||||
> **Open risk — embedded jar natives.** The notary service unpacks `lib/*.jar`
|
||||
> recursively and checks every Mach-O for a signature + hardened runtime. Our
|
||||
> sign loop only touches loose files, so 9 unsigned natives ride along inside
|
||||
> jars on a macOS build: `secp256k1` (1, required at runtime), `jna` (2),
|
||||
> `sqlite` (2), and `skiko` (4, dead weight — Compose UI the CLI never renders).
|
||||
> Whether `notarytool` returns `Accepted` or `Invalid` on these is **unverified**
|
||||
> (the local validation had no notary creds). **Decide it with one run:** set the
|
||||
> six `MAC_*` secrets and trigger `create-release.yml` via `workflow_dispatch`
|
||||
> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and
|
||||
> now prints the per-file notary log on a non-`Accepted` verdict. If it comes
|
||||
> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before
|
||||
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the
|
||||
> `:commons` core/ui split the size budget already flags). The **desktop** app
|
||||
> bundles the same jars through Compose/jpackage notarization, so run a desktop
|
||||
> dry-run too; its in-jar handling differs and is likewise unverified.
|
||||
|
||||
Generating the values:
|
||||
|
||||
@@ -269,6 +314,14 @@ base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY
|
||||
# GPG key → armored private key for SIGNING_PRIVATE_KEY
|
||||
gpg --full-generate-key # create the key (once)
|
||||
gpg --armor --export-secret-keys <KEY_ID> # paste output into SIGNING_PRIVATE_KEY
|
||||
|
||||
# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12.
|
||||
# In Keychain Access, export the "Developer ID Application: ..." cert (with its
|
||||
# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD).
|
||||
base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12
|
||||
security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string
|
||||
# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com
|
||||
# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login.
|
||||
```
|
||||
|
||||
`SONATYPE_USERNAME`/`SONATYPE_PASSWORD` are a **user token** from
|
||||
@@ -339,6 +392,51 @@ The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
|
||||
tiling window manager). After the first PR is merged, `bump-homebrew.yml`
|
||||
auto-submits new version bumps on each stable release.
|
||||
|
||||
> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the
|
||||
> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in
|
||||
> homebrew-**core**; both are "mainline." A private tap is only the *fallback*
|
||||
> if Homebrew ever rejects the (now signed + notarized) cask.
|
||||
|
||||
### Homebrew-core formula for the `amy` CLI (one-time initial PR)
|
||||
|
||||
The CLI goes to **homebrew-core** (mainline formulae), not homebrew-cask —
|
||||
casks are for GUI apps. homebrew-core builds in a **network-sandboxed**
|
||||
environment, so a from-source Gradle build can't resolve its Maven
|
||||
dependencies there. Instead the formula downloads the pre-built **no-JRE jar
|
||||
bundle** `amy-<version>-jvm.tar.gz` (published by `create-release.yml`) and
|
||||
`depends_on "openjdk"`. The reference formula lives at
|
||||
[`cli/packaging/homebrew/amy.rb`](cli/packaging/homebrew/amy.rb).
|
||||
|
||||
To submit:
|
||||
|
||||
```bash
|
||||
# 1. Grab the published asset's sha256
|
||||
curl -fsSL -o amy-jvm.tar.gz \
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
|
||||
shasum -a 256 amy-jvm.tar.gz
|
||||
|
||||
# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR
|
||||
brew create --set-name amy --tap homebrew/core \
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
|
||||
# (paste the reference formula body, run `brew audit --new amy`,
|
||||
# `brew install --build-from-source amy`, `brew test amy`, then PR it.)
|
||||
```
|
||||
|
||||
Caveats that the maintainer must weigh before submitting:
|
||||
|
||||
- **Name collision.** `amy` may already exist in homebrew-core — check with
|
||||
`brew search amy` first. If taken, fall back to `amethyst-cli`.
|
||||
- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading
|
||||
a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to
|
||||
justify it (sandboxed Gradle can't fetch Maven deps).
|
||||
- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks
|
||||
Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons`
|
||||
core/ui split) would shrink it and smooth review — tracked as a follow-up.
|
||||
|
||||
After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot
|
||||
auto-open version-bump PRs on each stable release — no token or workflow on our
|
||||
side (unlike the cask/winget bumps).
|
||||
|
||||
### Winget (one-time initial submission)
|
||||
|
||||
```bash
|
||||
@@ -480,9 +578,17 @@ for the deprecation date. When it hits:
|
||||
Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask`
|
||||
on 2026-09-01. Before that date:
|
||||
|
||||
**Option A**: Commit budget to Apple Developer Program ($99/yr), add
|
||||
`signing { sign.set(true) }` + `notarization {}` blocks to
|
||||
`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI.
|
||||
**Option A (wiring done — needs Apple creds)**: The `signing { sign.set(true) }`
|
||||
+ `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on
|
||||
the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of
|
||||
`create-release.yml` imports a Developer ID cert into a throwaway keychain and
|
||||
exports the signing/notary env. It all stays a **no-op until the six
|
||||
`MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI
|
||||
needs](#secrets-the-ci-needs)) — until then the DMG builds unsigned. To turn it
|
||||
on: join the Apple Developer Program ($99/yr), create a *Developer ID
|
||||
Application* certificate, generate an app-specific password, and set the six
|
||||
secrets. The first signed+notarized DMG is best validated with a
|
||||
`workflow_dispatch` dry-run before a real tag.
|
||||
|
||||
**Option B**: Pivot to a private Homebrew tap:
|
||||
|
||||
|
||||
@@ -408,6 +408,9 @@ dependencies {
|
||||
implementation(libs.zxing)
|
||||
implementation(libs.zxing.embedded)
|
||||
|
||||
// OpenStreetMap tiles for road event location maps (kind 1315/1316)
|
||||
implementation(libs.osmdroid.android)
|
||||
|
||||
// Markdown
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
|
||||
@@ -75,6 +75,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
@@ -3870,6 +3872,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RoadEventReportEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RoadEventConfirmationEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RelayDiscoveryEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
|
||||
|
||||
import com.vitorpamplona.amethyst.service.location.GeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
|
||||
|
||||
fun compute50kmLine(geoHash: GeoHash): List<String> {
|
||||
val hashes = mutableListOf<String>()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.location
|
||||
|
||||
import android.location.Location
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
|
||||
|
||||
/**
|
||||
* Android `Location` glue for the platform-agnostic [GeoHash] that now lives in
|
||||
* Quartz. The encode/decode/neighbor logic is shared in `commonMain`; only these
|
||||
* conversions to/from the Android `Location` type stay here.
|
||||
*/
|
||||
fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount)
|
||||
|
||||
fun GeoHash.toLocation(): Location =
|
||||
Location("").also {
|
||||
it.latitude = centerLat
|
||||
it.longitude = centerLon
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.location
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
+32
-10
@@ -39,13 +39,16 @@ import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -79,6 +82,10 @@ class NotificationRelayService : Service() {
|
||||
|
||||
private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE"
|
||||
|
||||
// Throttle interval for refreshing the persistent notification's relay count.
|
||||
// Keeps notification updates well under Android's rate limit (~10/s).
|
||||
private const val NOTIFICATION_REFRESH_MS = 1000L
|
||||
|
||||
const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE"
|
||||
|
||||
fun start(context: Context) {
|
||||
@@ -242,6 +249,7 @@ class NotificationRelayService : Service() {
|
||||
* drafts, and relay list changes. Since the service keeps the client connected,
|
||||
* those subscriptions remain active on the relays.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun startRelayConnection() {
|
||||
relayServiceCollectorJob?.cancel()
|
||||
relayServiceCollectorJob =
|
||||
@@ -253,13 +261,22 @@ class NotificationRelayService : Service() {
|
||||
}
|
||||
|
||||
launch {
|
||||
Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays ->
|
||||
val count = relays.size
|
||||
if (count != connectedRelayCount) {
|
||||
connectedRelayCount = count
|
||||
updateNotification(count)
|
||||
// sample() caps how often we touch the notification. During feed
|
||||
// load/teardown connectedRelaysFlow churns dozens of times per second;
|
||||
// posting on every delta blows past Android's notification rate limit
|
||||
// (~10/s), which silently drops updates and leaves the visible count
|
||||
// stuck on a stale intermediate value. One refresh per second stays
|
||||
// well under the limit and always lands the settled count.
|
||||
Amethyst.instance.client
|
||||
.connectedRelaysFlow()
|
||||
.sample(NOTIFICATION_REFRESH_MS)
|
||||
.collectLatest { relays ->
|
||||
val count = relays.size
|
||||
if (count != connectedRelayCount) {
|
||||
connectedRelayCount = count
|
||||
updateNotification(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,10 +289,15 @@ class NotificationRelayService : Service() {
|
||||
|
||||
private fun buildNotification(connectedRelays: Int): Notification {
|
||||
val contentText =
|
||||
if (connectedRelays > 0) {
|
||||
getString(R.string.always_on_notif_connected, connectedRelays)
|
||||
} else {
|
||||
getString(R.string.always_on_notif_connecting)
|
||||
when {
|
||||
connectedRelays <= 0 -> getString(R.string.always_on_notif_connecting)
|
||||
// Foreground: the pool also holds the feed/finder outbox relays, so the
|
||||
// count reflects all connections, not just the inbox. Backgrounded, the
|
||||
// feeds tear down and only inbox + DM relays remain.
|
||||
MainActivity.isResumed ->
|
||||
pluralStringRes(this, R.plurals.always_on_notif_connected_foreground, connectedRelays, connectedRelays)
|
||||
else ->
|
||||
pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays)
|
||||
}
|
||||
|
||||
val openAppIntent =
|
||||
|
||||
+18
-2
@@ -43,6 +43,10 @@ import androidx.compose.ui.unit.Velocity
|
||||
* - onPostFling snaps a mid-way bar to the nearest edge, using the fling's remaining
|
||||
* velocity as the spring's initial velocity so the settle feels continuous. No velocity
|
||||
* is returned upward to avoid phantom scrolls on parent containers.
|
||||
* - Hiding tracks the finger 1:1, but revealing is damped by [REVEAL_SENSITIVITY]. Once the
|
||||
* bars are hidden, the small reverse drag a finger naturally makes when it catches/stops a
|
||||
* fast scroll would otherwise be enough to snap the chrome (and the OS status bar) back.
|
||||
* Damping the reveal direction makes bringing the bars back a more deliberate gesture.
|
||||
*/
|
||||
class DisappearingBarNestedScroll(
|
||||
private val state: DisappearingBarState,
|
||||
@@ -86,7 +90,19 @@ class DisappearingBarNestedScroll(
|
||||
private fun applyDelta(deltaY: Float) {
|
||||
val topLimit = state.topHeightLimit
|
||||
val bottomLimit = state.bottomHeightLimit
|
||||
state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f)
|
||||
state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f)
|
||||
// Positive delta reveals the bars; negative delta hides them. Hiding stays 1:1 with the
|
||||
// finger, while revealing is damped so a stray reverse drag doesn't bring the chrome back.
|
||||
val effectiveDelta = if (deltaY > 0f) deltaY * REVEAL_SENSITIVITY else deltaY
|
||||
state.topHeightOffset = (state.topHeightOffset + effectiveDelta).coerceIn(-topLimit, 0f)
|
||||
state.bottomHeightOffset = (state.bottomHeightOffset + effectiveDelta).coerceIn(-bottomLimit, 0f)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Fraction of scroll distance applied when revealing the bars (1.0 = same rate as hiding).
|
||||
* Lower values require a more deliberate downward scroll to bring the chrome back, so the
|
||||
* tiny reverse movement of a finger stopping a fast scroll no longer pops the bars open.
|
||||
*/
|
||||
const val REVEAL_SENSITIVITY = 0.5f
|
||||
}
|
||||
}
|
||||
|
||||
+27
-8
@@ -269,6 +269,14 @@ private fun ResetBarsOnResume(state: DisappearingBarState) {
|
||||
/** Fraction at which the tracked in-app bar is considered fully settled into the hidden edge. */
|
||||
private const val STATUS_BAR_HIDE_THRESHOLD = 0.999f
|
||||
|
||||
/**
|
||||
* Fraction the chrome must fall back below before the OS status bar reappears. Lower than
|
||||
* [STATUS_BAR_HIDE_THRESHOLD] on purpose: this hysteresis gap means the finger that stops a fast
|
||||
* scroll — which makes a tiny reverse reveal — does not immediately pop the status bar back. The
|
||||
* user has to deliberately scroll the chrome back by ~30% before the status bar returns.
|
||||
*/
|
||||
private const val STATUS_BAR_SHOW_THRESHOLD = 0.7f
|
||||
|
||||
/**
|
||||
* Hides the OS status bar once the tracked in-app bar has settled into the hidden edge, and shows it
|
||||
* the moment it starts coming back. The OS status bar is binary (cannot slide), so it is driven off
|
||||
@@ -295,16 +303,27 @@ private fun ImmersiveStatusBarEffect(state: DisappearingBarState) {
|
||||
|
||||
LaunchedEffect(controller, state) {
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
var statusBarHidden = false
|
||||
snapshotFlow {
|
||||
val fraction =
|
||||
if (state.topHeightLimit > 0f) state.topCollapsedFraction else state.bottomCollapsedFraction
|
||||
fraction >= STATUS_BAR_HIDE_THRESHOLD
|
||||
if (state.topHeightLimit > 0f) state.topCollapsedFraction else state.bottomCollapsedFraction
|
||||
}.distinctUntilChanged()
|
||||
.collect { shouldHide ->
|
||||
if (shouldHide) {
|
||||
controller.hide(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
controller.show(WindowInsetsCompat.Type.statusBars())
|
||||
.collect { fraction ->
|
||||
// Hysteresis: hide once fully settled, but only show again after the chrome has
|
||||
// been pulled back past STATUS_BAR_SHOW_THRESHOLD. Between the two thresholds the
|
||||
// status bar keeps its current state, so a stray reverse reveal can't flip it.
|
||||
val shouldHide =
|
||||
when {
|
||||
fraction >= STATUS_BAR_HIDE_THRESHOLD -> true
|
||||
fraction <= STATUS_BAR_SHOW_THRESHOLD -> false
|
||||
else -> statusBarHidden
|
||||
}
|
||||
if (shouldHide != statusBarHidden) {
|
||||
statusBarHidden = shouldHide
|
||||
if (shouldHide) {
|
||||
controller.hide(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
controller.show(WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderReport
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
|
||||
@@ -235,6 +237,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
@@ -348,6 +352,7 @@ fun NoteCompose(
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: (() -> Unit)? = null,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
WatchNoteEvent(
|
||||
@@ -378,6 +383,7 @@ fun NoteCompose(
|
||||
parentBackgroundColor = parentBackgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = onClick,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
@@ -399,6 +405,7 @@ fun AcceptableNote(
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: (() -> Unit)? = null,
|
||||
moreOptions: (@Composable () -> Unit)?,
|
||||
) {
|
||||
if (isQuotedNote || isBoostedNote) {
|
||||
@@ -451,6 +458,7 @@ fun AcceptableNote(
|
||||
accountViewModel = accountViewModel,
|
||||
showPopup = showPopup,
|
||||
nav = nav,
|
||||
onClick = onClick,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
@@ -505,6 +513,7 @@ fun AcceptableNote(
|
||||
accountViewModel = accountViewModel,
|
||||
showPopup = showPopup,
|
||||
nav = nav,
|
||||
onClick = onClick,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
@@ -571,6 +580,7 @@ private fun CheckNewAndRenderNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
showPopup: () -> Unit,
|
||||
nav: INav,
|
||||
onClick: (() -> Unit)? = null,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val backgroundColor =
|
||||
@@ -584,7 +594,7 @@ private fun CheckNewAndRenderNote(
|
||||
InnerNoteWithReactions(
|
||||
baseNote = baseNote,
|
||||
backgroundColor = backgroundColor,
|
||||
clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav),
|
||||
clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav, onClick),
|
||||
isBoostedNote = isBoostedNote,
|
||||
isQuotedNote = isQuotedNote,
|
||||
unPackReply = unPackReply,
|
||||
@@ -614,25 +624,30 @@ fun clickableNoteModifier(
|
||||
accountViewModel: AccountViewModel,
|
||||
showPopup: () -> Unit,
|
||||
nav: INav,
|
||||
onClick: (() -> Unit)? = null,
|
||||
): Modifier =
|
||||
remember(baseNote, modifier) {
|
||||
remember(baseNote, modifier, onClick) {
|
||||
modifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
val redirectToNote =
|
||||
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
|
||||
baseNote.replyTo?.lastOrNull() ?: baseNote
|
||||
} else {
|
||||
baseNote
|
||||
}
|
||||
|
||||
nav.nav {
|
||||
if (redirectToNote.event is DraftWrapEvent) {
|
||||
withContext(Dispatchers.IO) {
|
||||
routeEditDraftTo(redirectToNote, accountViewModel.account)
|
||||
if (onClick != null) {
|
||||
onClick()
|
||||
} else {
|
||||
val redirectToNote =
|
||||
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
|
||||
baseNote.replyTo?.lastOrNull() ?: baseNote
|
||||
} else {
|
||||
baseNote
|
||||
}
|
||||
|
||||
nav.nav {
|
||||
if (redirectToNote.event is DraftWrapEvent) {
|
||||
withContext(Dispatchers.IO) {
|
||||
routeEditDraftTo(redirectToNote, accountViewModel.account)
|
||||
}
|
||||
} else {
|
||||
routeFor(redirectToNote, accountViewModel.account)
|
||||
}
|
||||
} else {
|
||||
routeFor(redirectToNote, accountViewModel.account)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1285,6 +1300,14 @@ private fun RenderNoteRow(
|
||||
RenderBirdex(baseNote)
|
||||
}
|
||||
|
||||
is RoadEventReportEvent -> {
|
||||
RenderRoadEventReport(baseNote)
|
||||
}
|
||||
|
||||
is RoadEventConfirmationEvent -> {
|
||||
RenderRoadEventConfirmation(baseNote)
|
||||
}
|
||||
|
||||
is HighlightEvent -> {
|
||||
RenderHighlight(
|
||||
baseNote,
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -59,8 +60,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -1618,7 +1617,6 @@ private fun BoostTypeChoicePopup(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BoostTypeChoicePopup(
|
||||
baseNote: Note,
|
||||
@@ -1642,52 +1640,81 @@ private fun BoostTypeChoicePopup(
|
||||
enter = popupAnimationEnter,
|
||||
exit = popupAnimationExit,
|
||||
) {
|
||||
FlowRow {
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
accountViewModel.boost(baseNote)
|
||||
visibilityState.targetState = false
|
||||
} else {
|
||||
onRepost()
|
||||
visibilityState.targetState = false
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Text(stringRes(R.string.boost), color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = onQuote,
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Text(stringRes(R.string.quote), color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
BoostTypeChoicePopupContent(
|
||||
// removes the option to fork for now because we do not have screens for
|
||||
// LongForm, Wiki and NIP posting.
|
||||
if (baseNote.event is TextNoteEvent) {
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
showFork = baseNote.event is TextNoteEvent,
|
||||
onBoost = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
accountViewModel.boost(baseNote)
|
||||
} else {
|
||||
onRepost()
|
||||
}
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onQuote = {
|
||||
onQuote()
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onFork = {
|
||||
onFork()
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun BoostTypeChoicePopupContent(
|
||||
showFork: Boolean,
|
||||
onBoost: () -> Unit,
|
||||
onQuote: () -> Unit,
|
||||
onFork: () -> Unit,
|
||||
) {
|
||||
Box(HalfPadding, contentAlignment = Center) {
|
||||
ElevatedCard(
|
||||
shape = SmallBorder,
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
itemVerticalAlignment = CenterVertically,
|
||||
) {
|
||||
BoostActionChip(
|
||||
label = stringRes(R.string.boost),
|
||||
onClick = onBoost,
|
||||
) { tint ->
|
||||
RepostedIcon(Size18Modifier, tint)
|
||||
}
|
||||
|
||||
BoostActionChip(
|
||||
label = stringRes(R.string.quote),
|
||||
onClick = onQuote,
|
||||
) { tint ->
|
||||
Icon(
|
||||
symbol = MaterialSymbols.FormatQuote,
|
||||
contentDescription = null,
|
||||
modifier = Size18Modifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
if (showFork) {
|
||||
BoostActionChip(
|
||||
label = stringRes(R.string.fork),
|
||||
onClick = onFork,
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center)
|
||||
) { tint ->
|
||||
Icon(
|
||||
symbol = MaterialSymbols.EditNote,
|
||||
contentDescription = null,
|
||||
modifier = Size18Modifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1695,6 +1722,67 @@ private fun BoostTypeChoicePopup(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single quote/repost action rendered as a pill: a coloured leading icon next
|
||||
* to its label, wrapped in the same soft-outlined surface the zap rails use so
|
||||
* the popups read as one family.
|
||||
*/
|
||||
@Composable
|
||||
private fun BoostActionChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable (Color) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = ButtonBorder,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(ButtonBorder)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
icon(MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BoostTypeChoicePopupPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Column {
|
||||
// With the fork action (e.g. a plain text note).
|
||||
BoostTypeChoicePopupContent(
|
||||
showFork = true,
|
||||
onBoost = {},
|
||||
onQuote = {},
|
||||
onFork = {},
|
||||
)
|
||||
|
||||
// Without the fork action (e.g. long-form, wiki, …).
|
||||
BoostTypeChoicePopupContent(
|
||||
showFork = false,
|
||||
onBoost = {},
|
||||
onQuote = {},
|
||||
onFork = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val popupAnimationEnter: EnterTransition =
|
||||
slideInVertically(
|
||||
animationSpec = defaultTweenIntOffsetSpec,
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
|
||||
import com.vitorpamplona.amethyst.service.location.toGeoHash
|
||||
import com.vitorpamplona.amethyst.service.location.toLocation
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.note.creators.location
|
||||
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.vitorpamplona.amethyst.ui.theme.isLight
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
||||
/** Default close-up zoom for a single pinned location (street-level). */
|
||||
private const val DEFAULT_ZOOM = 16.0
|
||||
|
||||
/**
|
||||
* Night-mode filter for the (always-light) MAPNIK tiles so the map follows the
|
||||
* app theme. This is a lightness invert composed with a 180° hue rotation: it
|
||||
* darkens the map (light land → near-black) while *preserving* hue, so forests
|
||||
* stay green and water stays blue and the surfaces remain distinguishable —
|
||||
* unlike a plain colour invert (forests → magenta) or a desaturating invert
|
||||
* (everything → black).
|
||||
*/
|
||||
private val NIGHT_TILE_FILTER: ColorFilter =
|
||||
ColorMatrixColorFilter(
|
||||
floatArrayOf(
|
||||
0.574f,
|
||||
-1.430f,
|
||||
-0.144f,
|
||||
0f,
|
||||
255f,
|
||||
-0.426f,
|
||||
-0.430f,
|
||||
-0.144f,
|
||||
0f,
|
||||
255f,
|
||||
-0.426f,
|
||||
-1.430f,
|
||||
0.856f,
|
||||
0f,
|
||||
255f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
1f,
|
||||
0f,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude]
|
||||
* with a single pin at that point.
|
||||
*
|
||||
* Used by the Roadstr road event cards (kind 1315/1316) in place of a
|
||||
* reverse-geocoded city name. Tiles are fetched from the public OSM tile
|
||||
* servers, which requires the app's package name as the User-Agent (set on
|
||||
* [Configuration] below) — without it OSM returns HTTP 403.
|
||||
*
|
||||
* When [pinColor] and [pinEmoji] are supplied the marker becomes a colored
|
||||
* teardrop with the category emoji on it (see [roadEventPinBitmap]); otherwise
|
||||
* osmdroid's default pin is used. [pinAlpha] fades the marker to signal
|
||||
* freshness (e.g. an event close to its effective expiry).
|
||||
*
|
||||
* Pan/zoom stay enabled, but a touch listener asks the parent to stop
|
||||
* intercepting gestures while the finger is on the map, so dragging the map
|
||||
* pans it instead of scrolling the surrounding feed.
|
||||
*/
|
||||
@Composable
|
||||
fun LocationPreviewMap(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
modifier: Modifier = Modifier,
|
||||
zoom: Double = DEFAULT_ZOOM,
|
||||
pinColor: Color? = null,
|
||||
pinEmoji: String? = null,
|
||||
pinAlpha: Float = 1f,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val darkTheme = !MaterialTheme.colorScheme.isLight
|
||||
|
||||
val markerIcon =
|
||||
remember(pinColor, pinEmoji) {
|
||||
if (pinColor != null && pinEmoji != null) {
|
||||
val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density)
|
||||
BitmapDrawable(context.resources, bitmap)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val mapView =
|
||||
remember(context) {
|
||||
// Must be set before the MapView is created so OSM tile requests
|
||||
// carry a valid User-Agent (the default "osmdroid" is rejected).
|
||||
Configuration.getInstance().userAgentValue = context.packageName
|
||||
|
||||
MapView(context).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setOnTouchListener { view, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true)
|
||||
MotionEvent.ACTION_UP -> view.parent?.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
// Returning false lets the MapView still handle the pan/zoom.
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// osmdroid's MapView is a plain Android View with its own lifecycle: it
|
||||
// needs onResume/onPause to (re)start its tile threads, and onDetach to
|
||||
// free the tile cache when the composable leaves the tree.
|
||||
DisposableEffect(lifecycleOwner, mapView) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
||||
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
mapView.onDetach()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier.fillMaxWidth().aspectRatio(1f),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
val point = GeoPoint(latitude, longitude)
|
||||
map.controller.setZoom(zoom)
|
||||
map.controller.setCenter(point)
|
||||
|
||||
// Follow the app theme: dim the bright MAPNIK tiles in dark mode.
|
||||
map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null)
|
||||
|
||||
map.overlays.removeAll { it is Marker }
|
||||
val marker =
|
||||
Marker(map).apply {
|
||||
position = point
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
setInfoWindow(null)
|
||||
markerIcon?.let { icon = it }
|
||||
alpha = pinAlpha
|
||||
}
|
||||
map.overlays.add(marker)
|
||||
map.invalidate()
|
||||
},
|
||||
)
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.note.creators.location
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Draws a Roadstr-style map pin: a colored teardrop in [colorArgb] with [emoji]
|
||||
* centered on its head, a white ring, and a soft drop shadow.
|
||||
*
|
||||
* The pin's tip sits at the bottom-center of the returned bitmap, so the
|
||||
* osmdroid marker should be anchored at (0.5, 1.0) — the geographic point then
|
||||
* lands exactly under the tip.
|
||||
*
|
||||
* Matches the visual language of the roadstr reference clients
|
||||
* (<https://github.com/jooray/roadstr>): the same per-category color is used so
|
||||
* a pin reads the same across implementations.
|
||||
*/
|
||||
fun roadEventPinBitmap(
|
||||
emoji: String,
|
||||
colorArgb: Int,
|
||||
density: Float,
|
||||
): Bitmap {
|
||||
fun dp(value: Float) = value * density
|
||||
|
||||
val head = dp(34f) // head diameter
|
||||
val radius = head / 2f
|
||||
val tail = dp(12f) // pointer height below the head
|
||||
val pad = dp(5f) // room for the drop shadow
|
||||
val ringWidth = dp(2.5f)
|
||||
|
||||
val width = (head + pad * 2).roundToInt()
|
||||
val height = (head + tail + pad * 2).roundToInt()
|
||||
val cx = width / 2f
|
||||
val cy = pad + radius
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
|
||||
// Head circle + pointer drawn as a single path so they share one shadow
|
||||
// instead of seaming where the triangle meets the circle.
|
||||
val body =
|
||||
Path().apply {
|
||||
addCircle(cx, cy, radius, Path.Direction.CW)
|
||||
val mouth = radius * 0.7f
|
||||
moveTo(cx - mouth, cy + radius * 0.55f)
|
||||
lineTo(cx + mouth, cy + radius * 0.55f)
|
||||
lineTo(cx, pad + head + tail)
|
||||
close()
|
||||
}
|
||||
|
||||
val fill =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = colorArgb
|
||||
style = Paint.Style.FILL
|
||||
setShadowLayer(dp(3f), 0f, dp(2f), 0x80000000.toInt())
|
||||
}
|
||||
canvas.drawPath(body, fill)
|
||||
|
||||
val ring =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = ringWidth
|
||||
}
|
||||
canvas.drawCircle(cx, cy, radius - ringWidth / 2f, ring)
|
||||
|
||||
// Color emoji fonts ignore the paint color, so the glyph keeps its own hue.
|
||||
val text =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = head * 0.58f
|
||||
}
|
||||
val metrics = text.fontMetrics
|
||||
val baseline = cy - (metrics.ascent + metrics.descent) / 2f
|
||||
canvas.drawText(emoji, cx, baseline, text)
|
||||
|
||||
return bitmap
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Emoji marker for a road event category. Mirrors the icon set of the roadstr
|
||||
* reference clients (<https://github.com/jooray/roadstr>) so a report reads the
|
||||
* same across implementations; keeps the card icon-light and locale-independent.
|
||||
*/
|
||||
private fun RoadEventType.emoji(): String =
|
||||
when (this) {
|
||||
RoadEventType.POLICE -> "👮"
|
||||
RoadEventType.SPEED_CAMERA -> "📷"
|
||||
RoadEventType.TRAFFIC_JAM -> "🚗"
|
||||
RoadEventType.ACCIDENT -> "💥"
|
||||
RoadEventType.ROAD_CLOSURE -> "🚫"
|
||||
RoadEventType.CONSTRUCTION -> "🚧"
|
||||
RoadEventType.HAZARD -> "⚠️"
|
||||
RoadEventType.ROAD_CONDITION -> "🛣️"
|
||||
RoadEventType.POTHOLE -> "🕳️"
|
||||
RoadEventType.FOG -> "🌫️"
|
||||
RoadEventType.ICE -> "🧊"
|
||||
RoadEventType.ANIMAL -> "🦌"
|
||||
RoadEventType.OTHER -> "ℹ️"
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category pin color. Uses the exact palette from the roadstr reference
|
||||
* clients (<https://github.com/jooray/roadstr>) so a pin's color carries the
|
||||
* same meaning across the network.
|
||||
*/
|
||||
private fun RoadEventType.color(): Color =
|
||||
when (this) {
|
||||
RoadEventType.POLICE -> Color(0xFF0000FF)
|
||||
RoadEventType.SPEED_CAMERA -> Color(0xFF800080)
|
||||
RoadEventType.TRAFFIC_JAM -> Color(0xFFFF8C00)
|
||||
RoadEventType.ACCIDENT -> Color(0xFFFF0000)
|
||||
RoadEventType.ROAD_CLOSURE -> Color(0xFF8B0000)
|
||||
RoadEventType.CONSTRUCTION -> Color(0xFFFFD700)
|
||||
RoadEventType.HAZARD -> Color(0xFFFF4500)
|
||||
RoadEventType.ROAD_CONDITION -> Color(0xFF4682B4)
|
||||
RoadEventType.POTHOLE -> Color(0xFF795548)
|
||||
RoadEventType.FOG -> Color(0xFF9E9E9E)
|
||||
RoadEventType.ICE -> Color(0xFF00CED1)
|
||||
RoadEventType.ANIMAL -> Color(0xFF4CAF50)
|
||||
RoadEventType.OTHER -> Color(0xFF808080)
|
||||
}
|
||||
|
||||
private fun RoadEventType.labelRes(): Int =
|
||||
when (this) {
|
||||
RoadEventType.POLICE -> R.string.road_event_police
|
||||
RoadEventType.SPEED_CAMERA -> R.string.road_event_speed_camera
|
||||
RoadEventType.TRAFFIC_JAM -> R.string.road_event_traffic_jam
|
||||
RoadEventType.ACCIDENT -> R.string.road_event_accident
|
||||
RoadEventType.ROAD_CLOSURE -> R.string.road_event_road_closure
|
||||
RoadEventType.CONSTRUCTION -> R.string.road_event_construction
|
||||
RoadEventType.HAZARD -> R.string.road_event_hazard
|
||||
RoadEventType.ROAD_CONDITION -> R.string.road_event_road_condition
|
||||
RoadEventType.POTHOLE -> R.string.road_event_pothole
|
||||
RoadEventType.FOG -> R.string.road_event_fog
|
||||
RoadEventType.ICE -> R.string.road_event_ice
|
||||
RoadEventType.ANIMAL -> R.string.road_event_animal
|
||||
RoadEventType.OTHER -> R.string.road_event_other
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained card for a Roadstr road event report (kind 1315).
|
||||
*
|
||||
* A map hero pinned at the event's coordinates, with a floating category pill
|
||||
* (colored per [RoadEventType.color]) and the optional free-text comment below.
|
||||
* Identical in the feed and the opened thread view, so it takes no `makeItShort`
|
||||
* flag — mirroring [RenderBirdex].
|
||||
*/
|
||||
@Composable
|
||||
fun RenderRoadEventReport(baseNote: Note) {
|
||||
val noteEvent = baseNote.event as? RoadEventReportEvent ?: return
|
||||
|
||||
val type = remember(noteEvent) { noteEvent.roadEventType() }
|
||||
val comment = remember(noteEvent) { noteEvent.content.trim() }
|
||||
val point = remember(noteEvent) { noteEvent.roadEventPoint() }
|
||||
val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() }
|
||||
|
||||
val color = type?.color() ?: MaterialTheme.colorScheme.outline
|
||||
val emoji = type?.emoji() ?: "📍"
|
||||
val label = if (type != null) stringResource(type.labelRes()) else stringResource(R.string.road_event_unknown)
|
||||
|
||||
RoadEventCard(
|
||||
chipColor = color,
|
||||
chipEmoji = emoji,
|
||||
chipLabel = label,
|
||||
point = point,
|
||||
pinAlpha = freshness,
|
||||
comment = comment,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained card for a Roadstr confirmation/denial (kind 1316).
|
||||
*
|
||||
* Same hero/pill layout as a report, with a green ✅ "Still there" or red ❌
|
||||
* "No longer there" pill and a one-line description of the action.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderRoadEventConfirmation(baseNote: Note) {
|
||||
val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return
|
||||
|
||||
val status = remember(noteEvent) { noteEvent.status() }
|
||||
val point = remember(noteEvent) { noteEvent.roadEventPoint() }
|
||||
|
||||
val denied = status == RoadEventStatus.NO_LONGER_THERE
|
||||
val emoji = if (denied) "❌" else "✅"
|
||||
val color = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50)
|
||||
|
||||
RoadEventCard(
|
||||
chipColor = color,
|
||||
chipEmoji = emoji,
|
||||
chipLabel = stringResource(if (denied) R.string.road_event_denied else R.string.road_event_confirmed),
|
||||
point = point,
|
||||
subtitle = stringResource(if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared road event card chrome: a full-bleed [LocationPreviewMap] hero with a
|
||||
* floating [CategoryPill] overlaid on its top-start, and an optional
|
||||
* [subtitle] + [comment] block below. When [point] is null the pill stands on
|
||||
* its own so the card still reads.
|
||||
*/
|
||||
@Composable
|
||||
private fun RoadEventCard(
|
||||
chipColor: Color,
|
||||
chipEmoji: String,
|
||||
chipLabel: String,
|
||||
point: Pair<Double, Double>?,
|
||||
pinAlpha: Float = 1f,
|
||||
comment: String = "",
|
||||
subtitle: String? = null,
|
||||
) {
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
if (point != null) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
LocationPreviewMap(
|
||||
latitude = point.first,
|
||||
longitude = point.second,
|
||||
pinColor = chipColor,
|
||||
pinEmoji = chipEmoji,
|
||||
pinAlpha = pinAlpha,
|
||||
)
|
||||
CategoryPill(
|
||||
color = chipColor,
|
||||
emoji = chipEmoji,
|
||||
label = chipLabel,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(10.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CategoryPill(
|
||||
color = chipColor,
|
||||
emoji = chipEmoji,
|
||||
label = chipLabel,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (subtitle != null || comment.isNotEmpty()) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
if (comment.isNotEmpty()) {
|
||||
if (subtitle != null) Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = comment,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A floating rounded pill: [emoji] + [label] on a [color] background, text auto-contrasted. */
|
||||
@Composable
|
||||
private fun CategoryPill(
|
||||
color: Color,
|
||||
emoji: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White,
|
||||
shape = RoundedCornerShape(50),
|
||||
shadowElevation = 3.dp,
|
||||
) {
|
||||
Text(
|
||||
text = "$emoji $label",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best available coordinate for the card map: the event's explicit `lat`/`lon`
|
||||
* tags when present, otherwise the center of the finest published geohash.
|
||||
* Returns null when the event carries no location at all.
|
||||
*/
|
||||
private fun RoadEventReportEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
|
||||
|
||||
private fun RoadEventConfirmationEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
|
||||
|
||||
private fun resolveRoadEventPoint(
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
geohashes: List<String>,
|
||||
): Pair<Double, Double>? {
|
||||
if (latitude != null && longitude != null) return latitude to longitude
|
||||
|
||||
val finest = geohashes.maxByOrNull { it.length } ?: return null
|
||||
val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null
|
||||
return decoded.centerLat to decoded.centerLon
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker opacity by freshness, matching the roadstr clients: full while the
|
||||
* report has plenty of life left, dimmed to 0.6 once under 25% of its effective
|
||||
* TTL remains, and faded to 0.4 once effectively expired. Returns 1f when the
|
||||
* type (and therefore the TTL) is unknown.
|
||||
*/
|
||||
private fun RoadEventReportEvent.freshnessAlpha(now: Long = TimeUtils.now()): Float {
|
||||
val expiryAt = effectiveExpirationAt() ?: return 1f
|
||||
val total = expiryAt - createdAt
|
||||
if (total <= 0L) return 1f
|
||||
|
||||
val fraction = (expiryAt - now).toFloat() / total
|
||||
return when {
|
||||
fraction <= 0f -> 0.4f
|
||||
fraction < 0.25f -> 0.6f
|
||||
else -> 1f
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -136,12 +136,15 @@ private fun ChannelMetadataScaffold(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val channelRelays by postViewModel.channelRelays.collectAsStateWithLifecycle()
|
||||
val canPost = postViewModel.canPost && channelRelays.isNotEmpty()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (postViewModel.isNewChannel()) {
|
||||
CreatingTopBar(
|
||||
titleRes = R.string.public_chat,
|
||||
isActive = postViewModel::canPost,
|
||||
isActive = { canPost },
|
||||
onCancel = {
|
||||
postViewModel.clear()
|
||||
nav.popBack()
|
||||
@@ -163,7 +166,7 @@ private fun ChannelMetadataScaffold(
|
||||
} else {
|
||||
SavingTopBar(
|
||||
titleRes = R.string.public_chat,
|
||||
isActive = postViewModel::canPost,
|
||||
isActive = { canPost },
|
||||
onCancel = {
|
||||
postViewModel.clear()
|
||||
nav.popBack()
|
||||
@@ -183,8 +186,6 @@ private fun ChannelMetadataScaffold(
|
||||
}
|
||||
},
|
||||
) { pad ->
|
||||
val feedState by postViewModel.channelRelays.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -220,7 +221,7 @@ private fun ChannelMetadataScaffold(
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.relay }) { _, item ->
|
||||
itemsIndexed(channelRelays, key = { _, item -> "ChatRelays" + item.relay }) { _, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteHomeRelay(item) },
|
||||
|
||||
+2
-6
@@ -304,12 +304,8 @@ open class ChannelNewMessageViewModel :
|
||||
val version = draftTag.current
|
||||
cancel()
|
||||
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) {
|
||||
channelRelays.toList()
|
||||
}
|
||||
} else {
|
||||
accountViewModel.account.signAndSendPrivately(template, channelRelays)
|
||||
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) {
|
||||
channelRelays.toList()
|
||||
}
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||
|
||||
+5
-1
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -86,7 +88,9 @@ class GeoHashFeedFilter(
|
||||
event is MusicTrackEvent ||
|
||||
event is MusicPlaylistEvent ||
|
||||
event is PodcastEpisodeEvent ||
|
||||
event is PodcastMetadataEvent
|
||||
event is PodcastMetadataEvent ||
|
||||
event is RoadEventReportEvent ||
|
||||
event is RoadEventConfirmationEvent
|
||||
) &&
|
||||
event.isTaggedGeoHash(geohash)
|
||||
|
||||
|
||||
+4
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -57,6 +59,8 @@ val PostsByGeohashKinds =
|
||||
PodcastEpisodeEvent.KIND,
|
||||
PodcastMetadataEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
RoadEventReportEvent.KIND,
|
||||
RoadEventConfirmationEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterPostsByGeohash(
|
||||
|
||||
+146
-4
@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -60,10 +61,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -94,8 +97,10 @@ import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayDraft
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayOtsIfInOriginal
|
||||
import com.vitorpamplona.amethyst.ui.note.ExpandMoreIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.Expiration
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
|
||||
import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
@@ -192,6 +197,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
|
||||
@@ -220,6 +227,8 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
@@ -250,6 +259,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
|
||||
@@ -362,8 +373,45 @@ fun RenderThreadFeed(
|
||||
nav: INav,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
val levels by viewModel.levelCacheFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val position = items.list.indexOfFirst { it.idHex == noteId }
|
||||
// Hides every descendant of a collapsed reply and counts how many were hidden. The feed is
|
||||
// ordered depth-first, so a note's descendants are the contiguous items that follow it with a
|
||||
// strictly deeper reply level.
|
||||
val visible by
|
||||
remember(items, levels) {
|
||||
derivedStateOf {
|
||||
val full = items.list
|
||||
if (viewModel.collapsedReplies.isEmpty()) {
|
||||
VisibleThread(full, emptyMap())
|
||||
} else {
|
||||
val result = ArrayList<Note>(full.size)
|
||||
val hiddenCounts = HashMap<String, Int>()
|
||||
var hideDeeperThan = Int.MAX_VALUE
|
||||
var collapsedAncestorId: String? = null
|
||||
full.forEach { note ->
|
||||
val level = levels[note] ?: 0
|
||||
if (level > hideDeeperThan) {
|
||||
collapsedAncestorId?.let { hiddenCounts[it] = (hiddenCounts[it] ?: 0) + 1 }
|
||||
return@forEach
|
||||
}
|
||||
|
||||
hideDeeperThan = Int.MAX_VALUE
|
||||
collapsedAncestorId = null
|
||||
result.add(note)
|
||||
|
||||
if (viewModel.isCollapsed(note.idHex)) {
|
||||
hideDeeperThan = level
|
||||
collapsedAncestorId = note.idHex
|
||||
}
|
||||
}
|
||||
VisibleThread(result, hiddenCounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val visibleItems = visible.items
|
||||
val position = visibleItems.indexOfFirst { it.idHex == noteId }
|
||||
|
||||
LaunchedEffect(noteId, position) {
|
||||
// hack to allow multiple scrolls to Item while posts on the screen load.
|
||||
@@ -382,7 +430,7 @@ fun RenderThreadFeed(
|
||||
|
||||
if (position >= 0 && !viewModel.hasDragged.value) {
|
||||
val offset =
|
||||
if (position > items.list.size - 3) {
|
||||
if (position > visibleItems.size - 3) {
|
||||
0
|
||||
} else {
|
||||
-200
|
||||
@@ -398,9 +446,15 @@ fun RenderThreadFeed(
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
visibleItems,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { index, _ -> if (index == 0) "master" else "reply" },
|
||||
contentType = { index, item ->
|
||||
when {
|
||||
index == 0 -> "master"
|
||||
viewModel.isCollapsed(item.idHex) -> "collapsed"
|
||||
else -> "reply"
|
||||
}
|
||||
},
|
||||
) { index, item ->
|
||||
val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0)
|
||||
|
||||
@@ -426,6 +480,15 @@ fun RenderThreadFeed(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
} else if (viewModel.isCollapsed(item.idHex)) {
|
||||
CollapsedNoteCompose(
|
||||
baseNote = item,
|
||||
modifier = modifier,
|
||||
hiddenReplyCount = visible.hiddenCounts[item.idHex] ?: 0,
|
||||
onExpand = { viewModel.toggleCollapsed(item.idHex) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
val selectedNoteColor = MaterialTheme.colorScheme.selectedNote
|
||||
val background =
|
||||
@@ -433,6 +496,8 @@ fun RenderThreadFeed(
|
||||
if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null
|
||||
}
|
||||
|
||||
val onCollapse = remember(item) { { viewModel.toggleCollapsed(item.idHex) } }
|
||||
|
||||
NoteCompose(
|
||||
baseNote = item,
|
||||
modifier = modifier,
|
||||
@@ -442,6 +507,7 @@ fun RenderThreadFeed(
|
||||
parentBackgroundColor = background,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = onCollapse,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -452,6 +518,78 @@ fun RenderThreadFeed(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the thread items currently visible after collapsing, plus, for each collapsed reply id,
|
||||
* the number of descendant replies that were hidden underneath it.
|
||||
*/
|
||||
private class VisibleThread(
|
||||
val items: List<Note>,
|
||||
val hiddenCounts: Map<String, Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Compact rendering of a thread reply the user has collapsed: avatar, author name, and the
|
||||
* first two lines of its content. The right side shows how many replies are hidden underneath it
|
||||
* and an ExpandMore indicator. Tapping anywhere on the row expands the note (and reveals its
|
||||
* hidden children) again.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapsedNoteCompose(
|
||||
baseNote: Note,
|
||||
modifier: Modifier,
|
||||
hiddenReplyCount: Int,
|
||||
onExpand: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchNoteEvent(baseNote, accountViewModel, nav) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onExpand)
|
||||
// Match NoteComposeLayout: 12dp leading/trailing padding, 10dp top.
|
||||
.padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NoteAuthorPicture(baseNote = baseNote, size = Size40dp, accountViewModel = accountViewModel, nav = nav)
|
||||
|
||||
// 10dp gap between the avatar and the name, same as NoteComposeLayout's authorContentGap.
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
NoteUsernameDisplay(baseNote, accountViewModel = accountViewModel)
|
||||
|
||||
LoadDecryptedContent(baseNote, accountViewModel) { body ->
|
||||
Text(
|
||||
text = body,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
// The label carries an explicit '\n' so the count and the word always stack on two
|
||||
// lines (e.g. "+1\nreply"), keeping the horizontal footprint small and consistent.
|
||||
// Always shown — even "+0 replies" — so every collapsed card looks the same.
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.thread_collapsed_reply_count, hiddenReplyCount, hiddenReplyCount),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
ExpandMoreIcon(modifier = Modifier.size(Size20dp), contentDescriptor = R.string.expand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NoteMaster(
|
||||
@@ -814,6 +952,10 @@ private fun FullBleedNoteCompose(
|
||||
RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav)
|
||||
} else if (noteEvent is BirdexEvent) {
|
||||
RenderBirdex(baseNote)
|
||||
} else if (noteEvent is RoadEventReportEvent) {
|
||||
RenderRoadEventReport(baseNote)
|
||||
} else if (noteEvent is RoadEventConfirmationEvent) {
|
||||
RenderRoadEventConfirmation(baseNote)
|
||||
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
|
||||
RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is RelayDiscoveryEvent) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.tor
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
|
||||
/**
|
||||
* Pure, file- and JNI-free parsers over Arti's persisted guard sample
|
||||
* (`<filesDir>/arti/state/state/guards.json`).
|
||||
*
|
||||
* Both recovery heuristics in the Tor stack read the same on-disk file, so the
|
||||
* decisions live here where they can be unit-tested against captured fixtures
|
||||
* (see `ArtiGuardStateTest`). [TorService] does the file IO and delegates the
|
||||
* parsing; [TorManager] consumes [hasConfirmedGuard] to seed its
|
||||
* `hasEverBootstrapped` flag across process restarts.
|
||||
*
|
||||
* The file shape is a map of guard-set selection name (e.g. `"default"`) to an
|
||||
* object with a `"guards"` array. Each guard entry carries:
|
||||
* - `disabled`: non-null once Arti permanently retires the guard (e.g.
|
||||
* `TooManyIndeterminateFailures` on a flaky network).
|
||||
* - `unlisted_since`: non-null once the guard drops out of the consensus.
|
||||
* - `confirmed_at`: non-null once the guard has actually been used to build a
|
||||
* circuit — i.e. a real bootstrap reached the guard-confirmation stage.
|
||||
*/
|
||||
object ArtiGuardState {
|
||||
private val mapper = jacksonObjectMapper()
|
||||
|
||||
/** Convenience for tests/callers holding the raw file text. */
|
||||
fun parse(json: String): JsonNode = mapper.readTree(json)
|
||||
|
||||
/**
|
||||
* True when at least one non-empty guard selection has *zero* usable guards —
|
||||
* every guard permanently `disabled` or dropped from the consensus
|
||||
* (`unlisted_since`). This is the AllGuardsDown wedge: Arti can neither build
|
||||
* circuits nor replenish the sample (it's full of unusable entries), so it
|
||||
* stays broken across restarts until the on-disk state is wiped. A single
|
||||
* usable guard is enough to recover, so this only trips on a total wipeout.
|
||||
*/
|
||||
fun hasNoUsableGuards(root: JsonNode): Boolean {
|
||||
var wedged = false
|
||||
root.forEach { selection ->
|
||||
val guards = selection.get("guards") ?: return@forEach
|
||||
if (guards.isArray && guards.size() > 0) {
|
||||
val usable = guards.count { !it.isDisabled() && !it.isUnlisted() }
|
||||
if (usable == 0) wedged = true
|
||||
}
|
||||
}
|
||||
return wedged
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the sample contains a guard Arti has *confirmed* (non-null
|
||||
* `confirmed_at`), even if that guard is now disabled or unlisted.
|
||||
*
|
||||
* Confirmation only happens after a guard has successfully built circuits, so
|
||||
* its presence on disk is durable proof that a real bootstrap completed at
|
||||
* least once on this install — surviving the process restarts that reset
|
||||
* [TorManager]'s in-memory `hasEverBootstrapped` flag to false. This lets the
|
||||
* stuck-Connecting watchdog treat persisted state as stale/poisoned (wipe it)
|
||||
* rather than as a pristine slow first bootstrap (leave it alone).
|
||||
*/
|
||||
fun hasConfirmedGuard(root: JsonNode): Boolean {
|
||||
var confirmed = false
|
||||
root.forEach { selection ->
|
||||
val guards = selection.get("guards") ?: return@forEach
|
||||
if (guards.isArray) {
|
||||
guards.forEach { guard ->
|
||||
val at = guard.get("confirmed_at")
|
||||
if (at != null && !at.isNull) confirmed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return confirmed
|
||||
}
|
||||
|
||||
private fun JsonNode.isDisabled(): Boolean {
|
||||
val d = get("disabled")
|
||||
return d != null && !d.isNull
|
||||
}
|
||||
|
||||
private fun JsonNode.isUnlisted(): Boolean {
|
||||
val u = get("unlisted_since")
|
||||
return u != null && !u.isNull
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,13 @@ interface TorBackend {
|
||||
suspend fun reset()
|
||||
|
||||
suspend fun resetWithCleanState()
|
||||
|
||||
/**
|
||||
* True when on-disk state proves Tor bootstrapped successfully on this
|
||||
* install before. Lets [TorManager] seed `hasEverBootstrapped` across process
|
||||
* restarts so the stuck-Connecting watchdog wipes stale/poisoned state rather
|
||||
* than waiting it out as a first bootstrap. Implementations do file IO, so
|
||||
* this is a `suspend` call.
|
||||
*/
|
||||
suspend fun hasBootstrappedBefore(): Boolean
|
||||
}
|
||||
|
||||
@@ -93,10 +93,27 @@ class TorManager(
|
||||
* bootstrap there is no stale state to wipe, and wiping just forces an unnecessary
|
||||
* re-bootstrap cycle. Once we've seen Tor work once, persisted `arti/state/` is fair game
|
||||
* for the recovery to wipe.
|
||||
*
|
||||
* Also seeded at startup from [TorBackend.hasBootstrappedBefore]: the in-memory flag resets
|
||||
* every process, but Arti's persisted guard sample does not. If it already holds a confirmed
|
||||
* guard, Tor bootstrapped here before, so a stuck Connecting span means the persisted state
|
||||
* is stale/poisoned and the watchdog should wipe it. Without this seed a fresh process would
|
||||
* mistake poisoned guards for a pristine first bootstrap and only ever gentle-reset (keeping
|
||||
* the poison), looping forever — the exact "can't connect to Tor across restarts" failure.
|
||||
*/
|
||||
@Volatile private var hasEverBootstrapped: Boolean = false
|
||||
|
||||
init {
|
||||
// Seed hasEverBootstrapped from persisted on-disk evidence before the watchdog can fire
|
||||
// (well under SELF_HEAL_AFTER_MS), so a stuck bootstrap on a previously-working install
|
||||
// wipes its stale guard state instead of nursing it.
|
||||
scope.launch(ioDispatcher) {
|
||||
if (service.hasBootstrappedBefore()) {
|
||||
hasEverBootstrapped = true
|
||||
Log.d("TorManager") { "Seeded hasEverBootstrapped from persisted confirmed guard" }
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
|
||||
}
|
||||
@@ -296,13 +313,20 @@ class TorManager(
|
||||
* the per-relay success/failure outcome and the Tor-routing of each url) detects the
|
||||
* all-failing condition and pokes us here — analogous to [onNetworkChange].
|
||||
*
|
||||
* Recovery mirrors the post-Active stuck-Connecting path: drop the client and wipe
|
||||
* `arti/state/` (we *did* bootstrap, so a fully-dead exit set behind a healthy-looking
|
||||
* guards.json points at a bad persisted guard/circuit sample worth rebuilding), then bump
|
||||
* [resetEpoch] so the status combine re-enters the INTERNAL branch and runs a full
|
||||
* re-init. Shares [lastSelfHealAtMs]/[SELF_HEAL_COOLDOWN_MS] with the Connecting watchdog
|
||||
* so the two can't thrash — at most one self-heal per cooldown window. If circuits are
|
||||
* still dead after the reset, the cooldown suppresses further resets and the 60s
|
||||
* The failure is exit-side, not entry-side: the dead circuits' *exits* can't reach the
|
||||
* relays (`ExitTimeout` / `RESOLVEFAILED`), while the guards (entry) and the cached
|
||||
* consensus are fine. So recovery is a **warm** [TorBackend.reset]: drop the in-process
|
||||
* client so the next start rebuilds the circuit pool from scratch — a fresh exit draw —
|
||||
* but keep `arti/state/` (the guards were never the problem) and the consensus cache, so
|
||||
* the re-bootstrap is a ~5s warm restart, not a ~60s cold consensus re-download. We
|
||||
* deliberately do NOT [TorBackend.resetWithCleanState] here: exits aren't persisted, so
|
||||
* wiping guards + cache can't improve exit selection, and the 60s cold bootstrap it forces
|
||||
* is exactly the blackout that strands users on the [connectionFailure] dialog. Then bump
|
||||
* [resetEpoch] so the status combine re-enters the INTERNAL branch and re-inits.
|
||||
*
|
||||
* Shares [lastSelfHealAtMs]/[SELF_HEAL_COOLDOWN_MS] with the Connecting watchdog so the
|
||||
* two can't thrash — at most one self-heal per cooldown window. If the fresh circuits are
|
||||
* still dead after the rotation, the cooldown suppresses further resets and the 60s
|
||||
* [connectionFailure] dialog still offers the user the bypass.
|
||||
*/
|
||||
fun onTorCircuitsDead() {
|
||||
@@ -311,9 +335,9 @@ class TorManager(
|
||||
val now = nowMs()
|
||||
if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return
|
||||
lastSelfHealAtMs = now
|
||||
Log.w("TorManager") { "Tor Active but all circuits failing — self-healing (drop client + wipe state)" }
|
||||
Log.w("TorManager") { "Tor Active but all circuits failing — self-healing (drop client to rotate exits, keep state)" }
|
||||
scope.launch(ioDispatcher) {
|
||||
service.resetWithCleanState()
|
||||
service.reset()
|
||||
resetEpoch.update { it + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import android.content.Context
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -99,9 +100,21 @@ class TorService(
|
||||
*/
|
||||
private fun guardsFile() = File(File(File(artiDataDir(), "state"), "state"), "guards.json")
|
||||
|
||||
/** Reads and parses [guardsFile], or null if it is absent/unreadable. */
|
||||
private fun readGuardsTree(): JsonNode? {
|
||||
val file = guardsFile()
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
jacksonObjectMapper().readTree(file)
|
||||
} catch (e: Exception) {
|
||||
Log.w("TorService") { "Could not inspect guards.json: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the wedged-guard-sample state behind the long-standing "can't
|
||||
* connect to Tor" bug.
|
||||
* connect to Tor" bug (see [ArtiGuardState.hasNoUsableGuards]).
|
||||
*
|
||||
* On a flaky network, Arti records circuit failures past the first hop as
|
||||
* "indeterminate" (it can't tell whether the guard or a later hop was at
|
||||
@@ -116,43 +129,20 @@ class TorService(
|
||||
* `AllGuardsDown`. The state persists in `guards.json`, and bootstrap still
|
||||
* "succeeds" (it reads cached directory data), so none of the init-failure
|
||||
* self-heal paths ever fire and Tor is stuck across restarts.
|
||||
*
|
||||
* A single usable guard is enough to keep building circuits, so we only
|
||||
* recover at the last resort: when a non-empty guard set has *zero* usable
|
||||
* guards. A guard is unusable on disk if it has been permanently
|
||||
* `disabled` or dropped from the consensus (`unlisted_since` set);
|
||||
* reachability is in-memory only and not persisted, so it can't be checked
|
||||
* here. Returns true when at least one non-empty selection has no usable
|
||||
* guard left.
|
||||
*/
|
||||
private fun noUsableGuards(): Boolean {
|
||||
val file = guardsFile()
|
||||
if (!file.exists()) return false
|
||||
private fun noUsableGuards(): Boolean = readGuardsTree()?.let { ArtiGuardState.hasNoUsableGuards(it) } ?: false
|
||||
|
||||
return try {
|
||||
val root = jacksonObjectMapper().readTree(file)
|
||||
var wedged = false
|
||||
// Each top-level field is a guard-set selection (e.g. "default").
|
||||
root.forEach { selection ->
|
||||
val guards = selection.get("guards") ?: return@forEach
|
||||
if (guards.isArray && guards.size() > 0) {
|
||||
val usable =
|
||||
guards.count { guard ->
|
||||
val disabled = guard.get("disabled")
|
||||
val unlisted = guard.get("unlisted_since")
|
||||
val isDisabled = disabled != null && !disabled.isNull
|
||||
val isUnlisted = unlisted != null && !unlisted.isNull
|
||||
!isDisabled && !isUnlisted
|
||||
}
|
||||
if (usable == 0) wedged = true
|
||||
}
|
||||
}
|
||||
wedged
|
||||
} catch (e: Exception) {
|
||||
Log.w("TorService") { "Could not inspect guards.json: ${e.message}" }
|
||||
false
|
||||
/**
|
||||
* True when the persisted guard sample proves Tor bootstrapped successfully
|
||||
* on this install before (a confirmed guard on disk; see
|
||||
* [ArtiGuardState.hasConfirmedGuard]). [TorManager] seeds its in-memory
|
||||
* `hasEverBootstrapped` from this so a stuck bootstrap on a fresh process
|
||||
* wipes stale/poisoned state instead of nursing it as a first bootstrap.
|
||||
*/
|
||||
override suspend fun hasBootstrappedBefore(): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
readGuardsTree()?.let { ArtiGuardState.hasConfirmedGuard(it) } ?: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all Arti persistent data (state + cache). Used as a last resort
|
||||
|
||||
@@ -674,6 +674,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -586,6 +586,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1341,7 +1341,6 @@
|
||||
<string name="call_settings_turn_credential">Pověření</string>
|
||||
<string name="always_on_notif_channel_description">Udržuje připojení k vašim inbox relayím aktivní pro oznámení v reálném čase</string>
|
||||
<string name="always_on_notif_title">Amethyst oznámení aktivní</string>
|
||||
<string name="always_on_notif_connected">Připojeno k %1$d inbox relayím</string>
|
||||
<string name="always_on_notif_connecting">Připojování k inbox relayím\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Služba trvalých oznámení</string>
|
||||
<string name="always_on_notif_setting_description">Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu.</string>
|
||||
@@ -3108,6 +3107,7 @@
|
||||
<item quantity="many">%1$s +%2$d dalších</item>
|
||||
<item quantity="other">%1$s +%2$d dalších</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Cílová částka (sats)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Popište svůj cíl</string>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1325,7 +1325,6 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="call_settings_turn_credential">Anmeldeinformationen</string>
|
||||
<string name="always_on_notif_channel_description">Hält Verbindungen zu deinen Inbox-Relays aktiv für Echtzeit-Benachrichtigungen</string>
|
||||
<string name="always_on_notif_title">Amethyst-Benachrichtigungen aktiv</string>
|
||||
<string name="always_on_notif_connected">Mit %1$d Inbox-Relays verbunden</string>
|
||||
<string name="always_on_notif_connecting">Verbinde mit Inbox-Relays\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Dauerhafter Benachrichtigungsdienst</string>
|
||||
<string name="always_on_notif_setting_description">Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst.</string>
|
||||
@@ -3046,6 +3045,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<item quantity="one">%1$s +%2$d weitere</item>
|
||||
<item quantity="other">%1$s +%2$d weitere</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Zielbetrag (Sats)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Beschreibe dein Ziel</string>
|
||||
|
||||
@@ -528,6 +528,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -456,6 +456,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1603,6 +1603,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1578,6 +1578,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1578,6 +1578,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -961,6 +961,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -583,6 +583,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1451,6 +1451,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1311,7 +1311,6 @@
|
||||
<string name="call_settings_turn_credential">योग्यता प्रमाण</string>
|
||||
<string name="always_on_notif_channel_description">आपके आगतपेटिका पुनःप्रसारकों के साथ संयोजन सक्रिय रखता है तत्काल सूचनाओं के लिए</string>
|
||||
<string name="always_on_notif_title">अमेथिस्ट सूचनाएँ सक्रिय</string>
|
||||
<string name="always_on_notif_connected">संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ</string>
|
||||
<string name="always_on_notif_connecting">आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026</string>
|
||||
<string name="always_on_notif_setting_title">सदैव सक्रिय सूचना सेवा</string>
|
||||
<string name="always_on_notif_setting_description">अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे।</string>
|
||||
@@ -3033,6 +3032,7 @@
|
||||
<item quantity="one">%1$s सं॰ %2$d अधिक</item>
|
||||
<item quantity="other">%1$s सं॰ %2$d अधिक</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">उद्देश्य मात्रा (साट्स)</string>
|
||||
<string name="goal_amount_placeholder">१०००००</string>
|
||||
<string name="goal_description_label">आपके उद्देश्य का विवरण करें</string>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1327,7 +1327,6 @@
|
||||
<string name="call_settings_turn_credential">Hitelesítési adatok</string>
|
||||
<string name="always_on_notif_channel_description">Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében</string>
|
||||
<string name="always_on_notif_title">Amethyst értesítések aktíválva</string>
|
||||
<string name="always_on_notif_connected">Kapcsolódva %1$d beérkező üzenetátjátszóhoz</string>
|
||||
<string name="always_on_notif_connecting">Kapcsolódás a beérkező üzenetátjátszókhoz\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Folyamatos értesítési szolgáltatás</string>
|
||||
<string name="always_on_notif_setting_description">Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem.</string>
|
||||
@@ -3049,6 +3048,7 @@
|
||||
<item quantity="one">%1$s +%2$d további</item>
|
||||
<item quantity="other">%1$s +%2$d további</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Célösszeg (satoshiban)</string>
|
||||
<string name="goal_amount_placeholder">100 000</string>
|
||||
<string name="goal_description_label">Cél leírása, bemutatása</string>
|
||||
|
||||
@@ -525,6 +525,7 @@ Seharusnya %3$s</string>
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -446,6 +446,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
<string name="show_anyway">Toch tonen</string>
|
||||
<string name="post_was_hidden">Dit bericht is verborgen omdat het verborgen gebruikers of woorden bevat</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Bericht gemarkeerd als ongepast door</string>
|
||||
<plurals name="post_was_hidden_due_to_too_many_hashtags">
|
||||
<item quantity="one">Dit bericht bevat meer dan %1$d hashtag</item>
|
||||
<item quantity="other">Dit bericht bevat meer dan %1$d hashtags</item>
|
||||
</plurals>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d asset gebundeld</item>
|
||||
<item quantity="other">%1$d assets gebundeld</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">Bericht niet gevonden</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Kanaalafbeelding</string>
|
||||
@@ -95,7 +103,63 @@
|
||||
<string name="log_out">Uitloggen</string>
|
||||
<string name="show_more">Meer</string>
|
||||
<string name="lightning_invoice">Lightning-invoice</string>
|
||||
<string name="invoice_expired">Verlopen</string>
|
||||
<string name="clink_lightning_offer">CLINK-aanbod</string>
|
||||
<string name="clink_offer_pay_to">Aan</string>
|
||||
<string name="clink_requesting_invoice">Invoice aanvragen…</string>
|
||||
<string name="clink_debit_no_response">De debit-service heeft de betaling niet voltooid.</string>
|
||||
<string name="clink_confirm_payment_title">Betaling bevestigen</string>
|
||||
<string name="clink_confirm_pay_amount_via_source">Betaal %1$s via %2$s?</string>
|
||||
<string name="clink_confirm_pay_via_source">Betaal deze invoice via %1$s?</string>
|
||||
<string name="clink_offer_amount_sats">Bedrag (sats)</string>
|
||||
<string name="clink_offer_invalid_amount">Voer een geldig bedrag in voor dit aanbod.</string>
|
||||
<string name="clink_offer_amount_range">Toegestane range: %1$s–%2$s sats</string>
|
||||
<string name="clink_offer_label">CLINK-aanbod (noffer)</string>
|
||||
<string name="clink_budget_title">Uitgavenbudget</string>
|
||||
<string name="clink_budget_amount_sats">Bedrag (sats)</string>
|
||||
<string name="clink_budget_request">Aanvragen</string>
|
||||
<string name="clink_budget_approved">Budget goedgekeurd</string>
|
||||
<string name="clink_budget_one_time">Eenmalig</string>
|
||||
<string name="clink_budget_daily">Dagelijks</string>
|
||||
<string name="clink_budget_weekly">Wekelijks</string>
|
||||
<string name="clink_budget_monthly">Maandelijks</string>
|
||||
<string name="clink_debit_pay_only">Alleen betalen</string>
|
||||
<string name="wallet_add_clink_description">Betaal en zap vanuit een wallet die je account vooraf heeft geautoriseerd. Alleen uitgeven — geen saldo of geschiedenis.</string>
|
||||
<string name="wallet_add_clink_invalid">Ongeldige CLINK debit-pointer. Verwacht een ndebit1… string.</string>
|
||||
<string name="wallet_paste_ndebit">Plak ndebit-pointer</string>
|
||||
<string name="pay">Betalen</string>
|
||||
<string name="send_payment_title">Betaling versturen</string>
|
||||
<string name="send_payment_method">Ontvangen op</string>
|
||||
<string name="send_payment_from">Betalen vanuit</string>
|
||||
<string name="send_payment_source_external">Andere wallet-app</string>
|
||||
<string name="send_payment_method_clink">CLINK-aanbod</string>
|
||||
<string name="send_payment_amount">Bedrag</string>
|
||||
<string name="send_payment_fixed_price">Prijs vastgelegd door het aanbod</string>
|
||||
<string name="send_payment_receipt_section">Zap-ontvangstbewijs</string>
|
||||
<string name="send_payment_receipt_clink">Directe Lightning-betaling — geen zap-ontvangstbewijs wordt gepubliceerd op Nostr.</string>
|
||||
<string name="send_payment_receipt_onchain">Een on-chain zap-ontvangstbewijs wordt gepubliceerd op Nostr zodat de ontvanger de betaling kan vinden.</string>
|
||||
<string name="send_payment_receipt_onchain_address">Betaalt het aangekondigde Bitcoin-adres van het profiel (%1$s) direct vanuit je on-chain wallet — geen zap-ontvangstbewijs wordt gepubliceerd.</string>
|
||||
<string name="send_payment_receipt_cashu">Het nutzap-event levert de ecash zelf, dus het wordt altijd gepubliceerd.</string>
|
||||
<string name="send_payment_requesting_invoice">Invoice aanvragen…</string>
|
||||
<string name="send_payment_requesting_invoice_nostr">Invoice aanvragen via Nostr…</string>
|
||||
<string name="send_payment_paying_via">Betalen via %1$s…</string>
|
||||
<string name="send_payment_sending_nutzap">Ecash vergrendelen en versturen…</string>
|
||||
<string name="send_payment_building_tx">Transactie bouwen en uitzenden…</string>
|
||||
<string name="send_payment_sent_to_wallet">Invoice overhandigd aan je wallet-app om de betaling te voltooien.</string>
|
||||
<string name="send_payment_success">Betaling verzonden!</string>
|
||||
<string name="send_payment_failed">Betaling mislukt</string>
|
||||
<string name="send_payment_try_again">Probeer opnieuw</string>
|
||||
<string name="send_payment_message_recipient">Bericht de ontvanger over deze betaling</string>
|
||||
<string name="send_payment_done">Klaar</string>
|
||||
<string name="send_payment_pay_button">Betaal %1$s sats</string>
|
||||
<string name="send_payment_pay_button_empty">Betalen</string>
|
||||
<string name="send_payment_no_methods">Dit profiel heeft geen betalingsmethodes die Amethyst direct kan betalen.</string>
|
||||
<string name="send_payment_min_onchain">On-chain zaps vereisen minstens %1$s sats</string>
|
||||
<string name="send_payment_cashu_insufficient">Onvoldoende saldo in een mint die deze ontvanger accepteert. Vul eerst je Cashu-wallet aan.</string>
|
||||
<string name="send_payment_cashu_balance">Besteedbaar voor deze ontvanger: %1$s sats</string>
|
||||
<string name="send_payment_onchain_fee">Netwerkkosten</string>
|
||||
<string name="send_payment_external_wallet">je wallet-app</string>
|
||||
<string name="send_payment_onchain_txid">Transactie: %1$s</string>
|
||||
<string name="lightning_tips">Lightning-tips</string>
|
||||
<string name="note_to_receiver">Bericht aan ontvanger</string>
|
||||
<string name="thank_you_so_much">Hartelijk bedankt!</string>
|
||||
@@ -123,6 +187,7 @@
|
||||
<string name="write_a_message">Schrijf een bericht…</string>
|
||||
<string name="post">Plaatsen</string>
|
||||
<string name="save">Opslaan</string>
|
||||
<string name="send">Verzenden</string>
|
||||
<string name="create">Aanmaken</string>
|
||||
<string name="rename">Hernoemen</string>
|
||||
<string name="cancel">Annuleren</string>
|
||||
@@ -165,6 +230,9 @@
|
||||
<string name="image_saved_to_the_gallery">Afbeelding opgeslagen in galerij</string>
|
||||
<string name="video_download_has_started_toast">Video download gestart…</string>
|
||||
<string name="media_download_has_started_toast">Media download gestart…</string>
|
||||
<string name="pin_conversation">Vastzetten bovenaan</string>
|
||||
<string name="unpin_conversation">Losmaken</string>
|
||||
<string name="pinned_to_top">Vastgezet bovenaan</string>
|
||||
<string name="failed_to_save_the_image">Afbeelding opslaan mislukt</string>
|
||||
<string name="video_saved_to_the_gallery">Video opgeslagen in galerij</string>
|
||||
<string name="failed_to_save_the_video">Video opslaan mislukt</string>
|
||||
@@ -194,6 +262,7 @@
|
||||
<string name="voice_anonymize_description">Verandert de toonhoogte van je stem. Let op: eenvoudige toonhoogteveranderingen kunnen mogelijk worden teruggedraaid door vastberaden luisteraars.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen</string>
|
||||
<string name="reply_here">"hier reageren…"</string>
|
||||
<string name="user_suggestion_in_this_chat">In deze chat</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopieert note-ID naar klembord om te delen</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Kopieer kanaal-ID (note) naar klembord</string>
|
||||
<string name="edits_the_channel_metadata">Past kanaal-metadata aan</string>
|
||||
@@ -254,8 +323,12 @@
|
||||
<string name="generate_a_new_key">Nieuwe sleutel genereren</string>
|
||||
<string name="loading_feed">Feed laden…</string>
|
||||
<string name="loading_account">Account laden…</string>
|
||||
<string name="chats_history_proto_nip17">versleuteld</string>
|
||||
<string name="chats_reply_searching_history">Op zoek naar het originele bericht…</string>
|
||||
<!-- A reply whose target was searched for across all reachable history and never found. -->
|
||||
<string name="chats_reply_not_found">Kon dit bericht niet vinden</string>
|
||||
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
|
||||
<string name="chats_reply_searched">Alle relays doorzocht · tik om te zien</string>
|
||||
<string name="error_loading_replies">"Fout bij laden reacties:"</string>
|
||||
<string name="try_again">Opnieuw proberen</string>
|
||||
<string name="notification_feed_is_empty">Nog geen meldingen.</string>
|
||||
@@ -348,6 +421,10 @@
|
||||
<string name="quick_action_select">Selecteren</string>
|
||||
<string name="quick_action_share_browser_link">Browserlink delen</string>
|
||||
<string name="quick_action_share">Delen</string>
|
||||
<string name="share_as_image">Delen als afbeelding</string>
|
||||
<string name="share_as_image_url">Delen als afbeeldings-URL</string>
|
||||
<string name="share_as_image_generating">Voorbeeld genereren…</string>
|
||||
<string name="share_as_image_watermark">Gedeeld via Amethyst</string>
|
||||
<string name="quick_action_copy_user_id">Auteur-ID</string>
|
||||
<string name="quick_action_copy_note_id">Note-ID</string>
|
||||
<string name="quick_action_copy_text">Tekst kopiëren</string>
|
||||
@@ -536,7 +613,65 @@
|
||||
<string name="profile_badges_header">Badges · %1$d</string>
|
||||
<string name="profile_badges_description">Kies welke van de ontvangen badges op je profiel verschijnen.</string>
|
||||
<string name="profile_badges_empty">Je hebt nog geen badges ontvangen.</string>
|
||||
<string name="profile_apps_empty_hint">Aanbevolen de Nostr-apps die je gebruikt zodat anderen ze kunnen ontdekken.</string>
|
||||
<string name="profile_app_recommendations_title">Aanbevolen apps</string>
|
||||
<string name="profile_app_recommendations_description">Kies welke Nostr-apps je publiekelijk aanbeveelt. Aanbevelingen verschijnen op je profiel en helpen anderen apps te ontdekken voor inhoud die deze client niet kan openen.</string>
|
||||
<string name="profile_app_recommendations_empty">Nog geen apps gevonden. Apps verschijnen hier zodra ze ontdekt worden op je relays.</string>
|
||||
<string name="app_definition_untitled">Naamloze app</string>
|
||||
<string name="app_definition_no_supported_kinds">Geeft niet aan welke inhoud het ondersteunt</string>
|
||||
<string name="app_definition_recommend">Aanbevelen</string>
|
||||
<string name="app_definition_recommended">Aanbevolen</string>
|
||||
<string name="app_definition_handles">Behandelt</string>
|
||||
<string name="app_definition_by">door</string>
|
||||
<string name="app_definition_available_on">Beschikbaar op</string>
|
||||
<string name="pictures">Afbeeldingen</string>
|
||||
<string name="new_workout">Nieuwe workout</string>
|
||||
<string name="workout_title">Titel</string>
|
||||
<string name="workout_duration">Duur</string>
|
||||
<string name="workout_distance">Afstand</string>
|
||||
<string name="workout_pace">Tempo</string>
|
||||
<string name="workout_speed">Snelheid</string>
|
||||
<string name="workout_elevation">Hoogtewinst</string>
|
||||
<string name="workout_elevation_loss">Hoogteverlies</string>
|
||||
<string name="workout_calories">Calorieën</string>
|
||||
<string name="workout_steps">Stappen</string>
|
||||
<string name="workout_heart_rate">Hartslag</string>
|
||||
<string name="workout_max_heart_rate">Maximale hartslag</string>
|
||||
<string name="workout_reps">Herhalingen</string>
|
||||
<string name="workout_weight">Gewicht</string>
|
||||
<string name="workout_notes">Notities</string>
|
||||
<string name="workout_hours">Uren</string>
|
||||
<string name="workout_minutes">Minuten</string>
|
||||
<string name="workout_seconds">Seconden</string>
|
||||
<string name="workout_suggestion_connect_title">Deel je workouts</string>
|
||||
<string name="workout_suggestion_connect_message">Laat Amethyst voltooide workouts lezen uit Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) en stel voor om ze als bericht te delen.</string>
|
||||
<string name="workout_suggestion_connect_button">Verbinden</string>
|
||||
<string name="workout_suggestion_detected_title">Nieuwe workout gedetecteerd</string>
|
||||
<string name="workout_suggestion_share">Delen</string>
|
||||
<string name="workout_suggestion_dismiss">Sluiten</string>
|
||||
<string name="workout_from_health_connect">Van Health Connect</string>
|
||||
<string name="exercise_running">Hardlopen</string>
|
||||
<string name="exercise_walking">Wandelen</string>
|
||||
<string name="exercise_cycling">Fietsen</string>
|
||||
<string name="exercise_hiking">Wandelen</string>
|
||||
<string name="exercise_swimming">Zwemmen</string>
|
||||
<string name="exercise_rowing">Roeien</string>
|
||||
<string name="exercise_strength">Krachttraining</string>
|
||||
<string name="exercise_meditation">Meditatie</string>
|
||||
<string name="exercise_diet">Dieet</string>
|
||||
<string name="exercise_fasting">Vasten</string>
|
||||
<string name="nip82_repository_label">Bron: %1$s</string>
|
||||
<string name="nip82_download">Downloaden</string>
|
||||
<string name="nip82_section_about">Over</string>
|
||||
<string name="nip82_section_platforms">Platformen</string>
|
||||
<string name="nip82_section_topics">Onderwerpen</string>
|
||||
<string name="nip82_section_latest_release">Laatste release</string>
|
||||
<string name="nip82_section_comments">Reacties</string>
|
||||
<string name="nip82_no_comments">Wees de eerste die reageert.</string>
|
||||
<string name="nip82_by_author">door</string>
|
||||
<string name="nip82_older_releases_show">Toon oudere releases</string>
|
||||
<string name="nip82_older_releases_hide">Verberg oudere releases</string>
|
||||
<string name="calendars">Kalenders</string>
|
||||
<string name="shorts">Shorts</string>
|
||||
<string name="public_chats">Publieke chats</string>
|
||||
<string name="follow_packs">Volgpakketten</string>
|
||||
@@ -696,12 +831,52 @@
|
||||
<string name="nests_servers_empty">Nog geen nest-servers. Voeg er een toe of kies een aanbevolen server.</string>
|
||||
<string name="longs">Video\'s</string>
|
||||
<string name="articles">Artikelen</string>
|
||||
<string name="route_music_tracks">Muziek</string>
|
||||
<string name="route_music_playlists">Afspeellijsten</string>
|
||||
<string name="route_podcast_episodes">Afleveringen</string>
|
||||
<string name="podcast_view_episodes">Afleveringen bekijken</string>
|
||||
<string name="podcast_episodes_section">Afleveringen</string>
|
||||
<string name="podcast_no_episodes">Nog geen afleveringen gevonden</string>
|
||||
<plurals name="podcast_episode_count">
|
||||
<item quantity="one">%1$d aflevering</item>
|
||||
<item quantity="other">%1$d afleveringen</item>
|
||||
</plurals>
|
||||
<string name="new_music_track">Nieuw muziekstuk</string>
|
||||
<string name="new_music_playlist">Nieuwe afspeellijst</string>
|
||||
<string name="music_track_title_label">Titel</string>
|
||||
<string name="music_track_title_placeholder">Titel van het nummer</string>
|
||||
<string name="music_track_artist_label">Artiest</string>
|
||||
<string name="music_track_artist_placeholder">Naam artiest</string>
|
||||
<string name="music_track_audio_url_label">Audio-URL</string>
|
||||
<string name="music_track_image_url_label">Cover-afbeelding URL (optioneel)</string>
|
||||
<string name="music_track_album_label">Album (optioneel)</string>
|
||||
<string name="music_track_duration_label">Duur in seconden (optioneel)</string>
|
||||
<string name="music_track_description_label">Songteksten of credits (optioneel)</string>
|
||||
<string name="music_track_delete">Dit nummer verwijderen</string>
|
||||
<string name="music_track_delete_confirm">Een NIP-09-verwijdering publiceren voor dit muziekstuk?</string>
|
||||
<string name="add_to_music_playlist">Toevoegen aan afspeellijst</string>
|
||||
<string name="add_to_music_playlist_title">Toevoegen aan afspeellijst</string>
|
||||
<string name="add_to_music_playlist_empty">Je hebt nog geen afspeellijsten. Gebruik het veld hierboven om er een aan te maken.</string>
|
||||
<string name="add_to_music_playlist_manage_all">Alle afspeellijsten beheren</string>
|
||||
<string name="music_playlist_new_dialog_title">Nieuwe afspeellijst</string>
|
||||
<string name="music_playlist_new_title_placeholder">Naam van de afspeellijst</string>
|
||||
<string name="music_playlist_create_action">Aanmaken</string>
|
||||
<string name="music_playlist_untitled">(naamloze afspeellijst)</string>
|
||||
<plurals name="music_playlist_track_count_short">
|
||||
<item quantity="one">%1$d nummer</item>
|
||||
<item quantity="other">%1$d nummers</item>
|
||||
</plurals>
|
||||
<string name="private_bookmarks">Privé-bladwijzers</string>
|
||||
<string name="public_bookmarks">Publieke bladwijzers</string>
|
||||
<string name="add_to_private_bookmarks">Toevoegen aan privé-bladwijzers</string>
|
||||
<string name="add_to_public_bookmarks">Toevoegen aan publieke bladwijzers</string>
|
||||
<string name="remove_from_private_bookmarks">Verwijderen uit privé-bladwijzers</string>
|
||||
<string name="remove_from_public_bookmarks">Verwijderen uit publieke bladwijzers</string>
|
||||
<string name="add_hashtag_label">Hashtag toevoegen</string>
|
||||
<string name="add_hashtag_label_title">Hashtag toevoegen</string>
|
||||
<string name="add_hashtag_label_explainer">Publiek labelen met een hashtag (NIP-32 label). Mensen die je volgen zien het in de feed van die hashtag.</string>
|
||||
<string name="add_hashtag_label_confirm">Toevoegen</string>
|
||||
<string name="hashtag_label_added_by">toegevoegd door</string>
|
||||
<string name="pinned_notes">Vastgezette notities</string>
|
||||
<string name="pinned_notes_explainer">Je vastgezette notities</string>
|
||||
<string name="pin_to_profile">Vastmaken aan profiel</string>
|
||||
@@ -748,6 +923,15 @@
|
||||
<string name="article_bookmark_management_title">Dit artikel bookmarken</string>
|
||||
<string name="public_bookmark_presence_indicator">is een publieke bladwijzer hier</string>
|
||||
<string name="private_bookmark_presence_indicator">is een privébladwijzer hier</string>
|
||||
<string name="private_rumor_mark">Privé — alleen zichtbaar voor getagde deelnemers</string>
|
||||
<string name="private_note">Privé maken: gift-wrap de notitie alleen voor de genoemde gebruikers</string>
|
||||
<string name="disable_private_note">Publiek maken</string>
|
||||
<string name="private_note_locked">Reacties op een privénotitie blijven altijd privé</string>
|
||||
<string name="private_note_visible_to">Zichtbaar voor</string>
|
||||
<string name="private_note_no_receivers">Nog geen ontvangers: alleen jij kunt deze notitie zien. Voeg mensen toe om te delen.</string>
|
||||
<string name="notify_add_user">Toevoegen</string>
|
||||
<string name="notify_remove_user">Gebruiker verwijderen uit meldingen</string>
|
||||
<string name="notify_search_and_add_user">Zoek en voeg een gebruiker toe om te melden</string>
|
||||
<string name="bookmark_absence_indicator">is geen bladwijzer hier</string>
|
||||
<string name="bookmark_remove_action_desc">Bladwijzer uit lijst verwijderen</string>
|
||||
<string name="bookmark_add_action_desc">Bladwijzer toevoegen aan lijst</string>
|
||||
@@ -769,6 +953,29 @@
|
||||
<string name="wallet_connect_manual_config">Geavanceerd: handmatig verbindingsgegevens invoeren</string>
|
||||
<string name="quick_zap_amounts">Snelle zap-bedragen</string>
|
||||
<string name="quick_zap_amounts_explainer">Worden getoond bij het indrukken van de zap-knop. Tik op een bedrag om te verwijderen. Leeg laten opent steeds het invoerscherm.</string>
|
||||
<string name="send_onchain_instead">In plaats daarvan on-chain versturen</string>
|
||||
<string name="reload_mint_title">Mint aanvullen</string>
|
||||
<string name="reload_mint_topup_label">Aanvullingsbedrag</string>
|
||||
<string name="reload_mint_section_to">Mint om aan te vullen</string>
|
||||
<string name="reload_mint_section_from">Gelden van</string>
|
||||
<string name="reload_mint_pay_lightning">Betalen met Lightning</string>
|
||||
<string name="reload_mint_lightning_desc">Nieuwe ecash minten vanuit je Lightning-wallet</string>
|
||||
<string name="reload_mint_not_enough">Niet genoeg hier</string>
|
||||
<string name="reload_mint_available">%1$s sats beschikbaar</string>
|
||||
<string name="reload_mint_confirm">Aanvullen & zap versturen</string>
|
||||
<string name="reload_mint_send_confirm">Zap versturen</string>
|
||||
<string name="reload_mint_awaiting_payment">Wachten op betaling…</string>
|
||||
<string name="reload_mint_retry">Probeer opnieuw</string>
|
||||
<string name="reload_mint_summary">Vul %1$s sats aan bij %2$s en zap %3$s %4$s sats · kosten ≈ %5$s sats</string>
|
||||
<string name="reload_mint_summary_funded">Zap %1$s %2$s sats vanuit %3$s</string>
|
||||
<string name="reload_mint_recipient_fallback">de ontvanger</string>
|
||||
<string name="reload_mint_needs_more">heeft %1$s sat meer nodig</string>
|
||||
<string name="reload_mint_funded">aangevuld</string>
|
||||
<string name="reload_mint_copy_invoice">Invoice kopiëren</string>
|
||||
<string name="topup_mint_title">Mint aanvullen</string>
|
||||
<string name="topup_mint_action">Deze mint aanvullen</string>
|
||||
<string name="topup_mint_amount_label">Bedrag om toe te voegen</string>
|
||||
<string name="topup_mint_confirm">Aanvullen</string>
|
||||
<string name="zap_privacy_section">Zap-privacy</string>
|
||||
<string name="zap_type_section_explainer">Bepaalt hoe jouw identiteit zichtbaar is bij het versturen van een zap.</string>
|
||||
<string name="wallet_connect_connect_app">Wallet verbinden</string>
|
||||
@@ -859,6 +1066,7 @@
|
||||
<string name="payment_target_authority">Adres</string>
|
||||
<string name="delete_payment_target">Betalingsdoel verwijderen</string>
|
||||
<string name="no_payment_app_found">Geen app gevonden om deze betaling te verwerken. Installeer een compatibele wallet.</string>
|
||||
<string name="no_payment_app_found_for_type">Geen app geïnstalleerd om %1$s-betalingen te verwerken. Installeer een compatibele wallet.</string>
|
||||
<string name="error_dialog_payment_error">Kan betaling niet openen</string>
|
||||
<string name="uploading_state_ready">Niet gestart</string>
|
||||
<string name="uploading_state_compressing">Comprimeren</string>
|
||||
@@ -886,6 +1094,7 @@
|
||||
<string name="follow_list_kind3follows_proxy">Volgt via proxy</string>
|
||||
<string name="follow_list_aroundme">In de buurt</string>
|
||||
<string name="follow_list_global">Globaal</string>
|
||||
<string name="follow_list_curated">Gecureerd</string>
|
||||
<string name="follow_list_chess">Schaken</string>
|
||||
<string name="follow_list_mine">Van mij</string>
|
||||
<string name="follow_list_mute_list">Gedempte lijst</string>
|
||||
@@ -1069,7 +1278,6 @@
|
||||
<string name="call_settings_turn_credential">Credential</string>
|
||||
<string name="always_on_notif_channel_description">Houdt verbindingen met je inbox-relays actief voor realtime meldingen</string>
|
||||
<string name="always_on_notif_title">Amethyst-meldingen actief</string>
|
||||
<string name="always_on_notif_connected">Verbonden met %1$d inbox-relays</string>
|
||||
<string name="always_on_notif_connecting">Verbinden met inbox-relays…</string>
|
||||
<string name="always_on_notif_setting_title">Altijd-aan meldingsdienst</string>
|
||||
<string name="always_on_notif_setting_description">Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist.</string>
|
||||
@@ -1606,7 +1814,74 @@
|
||||
<string name="wallet_invalid_uri">Ongeldige NWC-verbinding-URI</string>
|
||||
<string name="wallet_move_up">Omhoog verplaatsen</string>
|
||||
<string name="wallet_move_down">Omlaag verplaatsen</string>
|
||||
<string name="wallet_onchain_transactions">Onchain-transacties</string>
|
||||
<string name="wallet_onchain_no_address">Geen on-chain adres beschikbaar voor dit account.</string>
|
||||
<string name="wallet_onchain_no_backend">Geen chain-backend geconfigureerd.</string>
|
||||
<string name="wallet_onchain_pending">In behandeling</string>
|
||||
<string name="wallet_onchain_public_chip">Publiek</string>
|
||||
<string name="wallet_onchain_public_dialog_title">Deze wallet is publiek</string>
|
||||
<string name="wallet_onchain_public_dialog_body">Je Taproot-adres is afgeleid van je Nostr-publieke sleutel, dus iedereen die je npub kent kan het saldo en de transactiegeschiedenis van deze wallet op de blockchain zien.\n\nHoud je privacy in stand door deze wallet te financieren en leeg te halen via niet-private accounts, zoals exchanges. Meng deze fondsen nooit met je cold wallets en behandel ze als geld dat je kunt verliezen, aangezien iedereen met je nsec het kan uitgeven.</string>
|
||||
<string name="wallet_onchain_public_dialog_confirm">Begrepen</string>
|
||||
<string name="wallet_onchain_copy_dialog_confirm">Adres kopiëren</string>
|
||||
<!-- Onchain zap send dialog -->
|
||||
<string name="onchain_send_title">On-chain zap versturen</string>
|
||||
<string name="onchain_send_close">Sluiten</string>
|
||||
<string name="onchain_send_to">Aan</string>
|
||||
<string name="onchain_send_post_author">Berichtauteur</string>
|
||||
<string name="onchain_send_recipient">Ontvanger</string>
|
||||
<string name="onchain_send_recipient_placeholder">naam, NIP-05 of npub</string>
|
||||
<string name="onchain_send_change_recipient">Ontvanger wijzigen</string>
|
||||
<string name="onchain_send_amount">Bedrag</string>
|
||||
<string name="onchain_send_min_warning">Minimum on-chain zap is %1$s sats — kleinere bedragen gaan verloren aan miner-kosten. Gebruik een Lightning-zap in plaats daarvan.</string>
|
||||
<string name="onchain_send_comment_label">Opmerking (optioneel)</string>
|
||||
<string name="onchain_send_priority">Prioriteit</string>
|
||||
<string name="onchain_send_fee_slow">Langzaam</string>
|
||||
<string name="onchain_send_fee_normal">Normaal</string>
|
||||
<string name="onchain_send_fee_fast">Snel</string>
|
||||
<string name="onchain_send_fee_eta_slow">~1 uur</string>
|
||||
<string name="onchain_send_loading_fees">Kostenramingen laden…</string>
|
||||
<string name="onchain_send_button_amount">Verstuur %1$s sats</string>
|
||||
<plurals name="onchain_send_button_amount_split">
|
||||
<item quantity="one">Verstuur %1$s sats, %2$d-weg</item>
|
||||
<item quantity="other">Verstuur %1$s sats, %2$d-wegen</item>
|
||||
</plurals>
|
||||
<plurals name="onchain_send_use_note_split">
|
||||
<item quantity="one">Gebruik de %1$d-weg zap-split van dit bericht</item>
|
||||
<item quantity="other">Gebruik de %1$d-weg zap-split van dit bericht</item>
|
||||
</plurals>
|
||||
<plurals name="onchain_send_splits_label">
|
||||
<item quantity="one">Splits onder %1$d ontvanger</item>
|
||||
<item quantity="other">Splits onder %1$d ontvangers</item>
|
||||
</plurals>
|
||||
<plurals name="onchain_send_splits_skipped">
|
||||
<item quantity="one">%1$d Lightning-adres-alleen-ontvanger overgeslagen — on-chain heeft een Nostr-pubkey nodig om het adres af te leiden.</item>
|
||||
<item quantity="other">%1$d Lightning-adres-alleen-ontvangers overgeslagen — on-chain heeft een Nostr-pubkey nodig om het adres af te leiden.</item>
|
||||
</plurals>
|
||||
<string name="onchain_send_dont_split">Niet splitsen — betaal één ontvanger in plaats daarvan</string>
|
||||
<string name="onchain_send_sending">Transactie bouwen, ondertekenen en uitzenden…</string>
|
||||
<string name="onchain_send_success">On-chain zap verstuurd</string>
|
||||
<string name="onchain_send_result_transaction">Transactie</string>
|
||||
<string name="onchain_send_result_fee">Kosten</string>
|
||||
<string name="onchain_send_result_change">Wisselgeld</string>
|
||||
<string name="onchain_send_done">Klaar</string>
|
||||
<string name="onchain_send_failed_at">Mislukt bij: %1$s</string>
|
||||
<string name="onchain_send_broadcast_no_receipt">Betaling uitgezonden (tx %1$s) maar het ontvangstbewijs is niet gepubliceerd.</string>
|
||||
<string name="onchain_stage_loading_utxos">saldo laden</string>
|
||||
<string name="onchain_stage_building">transactie bouwen</string>
|
||||
<string name="onchain_stage_signing">ondertekenen</string>
|
||||
<string name="onchain_stage_broadcasting">uitzenden</string>
|
||||
<string name="onchain_stage_publishing">ontvangstbewijs publiceren</string>
|
||||
<string name="onchain_send_error_backend_not_configured">Bitcoin chain-backend is niet geconfigureerd</string>
|
||||
<string name="onchain_send_error_load_utxos">Kon je Bitcoin-saldo niet laden</string>
|
||||
<string name="onchain_send_error_build">Kon de transactie niet bouwen</string>
|
||||
<string name="onchain_send_error_recipient_dust">Een aandeel van een ontvanger is onder dust</string>
|
||||
<string name="onchain_send_error_sign">Kon de transactie niet ondertekenen</string>
|
||||
<string name="onchain_send_error_broadcast">Kon de transactie niet uitzenden</string>
|
||||
<string name="onchain_send_error_publish_receipt">Betaling verstuurd, maar het zap-ontvangstbewijs kon niet worden gepubliceerd</string>
|
||||
<plurals name="onchain_send_error_publish_receipt_partial">
|
||||
<item quantity="one">Betaling verstuurd (%1$d van %2$d ontvangstbewijs gepubliceerd), maar het volgende ontvangstbewijs kon niet worden gepubliceerd</item>
|
||||
<item quantity="other">Betaling verstuurd (%1$d van %2$d ontvangstbewijzen gepubliceerd), maar het volgende ontvangstbewijs kon niet worden gepubliceerd</item>
|
||||
</plurals>
|
||||
<string name="route_security_filters">Beveiligingsfilters</string>
|
||||
<string name="route_import_follows">Volgers importeren</string>
|
||||
<string name="new_post">Nieuw bericht</string>
|
||||
@@ -1619,6 +1894,119 @@
|
||||
<string name="new_zap_poll">Nieuwe Zap-poll</string>
|
||||
<string name="new_regular_poll">Nieuwe gewone poll</string>
|
||||
<string name="new_picture">Nieuwe afbeelding</string>
|
||||
<string name="new_calendar_event">Nieuw agenda-evenement</string>
|
||||
<string name="edit_calendar_event">Agenda-evenement bewerken</string>
|
||||
<string name="calendar_event_all_day_locked">Vergrendeld tijdens bewerken — wijzigen zou een nieuw evenement creëren.</string>
|
||||
<string name="new_calendar_collection">Nieuwe agenda</string>
|
||||
<string name="edit_calendar_collection">Agenda bewerken</string>
|
||||
<string name="calendar_collection_events_section">Evenementen in deze agenda (%1$d)</string>
|
||||
<string name="calendar_collection_no_events_yet">Je hebt nog geen agenda-evenementen aangemaakt.</string>
|
||||
<string name="calendar_view_month">Maand</string>
|
||||
<string name="calendar_view_day">Dag</string>
|
||||
<string name="calendar_section_upcoming">Aankomend</string>
|
||||
<string name="calendar_section_past">Verleden</string>
|
||||
<string name="calendar_empty_feed">Nog geen aankomende of verleden agenda-evenementen van je geselecteerde feed.</string>
|
||||
<string name="calendar_empty_collections">Nog geen agenda-collecties.</string>
|
||||
<string name="calendar_empty_feed_title">Je agenda is leeg</string>
|
||||
<string name="calendar_empty_feed_subtitle">Evenementen gedeeld door mensen die je volgt verschijnen hier. Tik op de + knop om er zelf een aan te maken.</string>
|
||||
<string name="calendar_empty_collections_title">Nog geen collecties</string>
|
||||
<string name="calendar_empty_collections_subtitle">Groepeer evenementen — een serie meetups, een conferentie-track, de roadmap van je team. Tik op + om er een aan te maken.</string>
|
||||
<string name="calendar_empty_day_title">Niets gepland</string>
|
||||
<string name="calendar_empty_day_subtitle">Geen evenementen op deze dag. Tik op + om er een toe te voegen.</string>
|
||||
<string name="calendar_empty_week_title">Niets deze week</string>
|
||||
<string name="calendar_empty_week_subtitle">Geen evenementen vallen in deze week.</string>
|
||||
<string name="calendar_event_title">Titel</string>
|
||||
<string name="calendar_event_summary">Samenvatting</string>
|
||||
<string name="calendar_event_location">Locatie</string>
|
||||
<string name="calendar_event_image">Afbeeldings-URL</string>
|
||||
<string name="calendar_event_all_day">Hele dag evenement</string>
|
||||
<string name="calendar_event_start">Begint</string>
|
||||
<string name="calendar_event_end">Eindigt</string>
|
||||
<string name="calendar_event_hashtags">Hashtags (komma-gescheiden)</string>
|
||||
<string name="calendar_event_pick_date">Datum kiezen</string>
|
||||
<string name="calendar_event_pick_time">Tijd kiezen</string>
|
||||
<string name="calendar_event_invalid">Titel en starttijd zijn verplicht.</string>
|
||||
<string name="calendar_event_end_before_start">Eind moet na start zijn.</string>
|
||||
<string name="calendar_nav_previous_month">Vorige maand</string>
|
||||
<string name="calendar_nav_next_month">Volgende maand</string>
|
||||
<string name="calendar_nav_previous_week">Vorige week</string>
|
||||
<string name="calendar_nav_next_week">Volgende week</string>
|
||||
<string name="calendar_nav_previous_day">Vorige dag</string>
|
||||
<string name="calendar_nav_next_day">Volgende dag</string>
|
||||
<string name="calendar_no_events_today">Geen evenementen op deze dag</string>
|
||||
<string name="calendar_no_events">Geen evenementen</string>
|
||||
<string name="calendar_continues">Doorgaan</string>
|
||||
<string name="calendar_day_of_total">Dag %1$d van %2$d</string>
|
||||
<string name="calendar_add_to_phone_calendar">Toevoegen aan telefoon-agenda</string>
|
||||
<string name="calendar_nav_jump_to_today">Spring naar vandaag</string>
|
||||
<plurals name="calendar_day_a11y_events">
|
||||
<item quantity="one">%1$s, %2$d evenement</item>
|
||||
<item quantity="other">%1$s, %2$d evenementen</item>
|
||||
</plurals>
|
||||
<string name="calendar_day_a11y_no_events">%1$s, geen evenementen</string>
|
||||
<string name="calendar_day_a11y_today_suffix">vandaag</string>
|
||||
<string name="calendar_day_a11y_selected_suffix">geselecteerd</string>
|
||||
<string name="calendar_fab_new_event">Nieuw agenda-evenement aanmaken</string>
|
||||
<string name="calendar_fab_new_collection">Nieuwe agenda-collectie aanmaken</string>
|
||||
<string name="calendar_fab_toggle">Aanmaakopties tonen</string>
|
||||
<string name="calendar_untitled">(naamloos)</string>
|
||||
<string name="calendar_all_day">Hele dag</string>
|
||||
<string name="calendar_rsvp_going_prefixed">✓ Ga</string>
|
||||
<string name="calendar_rsvp_maybe_prefixed">\? Misschien</string>
|
||||
<string name="calendar_rsvp_not_going_prefixed">✗ Kan niet</string>
|
||||
<string name="calendar_collection_title">Titel</string>
|
||||
<string name="calendar_collection_description">Omschrijving</string>
|
||||
<string name="calendar_collection_invalid">Een titel is verplicht.</string>
|
||||
<plurals name="calendar_collection_count">
|
||||
<item quantity="one">%1$d evenement</item>
|
||||
<item quantity="other">%1$d evenementen</item>
|
||||
</plurals>
|
||||
<string name="calendar_collection_empty_members">Nog geen evenementen in deze agenda.</string>
|
||||
<string name="calendar_rsvp_going">Ga</string>
|
||||
<string name="calendar_rsvp_maybe">Misschien</string>
|
||||
<string name="calendar_rsvp_not_going">Kan niet</string>
|
||||
<string name="calendar_rsvp_none">Nog geen RSVPs.</string>
|
||||
<string name="calendar_participants_section">Deelnemers (%1$d)</string>
|
||||
<string name="calendar_event_in_calendars">In agenda\'s (%1$d)</string>
|
||||
<string name="calendar_event_in_no_calendars">Nog niet onderdeel van een agenda.</string>
|
||||
<string name="calendar_event_loading">Evenement laden…</string>
|
||||
<string name="calendar_relative_ongoing">Nu aan de gang</string>
|
||||
<string name="calendar_relative_ongoing_with_end">%1$s · eindigt %2$s</string>
|
||||
<string name="calendar_export_share_title">Agenda-evenement delen</string>
|
||||
<string name="calendar_export_event">Exporteren naar agenda (.ics)</string>
|
||||
<string name="calendar_reminder_channel_name">Agenda-herinneringen</string>
|
||||
<string name="calendar_reminder_channel_description">Melding wanneer een evenement waar je naartoe gaat bijna begint.</string>
|
||||
<string name="calendar_reminder_default_title">Agenda-evenement</string>
|
||||
<plurals name="calendar_reminder_body">
|
||||
<item quantity="one">Begint over %1$d minuut</item>
|
||||
<item quantity="other">Begint over %1$d minuten</item>
|
||||
</plurals>
|
||||
<string name="calendar_add_to_calendar_action">Toevoegen aan een van je agenda\'s</string>
|
||||
<string name="calendar_add_to_calendar_title">Toevoegen aan een agenda</string>
|
||||
<string name="calendar_add_to_calendar_none">Je hebt nog geen agenda\'s aangemaakt.</string>
|
||||
<string name="calendar_collection_delete">Agenda verwijderen</string>
|
||||
<string name="calendar_collection_delete_confirm_title">Deze agenda verwijderen?</string>
|
||||
<string name="calendar_collection_delete_confirm_message">De agenda-lijst wordt verwijderd. Evenementen erin worden niet verwijderd.</string>
|
||||
<string name="calendar_event_pick_image">Afbeelding kiezen</string>
|
||||
<string name="calendar_event_image_upload_failed">Afbeelding uploaden mislukt</string>
|
||||
<string name="calendar_event_image_upload_failed_body">De gekozen afbeelding kon niet worden geüpload. Probeer opnieuw of plak een URL.</string>
|
||||
<string name="calendar_event_participants_section">Deelnemers (%1$d)</string>
|
||||
<string name="calendar_event_participant_input">Zoek naam, npub of nip-05</string>
|
||||
<string name="calendar_event_participant_invalid">Voer een geldige npub… of 64-karakter hex pubkey in.</string>
|
||||
<string name="calendar_event_participant_remove">Deelnemer verwijderen</string>
|
||||
<string name="calendar_reminder_settings_title">Agenda-herinneringen</string>
|
||||
<string name="calendar_reminder_settings_enabled_title">Herinneringen versturen</string>
|
||||
<string name="calendar_reminder_settings_enabled_subtitle">Een melding verschijnt wanneer een evenement waar je naartoe gaat bijna begint.</string>
|
||||
<string name="calendar_reminder_settings_lead_title">Herinneringstijd</string>
|
||||
<string name="calendar_reminder_settings_lead_subtitle">Hoeveel minuten voor het evenement je een melding wilt ontvangen.</string>
|
||||
<string name="calendar_share_nostr">Delen als nostr-link</string>
|
||||
<string name="calendar_share_nostr_title">Agenda-link delen</string>
|
||||
<string name="calendar_filter_all">Alle agenda\'s</string>
|
||||
<string name="calendar_filter_sheet_title">Toon evenementen van…</string>
|
||||
<string name="calendar_filter_no_calendars">Je hebt nog geen agenda\'s aangemaakt.</string>
|
||||
<string name="calendar_open_in_maps">Openen in kaarten</string>
|
||||
<string name="calendar_open_link">Link openen</string>
|
||||
<string name="route_calendar_event_detail">Evenementdetails</string>
|
||||
<string name="new_short_video">Nieuwe short video</string>
|
||||
<string name="new_long_video">Nieuwe lange video</string>
|
||||
<string name="article_title">Titel</string>
|
||||
@@ -1642,6 +2030,7 @@
|
||||
<string name="boost_or_quote_description">Boost of citaat</string>
|
||||
<string name="like_description">Vind ik leuk</string>
|
||||
<string name="zap_description">Zap</string>
|
||||
<string name="onchain_zap_pending">Wachten op bevestiging</string>
|
||||
<string name="change_reaction">Snelle reacties wijzigen</string>
|
||||
<string name="bottom_bar_settings">Onderste navigatiebalk</string>
|
||||
<string name="bottom_bar_settings_description">Sleep om te herordenen. Schakel in/uit om items toe te voegen of te verwijderen. Met nul items wordt de balk verborgen.</string>
|
||||
@@ -1693,6 +2082,15 @@
|
||||
<string name="video_player_settings_action_pip_description">Video in zwevend venster afspelen (verborgen als niet ondersteund)</string>
|
||||
<string name="video_player_settings_action_cast">Casten naar apparaat</string>
|
||||
<string name="video_player_settings_action_cast_description">Video casten naar een Chromecast-ontvanger op je wifi (verborgen bij lokale bestanden)</string>
|
||||
<string name="audio_visualizer_settings">Audio-visualisatie</string>
|
||||
<string name="audio_visualizer_settings_description">Kies de animatie die getoond wordt tijdens het afspelen van audionotities.</string>
|
||||
<string name="audio_visualizer_off">Uit</string>
|
||||
<string name="audio_visualizer_bars">Spectrum-balken</string>
|
||||
<string name="audio_visualizer_waves">Kleur-golven</string>
|
||||
<string name="audio_visualizer_radial">Radiale ring</string>
|
||||
<string name="audio_visualizer_aurora">Aurora-gloed</string>
|
||||
<string name="audio_visualizer_classic">Klassieke golfvorm</string>
|
||||
<string name="audio_visualizer_static">Statische afbeelding</string>
|
||||
<string name="profile_image_of_user">Profielfoto van %1$s</string>
|
||||
<string name="relay_info">Relay %1$s</string>
|
||||
<string name="expand_relay_list">Relay-lijst uitvouwen</string>
|
||||
@@ -2062,6 +2460,12 @@
|
||||
<string name="kind_user_settings">Gebruikersinstellingen</string>
|
||||
<string name="kind_audio_header">Audio-header</string>
|
||||
<string name="kind_audio_track">Audiospoor</string>
|
||||
<string name="kind_music_track">Muziekstuk</string>
|
||||
<string name="kind_music_playlist">Afspeellijst</string>
|
||||
<string name="kind_podcast_episode">Podcast-aflevering</string>
|
||||
<string name="kind_podcast_metadata">Podcast-show</string>
|
||||
<string name="kind_authored_podcasts">Gemaakt podcasts</string>
|
||||
<string name="kind_favorite_podcasts">Favoriete podcasts</string>
|
||||
<string name="kind_badge_awards">Badge-awards</string>
|
||||
<string name="kind_badge_definitions">Badge-definities</string>
|
||||
<string name="kind_accepted_badge_set">Geaccepteerde badge-set</string>
|
||||
@@ -2304,6 +2708,29 @@
|
||||
<item quantity="one">%1$d jaar</item>
|
||||
<item quantity="other">%1$d jaar</item>
|
||||
</plurals>
|
||||
<plurals name="cashu_pending_quotes_title">
|
||||
<item quantity="one">%1$d openstaande invoice</item>
|
||||
<item quantity="other">%1$d openstaande invoices</item>
|
||||
</plurals>
|
||||
<string name="cashu_pending_quotes_subtitle">Tik om door te gaan en de status te controleren.</string>
|
||||
<string name="cashu_pending_quotes_resume">Doorgaan</string>
|
||||
<plurals name="cashu_untrusted_mint_title">
|
||||
<item quantity="one">Fondsen bewaard bij %1$d mint die je niet gebruikt</item>
|
||||
<item quantity="other">Fondsen bewaard bij %1$d mints die je niet gebruikt</item>
|
||||
</plurals>
|
||||
<string name="cashu_untrusted_mint_subtitle">Deze munten kwamen van een nutzap. Verplaats ze naar een mint die je vertrouwt of trek ze terug naar Lightning om ze veilig te houden.</string>
|
||||
<string name="cashu_untrusted_mint_badge">Niet in je wallet</string>
|
||||
<string name="cashu_untrusted_mint_move">Verplaatsen</string>
|
||||
<string name="cashu_move_coins_title">Munten verplaatsen</string>
|
||||
<string name="cashu_move_coins_body">%2$s sat bij een mint die je niet gebruikt (%1$s). Verplaats ze naar een mint die je vertrouwt.</string>
|
||||
<string name="cashu_move_coins_to_mint">Verplaatsen naar een mint die je vertrouwt</string>
|
||||
<string name="cashu_move_coins_move">Verplaatsen</string>
|
||||
<string name="cashu_move_coins_fee_hint">Een kleine Lightning-kost wordt afgetrokken door deze mint, dus je moet mogelijk iets minder dan het volledige saldo sturen.</string>
|
||||
<string name="cashu_move_coins_no_trusted">Voeg eerst een mint toe aan je wallet om munten tussen mints te verplaatsen, of gebruik de opties hieronder.</string>
|
||||
<string name="cashu_move_coins_withdraw_ln">Terugtrekken via Lightning…</string>
|
||||
<string name="cashu_move_coins_export_token">Exporteren als Cashu-token…</string>
|
||||
<string name="cashu_move_coins_working">Munten verplaatsen…</string>
|
||||
<string name="cashu_move_coins_done">%1$s sat verplaatst naar %2$s.</string>
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="event_sync_status_connecting">Verbinden</string>
|
||||
<string name="event_sync_status_downloading">Downloaden</string>
|
||||
@@ -2409,6 +2836,17 @@
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<string name="goal_closed">Dit doel is gesloten</string>
|
||||
<string name="goal_progress">%1$s gefinancierd van %2$s sats doel</string>
|
||||
<string name="fundraiser_ends">Eindigt %1$s</string>
|
||||
<string name="fundraiser_onchain_donation">On-chain donatie</string>
|
||||
<plurals name="birdex_species_count">
|
||||
<item quantity="one">Birdex · %1$d soorten</item>
|
||||
<item quantity="other">Birdex · %1$d soorten</item>
|
||||
</plurals>
|
||||
<plurals name="birdex_species_preview_more">
|
||||
<item quantity="one">%1$s +%2$d meer</item>
|
||||
<item quantity="other">%1$s +%2$d meer</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Doelbedrag (sats)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Beschrijf je doel</string>
|
||||
@@ -2519,6 +2957,8 @@
|
||||
<string name="ai_writing_setting_description">Gebruikt een on-device AI-model om tekstcorrecties en toonwijzigingen voor te stellen.</string>
|
||||
<string name="tracked_broadcasts_setting_title">Getrackte uitzendingen</string>
|
||||
<string name="tracked_broadcasts_setting_description">Gebruik de tracked broadcaster bij het verzenden van events. Toont live voortgang en per-relay-status tijdens uitzenden.</string>
|
||||
<string name="suggest_workouts_setting_title">Workouts voorstellen om te delen</string>
|
||||
<string name="suggest_workouts_setting_description">Leest voltooide workouts uit Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) en stelt voor om ze als bericht te delen. Toestemming wordt alleen gevraagd wanneer je verbindt.</string>
|
||||
<string name="compose_settings">Opstel-instellingen</string>
|
||||
<string name="auto_create_drafts_setting_title">Automatisch concepten aanmaken</string>
|
||||
<string name="auto_create_drafts_setting_description">Slaat automatisch een concept op wanneer je typt of de opsteller verlaat met onverzonden tekst en stuurt dit naar je privé outbox-relays.</string>
|
||||
@@ -2580,4 +3020,13 @@
|
||||
<string name="community_rules_violation_wot_gate_failed">Je voldoet niet aan de web-of-trust-vereisten van deze community.</string>
|
||||
<string name="community_rules_violation_stale_rules">Het laatste community-regelsdocument is afgewezen als verouderd.</string>
|
||||
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
|
||||
<string name="nowhere_link_card_generic">Nowhere-site</string>
|
||||
<string name="nowhere_link_card_event">Nowhere-evenement</string>
|
||||
<string name="nowhere_link_card_fundraiser">Nowhere-donatieactie</string>
|
||||
<string name="nowhere_link_card_store">Nowhere-winkel</string>
|
||||
<string name="nowhere_link_card_petition">Nowhere-petitie</string>
|
||||
<string name="nowhere_link_card_message">Nowhere-bericht</string>
|
||||
<string name="nowhere_link_card_drop">Nowhere-drop</string>
|
||||
<string name="nowhere_link_card_art">Nowhere-kunst</string>
|
||||
<string name="nowhere_link_card_forum">Nowhere-forum</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1345,7 +1345,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="call_settings_turn_credential">Uwierzytelnienie</string>
|
||||
<string name="always_on_notif_channel_description">Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym</string>
|
||||
<string name="always_on_notif_title">Powiadomienia Ametyst Aktywne</string>
|
||||
<string name="always_on_notif_connected">Połączono z %1$d transmiterami odbiorczymi</string>
|
||||
<string name="always_on_notif_connecting">Łączenie z transmiterami odbiorczymi\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Usługa powiadomień zawsze włączona</string>
|
||||
<string name="always_on_notif_setting_description">Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości.</string>
|
||||
@@ -3113,6 +3112,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<item quantity="many">%1$s +%2$d więcej</item>
|
||||
<item quantity="other">%1$s +%2$d więcej</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Kwota zbiorki (w satoszach)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Opisz cel zbiórki</string>
|
||||
|
||||
@@ -1320,7 +1320,6 @@
|
||||
<string name="call_settings_turn_credential">Credencial</string>
|
||||
<string name="always_on_notif_channel_description">Mantém as conexões com seus relays de caixa de entrada ativas para notificações em tempo real</string>
|
||||
<string name="always_on_notif_title">Notificações do Amethyst ativas</string>
|
||||
<string name="always_on_notif_connected">Conectado a %1$d relays de caixa de entrada</string>
|
||||
<string name="always_on_notif_connecting">Conectando aos relays de caixa de entrada\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Serviço de notificações sempre ativo</string>
|
||||
<string name="always_on_notif_setting_description">Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem.</string>
|
||||
@@ -3041,6 +3040,7 @@
|
||||
<item quantity="one">%1$s +%2$d a mais</item>
|
||||
<item quantity="other">%1$s +%2$d a mais</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Valor da meta (sats)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Descreva sua meta</string>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -599,6 +599,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -457,6 +457,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="quick_action_select">Izberi</string>
|
||||
<string name="quick_action_share_browser_link">Deli povezavo brskalnika</string>
|
||||
<string name="quick_action_share">Deli</string>
|
||||
<string name="share_as_image">Deli sliko</string>
|
||||
<string name="share_as_image_url">Deli URL slike</string>
|
||||
<string name="share_as_image_generating">Ustvarjam predogled…</string>
|
||||
<string name="share_as_image_watermark">Deljeno z Amethyst</string>
|
||||
<string name="quick_action_copy_user_id">Avtorjev ID</string>
|
||||
<string name="quick_action_copy_note_id">ID zapiska</string>
|
||||
<string name="quick_action_copy_text">Kopiraj tekst</string>
|
||||
@@ -679,10 +683,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="workout_duration">Trajanje</string>
|
||||
<string name="workout_distance">Razdalja</string>
|
||||
<string name="workout_pace">Tempo</string>
|
||||
<string name="workout_speed">Hitrost</string>
|
||||
<string name="workout_elevation">Višinska razlika</string>
|
||||
<string name="workout_elevation_loss">Izguba višine</string>
|
||||
<string name="workout_calories">Kalorije</string>
|
||||
<string name="workout_steps">Koraki</string>
|
||||
<string name="workout_heart_rate">Srčni utrip</string>
|
||||
<string name="workout_max_heart_rate">Max srčni utrip</string>
|
||||
<string name="workout_sets">Serije</string>
|
||||
<string name="workout_reps">Ponovitve</string>
|
||||
<string name="workout_weight">Teža</string>
|
||||
@@ -690,6 +697,16 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="workout_hours">Ur</string>
|
||||
<string name="workout_minutes">Minut</string>
|
||||
<string name="workout_seconds">Sekund</string>
|
||||
<string name="workout_suggestion_connect_title">Deli svoje vadbe</string>
|
||||
<string name="workout_suggestion_connect_message">Dovoli aplikaciji Amethyst branje zaključenih vadb iz Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) , da ti predlaga objavo</string>
|
||||
<string name="workout_suggestion_connect_button">Poveži</string>
|
||||
<string name="workout_suggestion_detected_title">Zaznana nova vadba</string>
|
||||
<string name="workout_suggestion_share">Deli</string>
|
||||
<string name="workout_suggestion_dismiss">Prekliči</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="workout_suggestion_heart_rate">%1$s bpm</string>
|
||||
<string name="workout_suggestion_calories">%1$s kcal</string>
|
||||
<string name="workout_from_health_connect">Iz Health Connect</string>
|
||||
<string name="exercise_running">Tek</string>
|
||||
<string name="exercise_walking">Hoja</string>
|
||||
<string name="exercise_cycling">Kolesarjenje</string>
|
||||
@@ -1342,7 +1359,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="call_settings_turn_credential">Akreditivi</string>
|
||||
<string name="always_on_notif_channel_description">Ohranja aktivne povezave z vašimi releji za obvestila v realnem času</string>
|
||||
<string name="always_on_notif_title">Amethyst obvestila so aktivna</string>
|
||||
<string name="always_on_notif_connected">Povezan z %1$d vhodnimi releji</string>
|
||||
<string name="always_on_notif_connecting">Povezovanje vhodnih relejev\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Vedno aktivna obvestila</string>
|
||||
<string name="always_on_notif_setting_description">Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila.</string>
|
||||
@@ -1625,6 +1641,22 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="music_track_uploading">Nalagam…</string>
|
||||
<string name="music_track_uploading_banner">Nalaganje naslovnice in zvoka ter objavljanje. Postopek se bo nadaljeval, tudi če zapustiš ta zaslon.</string>
|
||||
<string name="music_track_upload_failed">Nalaganje ni uspelo: %1$s</string>
|
||||
<string name="edit_music_playlist">Uredi seznam predvajanja</string>
|
||||
<string name="music_playlist_edit_action">Uredi seznam predvajanja</string>
|
||||
<string name="music_playlist_title_label">Naslov</string>
|
||||
<string name="music_playlist_description_label">Opis (neobvezno)</string>
|
||||
<string name="music_playlist_notes_label">Zapiski (neobvezno)</string>
|
||||
<string name="music_playlist_cover_upload_cta">Naloži prikazno sliko</string>
|
||||
<string name="music_playlist_cover_upload_hint">Izberi kvadratno sliko za naslovnico tega seznama predvajanja.</string>
|
||||
<string name="music_playlist_private_hint">Skrij seznam predvajanja iz tvojega javnega profila.</string>
|
||||
<string name="music_playlist_collaborative_hint">Dovoli drugim, da dodajo skladbe v ta seznam predvajanja.</string>
|
||||
<string name="music_playlist_delete">Izbriši ta seznam predvajanja</string>
|
||||
<string name="music_playlist_delete_confirm">Objavi zahtevo za izbris NIP-09 za ta seznam predvajanja?</string>
|
||||
<string name="music_playlist_uploading_banner">Nalaganje in objava naslovnice. Ta postopek se bo nadaljeval, tudi če zapustite zaslon.</string>
|
||||
<string name="music_playlist_no_tracks_hint">Seznam je prazen. Dodaj skladbe iz menija posamezne pesmi z možnostjo \"Dodaj na seznam predvajanja\".</string>
|
||||
<string name="music_playlist_move_track_up">Premakni skladbo navzgor</string>
|
||||
<string name="music_playlist_move_track_down">Premakni skladbo na dol</string>
|
||||
<string name="music_playlist_remove_track">Odstrani skladbo iz seznama predvajanja</string>
|
||||
<string name="loading_location">Nalaganje lokacije</string>
|
||||
<string name="lack_location_permissions">Brez dovoljenj za lokacijo</string>
|
||||
<string name="add_sensitive_content_explainer">Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo</string>
|
||||
@@ -1944,6 +1976,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="cashu_mints">Kovnice</string>
|
||||
<string name="cashu_mint_url">URL kovnice</string>
|
||||
<string name="cashu_remove_mint">Odstrani kovnico</string>
|
||||
<string name="cashu_add_mint">Dodaj kovnico</string>
|
||||
<string name="cashu_history">Zgodovina</string>
|
||||
<string name="cashu_edit_wallet">Uredi denarnico</string>
|
||||
<string name="cashu_p2pk_section">Ključ Nutzap (napredno)</string>
|
||||
@@ -1989,6 +2022,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="cashu_settings_edit_wallet">Uredi podatke o denarnici</string>
|
||||
<string name="cashu_settings_edit_wallet_subtitle">Kovnice, Nutzap ključ</string>
|
||||
<string name="cashu_settings_my_recommendations">Priporočene kovnice</string>
|
||||
<string name="cashu_settings_recommendations_subtitle">Javno podpri kovnice, ki jim zaupaš, in prekliči priporočila.</string>
|
||||
<string name="cashu_settings_no_recommendations">Nimaš še priporočenih kovnic. Tapni palec navzgor ob kovnici in jo javno priporoči.</string>
|
||||
<string name="cashu_settings_delete_recommendation">Izbriši priporočeno</string>
|
||||
<string name="cashu_settings_delete_confirm_title">Umakni priporočilo?</string>
|
||||
@@ -1999,6 +2033,25 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="cashu_settings_restore_running">Preverjanje kovnic…</string>
|
||||
<string name="cashu_settings_restore_nothing_found">Ni najdenih dokazil za obnovo.</string>
|
||||
<string name="cashu_settings_restore_result">Uspešno obnovljenih %1$s satov skozi %2$s dokazil.</string>
|
||||
<string name="cashu_settings_stop_nutzaps">Ustavi prejemanje nutzapov</string>
|
||||
<string name="cashu_settings_stop_nutzaps_subtitle">Umakni svojo objavo vrste 10019, da ti drugi ne morejo več pošiljati nutzapov. Tvoja denarnica in stanje ostaneta nespremenjena.</string>
|
||||
<string name="cashu_settings_stop_nutzaps_confirm_title">Ustavim prejemanje nutzapov?</string>
|
||||
<string name="cashu_settings_stop_nutzaps_confirm_body">Nadomestili bomo vašo objavo o nutzapih s prazno in zahtevali njen izbris. Uporabniki vam ne bodo več mogli pošiljati nutzapov. To lahko kadar koli ponovno vklopite v urejanju denarnice. Stanje vaše denarnice ostaja nespremenjeno.</string>
|
||||
<string name="cashu_settings_recreate_key">Ponovno ustvari ključ za nutzape</string>
|
||||
<string name="cashu_settings_recreate_key_subtitle">Ustvari nov ključ za prejemanje nutzapov. To je redko potrebno – običajno le, če je bil trenutni ključ ogrožen. Nutzapi, ki so že bili poslani na stari ključ, a še niso bili unovčeni, bodo izgubljeni.</string>
|
||||
<string name="cashu_settings_recreate_key_confirm_title">Ponovno ustvarim ključ za nutzape?</string>
|
||||
<string name="cashu_settings_recreate_key_confirm_body">Ustvarjen in objavljen bo nov ključ P2PK v vrstah 10019 in 17375, pri čemer se obstoječe kovnice ohranijo. Pošiljatelji bodo začeli zaklepati nutzape na novi ključ. Vsi nutzapi, poslani na stari ključ, ki jih še niste unovčili, postanejo nepopravljivo izgubljeni. To je le redko potrebno.</string>
|
||||
<string name="cashu_settings_recreate_key_action">Ponovno ustvari ključ</string>
|
||||
<string name="cashu_settings_import_key">Uvozi ključ za nutzape</string>
|
||||
<string name="cashu_settings_import_key_subtitle">Zamenjaj ključ za nutzape s prilepljenim – na primer za obnovitev denarnice iz varnostne kopije. To je le redko potrebno. Nutzapi, zaklenjeni na stari ključ, ne bodo več unovčljivi.</string>
|
||||
<string name="cashu_settings_import_key_confirm_title">Uvozim ključ za nutzape?</string>
|
||||
<string name="cashu_settings_import_key_confirm_body">Prilepite zasebni ključ P2PK (hex) za prejemanje nutzapov. Objavili bomo novo verzijo vaših sporočil vrst 10019 in 17375 z uporabo tega ključa, pri čemer se obstoječe kovnice ohranijo. Nutzapi, zaklenjeni na vaš trenutni ključ, ne bodo več unovčljivi, razen če se ključa ujemata. To je le redko potrebno.</string>
|
||||
<string name="cashu_settings_import_key_field">P\"PK zasebni ključ (hex)</string>
|
||||
<string name="cashu_settings_import_key_action">Uvozi ključ</string>
|
||||
<string name="cashu_settings_delete_wallet">Izbriši denarnico</string>
|
||||
<string name="cashu_settings_delete_wallet_subtitle">Odstrani denarnico in ustavi nutzape. Morebitno preostalo stanje lahko postane nepopravljivo izgubljeno.</string>
|
||||
<string name="cashu_settings_delete_wallet_confirm_title">Izbrišem to denarnico?</string>
|
||||
<string name="cashu_settings_delete_wallet_confirm_body">To zahteva izbris vaše denarnice (vrsta 17375) in podatkov o nutzapih (vrsta 10019). Vaši dokazi o ecash dokazila (ecash proofs) se v kovnicah ne izbrišejo, vendar boste z izgubo ključa denarnice izgubili dostop do preostalega stanja in neunovčenih nutzapov. Pred nadaljevanjem obvezno porabite ali prenesite svoja sredstva. Tega dejanja ni mogoče razveljaviti.</string>
|
||||
<string name="cashu_pay_invoice">Plačaj račun</string>
|
||||
<string name="cashu_get_quote">Pridobi tečaj</string>
|
||||
<string name="cashu_getting_quote">Zahtevam tečaj od kovnice…</string>
|
||||
@@ -2017,6 +2070,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="cashu_done">Končano</string>
|
||||
<string name="cashu_requesting_invoice">ahtevam račun od kovnice…</string>
|
||||
<string name="cashu_waiting_for_payment">Čakanje na plačilo računa…</string>
|
||||
<string name="cashu_checking_mint">Preverjam kovnico…</string>
|
||||
<string name="cashu_completing_mint">Izdajanje dokazil…</string>
|
||||
<string name="cashu_paying_invoice">Plačujem prek kovnice…</string>
|
||||
<string name="cashu_building_token">Zamenjava dokazil…</string>
|
||||
@@ -2994,6 +3048,25 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
</plurals>
|
||||
<string name="cashu_pending_quotes_subtitle">Tapni za nadaljevanje in preverjanje stanja.</string>
|
||||
<string name="cashu_pending_quotes_resume">Nadaljuj</string>
|
||||
<plurals name="cashu_untrusted_mint_title">
|
||||
<item quantity="one">Sredstva v %1$d kovnici, ki je ne uporabljate</item>
|
||||
<item quantity="two">Sredstva v %1$d kovnicah, ki ju ne uporabljate</item>
|
||||
<item quantity="few">Sredstva v %1$d kovnicah, ki jih ne uporabljate</item>
|
||||
<item quantity="other">Sredstva v %1$d kovnicah, ki jih ne uporabljate</item>
|
||||
</plurals>
|
||||
<string name="cashu_untrusted_mint_subtitle">Ti kovanci so prispeli prek nutzapa. Prenesite jih v kovnico, ki ji zaupate, ali jih umaknite na omrežje Lightning, da ostanejo na varnem.</string>
|
||||
<string name="cashu_untrusted_mint_badge">Ni v vaši denarnici</string>
|
||||
<string name="cashu_untrusted_mint_move">Premakni</string>
|
||||
<string name="cashu_move_coins_title">Premakni kovance ven</string>
|
||||
<string name="cashu_move_coins_body">%2$s satov v kovnici, ki je ne uporabljate (%1$s). Prenesite jih nekam, kjer so varni.</string>
|
||||
<string name="cashu_move_coins_to_mint">Premakni v kovnico, ki ji zaupate</string>
|
||||
<string name="cashu_move_coins_move">Premakni</string>
|
||||
<string name="cashu_move_coins_fee_hint">Ta kovnica zaračuna manjši provizijo za Lightning, zato boste morda morali poslati nekoliko manj od celotnega stanja.</string>
|
||||
<string name="cashu_move_coins_no_trusted">Najprej v denarnico dodajte kovnico za prenos kovancev med njimi, ali uporabite spodnje možnosti.</string>
|
||||
<string name="cashu_move_coins_withdraw_ln">Izplačaj prek Lightninga…</string>
|
||||
<string name="cashu_move_coins_export_token">Izvozi kot žeton Cashu…</string>
|
||||
<string name="cashu_move_coins_working">Premikam kovance…</string>
|
||||
<string name="cashu_move_coins_done">%1$s satov premaknjenih v %2$s</string>
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="event_sync_status_connecting">Povezujem</string>
|
||||
<string name="event_sync_status_downloading">Prenašam</string>
|
||||
@@ -3113,6 +3186,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<item quantity="few">%1$s in še %2$d druge</item>
|
||||
<item quantity="other">%1$s in še %2$d drugih</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Ciljni znesek (sat)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Opiši svoj cilj</string>
|
||||
@@ -3223,6 +3297,8 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="ai_writing_setting_description">Uporablja model umetne inteligence v napravi za predloge popravkov besedila in spremembe tona.</string>
|
||||
<string name="tracked_broadcasts_setting_title">Sledeni prenosi</string>
|
||||
<string name="tracked_broadcasts_setting_description">Pri pošiljanju dogodkov uporabi sledilnik oddajanja. V živo prikazuje napredek in stanje posameznih relejev med oddajanjem.</string>
|
||||
<string name="suggest_workouts_setting_title">Predlagaj vadbo za deljenje</string>
|
||||
<string name="suggest_workouts_setting_description">Prebere opravljene vadbe iz aplikacije Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) in predlaga objavo. Dovoljenje je zahtevano le ob povezavi.</string>
|
||||
<string name="compose_settings">Nastavitve urejevalnika</string>
|
||||
<string name="auto_create_drafts_setting_title">Samodejno ustvari osnutke</string>
|
||||
<string name="auto_create_drafts_setting_description">Samodejno shrani osnutek, ko tipkaš ali zapustiš urejevalnik z neposlanim besedilom, in ga pošlje v tvoje zasebne odhodne releje.</string>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1320,7 +1320,6 @@
|
||||
<string name="call_settings_turn_credential">Inloggningsuppgift</string>
|
||||
<string name="always_on_notif_channel_description">Håller anslutningarna till dina inbox-relän aktiva för realtidsnotifieringar</string>
|
||||
<string name="always_on_notif_title">Amethyst-notifieringar aktiva</string>
|
||||
<string name="always_on_notif_connected">Ansluten till %1$d inbox-relän</string>
|
||||
<string name="always_on_notif_connecting">Ansluter till inbox-relän\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Alltid på-notifieringstjänst</string>
|
||||
<string name="always_on_notif_setting_description">Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande.</string>
|
||||
@@ -3041,6 +3040,7 @@
|
||||
<item quantity="one">%1$s +%2$d till</item>
|
||||
<item quantity="other">%1$s +%2$d till</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">Målbelopp (sats)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Beskriv ditt mål</string>
|
||||
|
||||
@@ -497,6 +497,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -383,6 +383,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -828,6 +828,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -513,6 +513,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -1318,7 +1318,9 @@
|
||||
<string name="call_settings_turn_credential">凭证</string>
|
||||
<string name="always_on_notif_channel_description">保持与收件箱中继的连接以便接收实时通知</string>
|
||||
<string name="always_on_notif_title">Amethyst 通知活跃</string>
|
||||
<string name="always_on_notif_connected">已连接到 %1$d 个收件箱中继</string>
|
||||
<plurals name="always_on_notif_connected">
|
||||
<item quantity="other">已连接到 %1$d 个收件箱中继</item>
|
||||
</plurals>
|
||||
<string name="always_on_notif_connecting">正在连接到收件箱中继\u2026</string>
|
||||
<string name="always_on_notif_setting_title">“始终显示通知”服务</string>
|
||||
<string name="always_on_notif_setting_description">保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。</string>
|
||||
@@ -3071,6 +3073,7 @@
|
||||
<plurals name="birdex_species_preview_more">
|
||||
<item quantity="other">%1$s + 另%2$d</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="goal_amount_label">目标金额 (sats)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">描述您的目标</string>
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -776,6 +776,7 @@
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<!-- NIP-86 Relay Management -->
|
||||
<!-- Emoji packs -->
|
||||
<!-- NIP-9B community rules composer-side validation. -->
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
<item quantity="one">%1$d asset bundled</item>
|
||||
<item quantity="other">%1$d assets bundled</item>
|
||||
</plurals>
|
||||
<plurals name="thread_collapsed_reply_count">
|
||||
<item quantity="one">+%1$d\nreply</item>
|
||||
<item quantity="other">+%1$d\nreplies</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Channel Image</string>
|
||||
@@ -1438,7 +1442,14 @@
|
||||
<string name="always_on_notif_channel_name" translatable="false">Relay Connection Service</string>
|
||||
<string name="always_on_notif_channel_description">Keeps connections to your inbox relays active for real-time notifications</string>
|
||||
<string name="always_on_notif_title">Amethyst Notifications Active</string>
|
||||
<string name="always_on_notif_connected">Connected to %1$d inbox relays</string>
|
||||
<plurals name="always_on_notif_connected">
|
||||
<item quantity="one">Connected to %1$d inbox relay</item>
|
||||
<item quantity="other">Connected to %1$d inbox relays</item>
|
||||
</plurals>
|
||||
<plurals name="always_on_notif_connected_foreground">
|
||||
<item quantity="one">Connected to %1$d relay</item>
|
||||
<item quantity="other">Connected to %1$d relays</item>
|
||||
</plurals>
|
||||
<string name="always_on_notif_connecting">Connecting to inbox relays\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Always-on notification service</string>
|
||||
<string name="always_on_notif_setting_description">Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message.</string>
|
||||
@@ -3434,6 +3445,25 @@
|
||||
<item quantity="one">%1$s +%2$d more</item>
|
||||
<item quantity="other">%1$s +%2$d more</item>
|
||||
</plurals>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="road_event_police">Police</string>
|
||||
<string name="road_event_speed_camera">Speed camera</string>
|
||||
<string name="road_event_traffic_jam">Traffic jam</string>
|
||||
<string name="road_event_accident">Accident</string>
|
||||
<string name="road_event_road_closure">Road closure</string>
|
||||
<string name="road_event_construction">Construction</string>
|
||||
<string name="road_event_hazard">Hazard</string>
|
||||
<string name="road_event_road_condition">Road condition</string>
|
||||
<string name="road_event_pothole">Pothole</string>
|
||||
<string name="road_event_fog">Fog</string>
|
||||
<string name="road_event_ice">Ice</string>
|
||||
<string name="road_event_animal">Animal on the road</string>
|
||||
<string name="road_event_other">Road event</string>
|
||||
<string name="road_event_unknown">Road event</string>
|
||||
<string name="road_event_confirmed">Still there</string>
|
||||
<string name="road_event_denied">No longer there</string>
|
||||
<string name="road_event_confirms_report">Confirms a road report</string>
|
||||
<string name="road_event_denies_report">Reports a road event cleared</string>
|
||||
<string name="goal_amount_label">Goal amount (sats)</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_description_label">Describe your goal</string>
|
||||
|
||||
@@ -60,8 +60,7 @@
|
||||
<locale android:name="sr-SP"/>
|
||||
<locale android:name="ss-ZA"/>
|
||||
<locale android:name="sv-SE"/>
|
||||
<locale android:name="sw-KE"/>
|
||||
<locale android:name="sw-TZ"/>
|
||||
<locale android:name="sw"/>
|
||||
<locale android:name="ta"/>
|
||||
<locale android:name="ta-IN"/>
|
||||
<locale android:name="th"/>
|
||||
|
||||
+22
-6
@@ -79,16 +79,17 @@ class DisappearingBarNestedScrollTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrolling content down reveals both bars from a hidden state`() {
|
||||
fun `scrolling content down reveals both bars from a hidden state, damped`() {
|
||||
val state = state(topLimit = 100f, bottomLimit = 50f)
|
||||
state.topHeightOffset = -100f
|
||||
state.bottomHeightOffset = -50f
|
||||
val connection = nsc(state)
|
||||
|
||||
// Reveal is damped by REVEAL_SENSITIVITY (0.5), so a 30px drag only reveals 15px.
|
||||
connection.onPostScroll(Offset(0f, 30f), Offset(0f, 0f), NestedScrollSource.UserInput)
|
||||
|
||||
assertEquals(-70f, state.topHeightOffset)
|
||||
assertEquals(-20f, state.bottomHeightOffset)
|
||||
assertEquals(-85f, state.topHeightOffset)
|
||||
assertEquals(-35f, state.bottomHeightOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -99,11 +100,26 @@ class DisappearingBarNestedScrollTest {
|
||||
val connection = nsc(state)
|
||||
|
||||
// The list consumed 20px of a 40px reveal drag; 20 more was left as overscroll.
|
||||
// The bars should move by the total 40, not just one of the halves.
|
||||
// The bars should move by the total 40 (damped to 20 on reveal), not just one half.
|
||||
connection.onPostScroll(Offset(0f, 20f), Offset(0f, 20f), NestedScrollSource.UserInput)
|
||||
|
||||
assertEquals(-10f, state.topHeightOffset)
|
||||
assertEquals(-10f, state.bottomHeightOffset)
|
||||
assertEquals(-30f, state.topHeightOffset)
|
||||
assertEquals(-30f, state.bottomHeightOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `revealing is less sensitive than hiding for the same drag distance`() {
|
||||
// Hiding a 40px drag moves the bars the full 40px...
|
||||
val hiding = state(topLimit = 100f, bottomLimit = 100f)
|
||||
nsc(hiding).onPostScroll(Offset(0f, -40f), Offset(0f, 0f), NestedScrollSource.UserInput)
|
||||
assertEquals(-40f, hiding.topHeightOffset)
|
||||
|
||||
// ...while revealing the same 40px from fully hidden only brings back 20px.
|
||||
val revealing = state(topLimit = 100f, bottomLimit = 100f)
|
||||
revealing.topHeightOffset = -100f
|
||||
revealing.bottomHeightOffset = -100f
|
||||
nsc(revealing).onPostScroll(Offset(0f, 40f), Offset(0f, 0f), NestedScrollSource.UserInput)
|
||||
assertEquals(-80f, revealing.topHeightOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.tor
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ArtiGuardStateTest {
|
||||
private fun load(resource: String): String =
|
||||
javaClass.getResourceAsStream(resource)?.bufferedReader()?.use { it.readText() }
|
||||
?: error("Missing test resource: $resource")
|
||||
|
||||
/**
|
||||
* Real `guards.json` captured from an emulator stuck in "Connecting": two of the four
|
||||
* confirmed primary guards were poisoned (`TooManyIndeterminateFailures`), but usable
|
||||
* guards remained. This is the case that exposed the bug — full AllGuardsDown never trips,
|
||||
* so only the [ArtiGuardState.hasConfirmedGuard] seed recovers it.
|
||||
*/
|
||||
@Test
|
||||
fun `real device sample - confirmed guard present, not fully wedged`() {
|
||||
val root = ArtiGuardState.parse(load("/tor/guards-confirmed-with-poisoned.json"))
|
||||
|
||||
assertTrue(
|
||||
"device sample has confirmed guards → prior bootstrap proven",
|
||||
ArtiGuardState.hasConfirmedGuard(root),
|
||||
)
|
||||
assertFalse(
|
||||
"2 disabled of 22 guards is not a total wipeout → not wedged by the AllGuardsDown check",
|
||||
ArtiGuardState.hasNoUsableGuards(root),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed guard counts even when disabled or unlisted`() {
|
||||
val root =
|
||||
ArtiGuardState.parse(
|
||||
"""
|
||||
{ "default": { "guards": [
|
||||
{ "confirmed_at": "2026-06-17T01:36:40Z",
|
||||
"disabled": { "type": "TooManyIndeterminateFailures" } },
|
||||
{ "confirmed_at": null, "disabled": null, "unlisted_since": null }
|
||||
] } }
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertTrue(ArtiGuardState.hasConfirmedGuard(root))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fresh sample with no confirmed guard - first bootstrap, nothing to wipe`() {
|
||||
val root =
|
||||
ArtiGuardState.parse(
|
||||
"""
|
||||
{ "default": { "guards": [
|
||||
{ "confirmed_at": null, "disabled": null, "unlisted_since": null },
|
||||
{ "confirmed_at": null, "disabled": null, "unlisted_since": null }
|
||||
] } }
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertFalse(ArtiGuardState.hasConfirmedGuard(root))
|
||||
assertFalse(ArtiGuardState.hasNoUsableGuards(root))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all guards disabled or unlisted - AllGuardsDown wedge`() {
|
||||
val root =
|
||||
ArtiGuardState.parse(
|
||||
"""
|
||||
{ "default": { "guards": [
|
||||
{ "confirmed_at": "2026-06-10T15:52:00Z",
|
||||
"disabled": { "type": "TooManyIndeterminateFailures" } },
|
||||
{ "confirmed_at": "2026-06-11T15:52:00Z",
|
||||
"disabled": null, "unlisted_since": "2026-06-12T00:00:00Z" }
|
||||
] } }
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertTrue("every guard unusable → wedged", ArtiGuardState.hasNoUsableGuards(root))
|
||||
// Even when fully wedged, prior confirmation is still proven.
|
||||
assertTrue(ArtiGuardState.hasConfirmedGuard(root))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty selection is neither wedged nor confirmed`() {
|
||||
val root = ArtiGuardState.parse("""{ "default": { "guards": [] }, "restricted": { "guards": [] } }""")
|
||||
|
||||
assertFalse(ArtiGuardState.hasNoUsableGuards(root))
|
||||
assertFalse(ArtiGuardState.hasConfirmedGuard(root))
|
||||
}
|
||||
}
|
||||
@@ -171,18 +171,22 @@ class TorManagerTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `onTorCircuitsDead wipes state and re-inits when Active`() =
|
||||
fun `onTorCircuitsDead rotates exits with a warm reset when Active`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
|
||||
advanceUntilIdle()
|
||||
backend.setActive(17392)
|
||||
advanceUntilIdle()
|
||||
val resetCountBefore = backend.resetCount
|
||||
|
||||
manager.onTorCircuitsDead()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("Active-but-failing recovery wipes state", 1, backend.resetWithCleanStateCount)
|
||||
// Exit failures are exit-side: warm reset (keep guards + cache) to rotate exits,
|
||||
// never a state wipe (which only forces a 60s cold bootstrap).
|
||||
assertEquals("Active-but-failing recovery rotates exits warm", resetCountBefore + 1, backend.resetCount)
|
||||
assertEquals("must not wipe state for an exit-side failure", 0, backend.resetWithCleanStateCount)
|
||||
// resetEpoch bump re-enters INTERNAL → start() runs again.
|
||||
assertTrue("re-init should call start() again", backend.startCount >= 2)
|
||||
}
|
||||
@@ -195,10 +199,12 @@ class TorManagerTest {
|
||||
advanceUntilIdle()
|
||||
// Still Connecting (never reached Active).
|
||||
assertEquals(TorServiceStatus.Connecting, manager.status.value)
|
||||
val resetCountBefore = backend.resetCount
|
||||
|
||||
manager.onTorCircuitsDead()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("no rotation while not Active", resetCountBefore, backend.resetCount)
|
||||
assertEquals(0, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
@@ -212,10 +218,12 @@ class TorManagerTest {
|
||||
advanceUntilIdle()
|
||||
manager.sessionBypass.value = true
|
||||
advanceUntilIdle()
|
||||
val resetCountBefore = backend.resetCount
|
||||
|
||||
manager.onTorCircuitsDead()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("no rotation while bypassing", resetCountBefore, backend.resetCount)
|
||||
assertEquals(0, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
@@ -228,10 +236,11 @@ class TorManagerTest {
|
||||
advanceUntilIdle()
|
||||
backend.setActive(17392)
|
||||
advanceUntilIdle()
|
||||
val resetCountBefore = backend.resetCount
|
||||
|
||||
manager.onTorCircuitsDead()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, backend.resetWithCleanStateCount)
|
||||
assertEquals(resetCountBefore + 1, backend.resetCount)
|
||||
|
||||
// Backend is Active again (re-init bootstrapped). A second call inside the cooldown
|
||||
// window must be suppressed.
|
||||
@@ -239,7 +248,7 @@ class TorManagerTest {
|
||||
advanceUntilIdle()
|
||||
manager.onTorCircuitsDead()
|
||||
advanceUntilIdle()
|
||||
assertEquals("cooldown should suppress the second self-heal", 1, backend.resetWithCleanStateCount)
|
||||
assertEquals("cooldown should suppress the second self-heal", resetCountBefore + 1, backend.resetCount)
|
||||
|
||||
// Past the cooldown it can fire again.
|
||||
clockNow += TorManager.SELF_HEAL_COOLDOWN_MS + 1_000L
|
||||
@@ -247,7 +256,8 @@ class TorManagerTest {
|
||||
advanceUntilIdle()
|
||||
manager.onTorCircuitsDead()
|
||||
advanceUntilIdle()
|
||||
assertEquals("after cooldown elapses, self-heal fires again", 2, backend.resetWithCleanStateCount)
|
||||
assertEquals("after cooldown elapses, self-heal fires again", resetCountBefore + 2, backend.resetCount)
|
||||
assertEquals("exit-side rotations never wipe state", 0, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -272,6 +282,23 @@ class TorManagerTest {
|
||||
assertEquals(0, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watchdog wipes state on first stuck-Connecting when guards prove prior bootstrap`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend().apply { bootstrappedBefore = true }
|
||||
// Never reaches Active in this session, but on-disk state proves a prior bootstrap.
|
||||
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(TorServiceStatus.Connecting, manager.status.value)
|
||||
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals("seeded from disk: must wipe stale/poisoned state, not gentle-reset", 0, backend.resetCount)
|
||||
assertEquals(1, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watchdog uses full reset after first Active`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
@@ -476,6 +503,11 @@ private class FakeTorBackend : TorBackend {
|
||||
var resetWithCleanStateCount = 0
|
||||
private set
|
||||
|
||||
/** Simulates a persisted confirmed guard on disk (prior successful bootstrap). */
|
||||
var bootstrappedBefore = false
|
||||
|
||||
override suspend fun hasBootstrappedBefore(): Boolean = bootstrappedBefore
|
||||
|
||||
override suspend fun start() {
|
||||
startCount++
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"default": {
|
||||
"guards": [
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "rquRARGG0HF/y3ugtCi8HmhvZ6jIa1lSXQlFDUBrHBA",
|
||||
"rsa": "b8f0736b96819c88a1aed46d5965fa18610694b5"
|
||||
},
|
||||
"orports": [
|
||||
"142.132.205.43:9993",
|
||||
"[2a01:4f8:261:5145::2]:9993"
|
||||
],
|
||||
"added_at": "2026-06-14T13:48:30Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "TooManyIndeterminateFailures",
|
||||
"history": {
|
||||
"n_successes": 2,
|
||||
"n_failures": 0,
|
||||
"n_indeterminate": 14
|
||||
},
|
||||
"failure_ratio": 0.875,
|
||||
"threshold_ratio": 0.7
|
||||
},
|
||||
"confirmed_at": "2026-06-18T08:49:30Z",
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "1tmwk24X7PS9baMIflSwPnRxczUjs0AumO1YDHWzKpk",
|
||||
"rsa": "dfb5ce34fe6f5d9c56377f38e86b3e55fffa1830"
|
||||
},
|
||||
"orports": [
|
||||
"109.70.100.245:9010",
|
||||
"[2a03:e600:100:c3::11]:9010"
|
||||
],
|
||||
"added_at": "2026-06-16T08:21:30Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "TooManyIndeterminateFailures",
|
||||
"history": {
|
||||
"n_successes": 2,
|
||||
"n_failures": 0,
|
||||
"n_indeterminate": 15
|
||||
},
|
||||
"failure_ratio": 0.8823529411764706,
|
||||
"threshold_ratio": 0.7
|
||||
},
|
||||
"confirmed_at": "2026-06-17T01:36:40Z",
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "gvuYmLGk50ed9efpKqMiEZWKM8SqkJW4bB2nwmIPKtM",
|
||||
"rsa": "c2f3631b1386fd7ba5227b28783315cd52eb1526"
|
||||
},
|
||||
"orports": [
|
||||
"152.53.18.121:9001",
|
||||
"[2a0a:4cc0:1:104e::1]:9001"
|
||||
],
|
||||
"added_at": "2026-06-14T20:06:10Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": "2026-06-16T22:54:00Z",
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "GX8TczQuVV+Wbk9Zeik5YA3LOPolZXO4wL3FYdGVcJ4",
|
||||
"rsa": "76d2eba82bbcca3df9f254a9a8372a10a26f3d14"
|
||||
},
|
||||
"orports": [
|
||||
"134.130.172.229:9001"
|
||||
],
|
||||
"added_at": "2026-06-10T15:52:00Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": "2026-06-10T15:52:00Z",
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "SHvdJYQ/zk1n24LrSDLjT0n1x/h/wn7UexhUnysm24Q",
|
||||
"rsa": "dc5f31743bb3074bdf6153a066e9bed01e03311d"
|
||||
},
|
||||
"orports": [
|
||||
"193.200.229.243:10443",
|
||||
"[2a03:94e0:ffff:193:200:229:0:243]:10443"
|
||||
],
|
||||
"added_at": "2026-06-09T03:23:00Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "zdp9Ni6/yN9xjXGwBkP8RgayDUl+gMzTkK1CvrqRGnQ",
|
||||
"rsa": "239ff93a066c1e70d95dbdb0a6819941caf73021"
|
||||
},
|
||||
"orports": [
|
||||
"51.83.41.117:9100",
|
||||
"[2001:41d0:305:2100::8448]:9100"
|
||||
],
|
||||
"added_at": "2026-06-16T19:32:40Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "//13KRovDTJtpueEaHyqB8zliL2sl1vNAriIvcUBWjs",
|
||||
"rsa": "99ec64fe9ef0e0ecec3e125a2327c2779c1e7947"
|
||||
},
|
||||
"orports": [
|
||||
"46.4.66.188:8000",
|
||||
"[2a01:4f8:140:244f::2]:8000"
|
||||
],
|
||||
"added_at": "2026-06-08T21:30:40Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "qA7+Jbr2gbkPMoDj13H5Bf02mmP+IdGdnTJfL2+DgAM",
|
||||
"rsa": "861bcfdd148973985e7fe97c7455c9e4ac4e13be"
|
||||
},
|
||||
"orports": [
|
||||
"157.90.212.53:443",
|
||||
"[2a01:4f8:252:194b::2]:443"
|
||||
],
|
||||
"added_at": "2026-06-16T22:43:00Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "y9oTlscEUikG1WYBbR7P1bEiV7h+Ne8RZvZoX/cPItM",
|
||||
"rsa": "e3040061dd578d614d83ac0ae3526abdfe9fc323"
|
||||
},
|
||||
"orports": [
|
||||
"87.106.54.7:9001"
|
||||
],
|
||||
"added_at": "2026-06-12T02:23:30Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "MnaJaeA1kVXRa4jYkVOI6ENFjFlZfRH5GP9kXlds+Wk",
|
||||
"rsa": "ef86ad86576f1a10f740ee26e8cb126e81dda5dd"
|
||||
},
|
||||
"orports": [
|
||||
"178.215.228.25:444",
|
||||
"[2a0d:5440::25]:9052"
|
||||
],
|
||||
"added_at": "2026-06-11T12:10:00Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "febl0xAVXMcOxOuEY3xbH1bDbgkxdNyCSAlgbuhqAms",
|
||||
"rsa": "f7b94b1a67b563459c6a7c6ad7d5b8031e127b26"
|
||||
},
|
||||
"orports": [
|
||||
"192.42.116.143:443",
|
||||
"[2001:67c:e60:c0c:192:42:116:143]:443"
|
||||
],
|
||||
"added_at": "2026-06-15T01:38:30Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "AV7CHhiBquq6Y21MEabK7hnAD4ZeNSmH8JDS1ppUXCM",
|
||||
"rsa": "04f4c22d98e5ba86a2ccf0bd6df42431c071f2aa"
|
||||
},
|
||||
"orports": [
|
||||
"87.236.199.223:9007",
|
||||
"[2a01:5f0:c001:108:29::8]:443"
|
||||
],
|
||||
"added_at": "2026-06-14T12:44:30Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "N/B+0QX4ggFsvw9+/bfnGCeeBm5ScqJQlkFaWYGEN50",
|
||||
"rsa": "c42c51efc275c45ef94cf810e02ba737fd99f7fa"
|
||||
},
|
||||
"orports": [
|
||||
"194.5.250.250:443"
|
||||
],
|
||||
"added_at": "2026-06-08T05:57:30Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "PtrkxtnBpL3NK7RQEw6TTdhy4YWT9EMpELF2DUh+vl8",
|
||||
"rsa": "0ea859733751f8e8cecd70e33c9994bb7a04ac10"
|
||||
},
|
||||
"orports": [
|
||||
"51.178.136.58:9000"
|
||||
],
|
||||
"added_at": "2026-06-07T08:24:50Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "F60AmQxOFjWujKzP9zPpezfV35XuqMNog9BvxjaY3mc",
|
||||
"rsa": "92fda7647b431e365b8a649851c3542e7a0ca280"
|
||||
},
|
||||
"orports": [
|
||||
"194.13.81.26:9001",
|
||||
"[2a03:4000:43:216:5443:2bff:fe16:c6b5]:9001"
|
||||
],
|
||||
"added_at": "2026-06-13T05:48:00Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "RiuLP1z65m0ghVEAqOEFD+a/96ta5b5F7Oz7mZLolBM",
|
||||
"rsa": "e08166ee25a85fd5c9433d133b39fc5ebde16d98"
|
||||
},
|
||||
"orports": [
|
||||
"217.12.206.128:9001"
|
||||
],
|
||||
"added_at": "2026-06-13T03:45:10Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "5fWkcDYvNYr0Gxwe2Ulu38f48m2FyDTBT6oziaTq8v8",
|
||||
"rsa": "724f4622ea76583af64614979db847dd39eea61b"
|
||||
},
|
||||
"orports": [
|
||||
"109.70.100.245:9030",
|
||||
"[2a03:e600:100:c3::11]:9030"
|
||||
],
|
||||
"added_at": "2026-06-08T00:07:00Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "xKIytpB9w1F0CNtCTkHT2sWBQ7nut7Ondyvim+NyJcg",
|
||||
"rsa": "a2a3b91e706d5fc1b90b0bf4eb6f8c5ec18ea181"
|
||||
},
|
||||
"orports": [
|
||||
"57.129.110.54:9100",
|
||||
"[2001:41d0:701:1100::9a4e]:9100"
|
||||
],
|
||||
"added_at": "2026-06-09T14:24:30Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "K8V12xahZ1pl0Sj2HN38VjL/TTq3DU+dcfn+qnm3KYs",
|
||||
"rsa": "91892720f9262cb37b91a7176d3d2280f7af14be"
|
||||
},
|
||||
"orports": [
|
||||
"46.4.66.178:9001",
|
||||
"[2a01:4f8:140:2459::2]:9001"
|
||||
],
|
||||
"added_at": "2026-06-11T12:53:40Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "MX9mVbBKjIQpNk9wDY5bsaYKcje0IeX73LxKVgapfp4",
|
||||
"rsa": "02bc27735d76b4b43fc789757900bbb38de0402c"
|
||||
},
|
||||
"orports": [
|
||||
"159.195.54.13:9001",
|
||||
"[2a0a:4cc0:c1:d5e3:54c9:6eff:fe0a:e8f7]:9001"
|
||||
],
|
||||
"added_at": "2026-06-10T06:03:10Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "wjv/3RiHzkbcjBQHBTFRxdds7YmsKReL2V9kMjSvuFc",
|
||||
"rsa": "a3af96c0600301138631d0a35a567cc748ce749d"
|
||||
},
|
||||
"orports": [
|
||||
"193.182.111.41:443",
|
||||
"[2a03:8600::a1]:443"
|
||||
],
|
||||
"added_at": "2026-06-10T07:03:00Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"ed25519": "HA/2D2GGWjO+pWKHccrTu10TLIaZW9h0JlGnCIU7GZE",
|
||||
"rsa": "beb887d44cb21ade582f2cb519c869ffdf520fdc"
|
||||
},
|
||||
"orports": [
|
||||
"57.128.170.236:9000",
|
||||
"[2001:41d0:801:2000::3129]:9000"
|
||||
],
|
||||
"added_at": "2026-06-15T05:16:50Z",
|
||||
"added_by": {
|
||||
"crate": "tor-guardmgr",
|
||||
"version": "0.42.0"
|
||||
},
|
||||
"disabled": null,
|
||||
"confirmed_at": null,
|
||||
"unlisted_since": null
|
||||
}
|
||||
],
|
||||
"confirmed": [
|
||||
{
|
||||
"ed25519": "1tmwk24X7PS9baMIflSwPnRxczUjs0AumO1YDHWzKpk",
|
||||
"rsa": "dfb5ce34fe6f5d9c56377f38e86b3e55fffa1830"
|
||||
},
|
||||
{
|
||||
"ed25519": "rquRARGG0HF/y3ugtCi8HmhvZ6jIa1lSXQlFDUBrHBA",
|
||||
"rsa": "b8f0736b96819c88a1aed46d5965fa18610694b5"
|
||||
},
|
||||
{
|
||||
"ed25519": "gvuYmLGk50ed9efpKqMiEZWKM8SqkJW4bB2nwmIPKtM",
|
||||
"rsa": "c2f3631b1386fd7ba5227b28783315cd52eb1526"
|
||||
},
|
||||
{
|
||||
"ed25519": "GX8TczQuVV+Wbk9Zeik5YA3LOPolZXO4wL3FYdGVcJ4",
|
||||
"rsa": "76d2eba82bbcca3df9f254a9a8372a10a26f3d14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"restricted": {
|
||||
"guards": [],
|
||||
"confirmed": []
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,28 @@ dependencies {
|
||||
implementation(libs.slf4j.nop)
|
||||
}
|
||||
|
||||
// amy is headless. It compiles against zero Compose UI (the Compose deps are
|
||||
// `implementation` in :commons, so they never reach the CLI compile classpath —
|
||||
// verified), yet they still ride the *runtime* classpath into the shipped image:
|
||||
// ~29 MB of Compose desktop render stack, including skiko's native .dylibs that
|
||||
// needlessly enlarge the macOS notarization surface. None of it is reachable
|
||||
// from a CLI command, so drop the whole UI layer from the runtime image. Keep
|
||||
// `androidx.compose.runtime` — snapshot state + the @Stable/@Immutable
|
||||
// annotations ARE CLI-safe and used by commons models/state holders (see
|
||||
// commons/ARCHITECTURE.md §1). A regression here is caught by the "no Compose UI
|
||||
// in the amy image" assertion in .github/workflows/create-release.yml.
|
||||
configurations.named("runtimeClasspath") {
|
||||
// skiko (native renderer + its .dylibs) and the Compose UI bytecode layer.
|
||||
// NB: Compose Multiplatform publishes UI under org.jetbrains.compose.* — only
|
||||
// `runtime` relocates to androidx.compose.runtime, which we deliberately keep.
|
||||
exclude(group = "org.jetbrains.skiko")
|
||||
exclude(group = "org.jetbrains.compose.ui")
|
||||
exclude(group = "org.jetbrains.compose.foundation")
|
||||
exclude(group = "org.jetbrains.compose.material")
|
||||
exclude(group = "org.jetbrains.compose.material3")
|
||||
exclude(group = "org.jetbrains.compose.animation")
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
|
||||
applicationName = "amy"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Reference Homebrew formula for `amy`, the Amethyst CLI.
|
||||
#
|
||||
# This file is NOT consumed by any build in this repo. It is the artifact you
|
||||
# submit to Homebrew/homebrew-core (`brew bump-formula-pr` / a new-formula PR).
|
||||
# Once accepted, homebrew-core's copy is the source of truth; keep this in sync
|
||||
# for reference and to make version bumps a copy-paste.
|
||||
#
|
||||
# Why a pre-built jar bundle instead of building from source:
|
||||
# homebrew-core builds inside a network sandbox, so a Gradle build cannot
|
||||
# resolve its Maven dependencies there. The accepted pattern for JVM tools is
|
||||
# to download a pre-built, no-JRE jar bundle and depend on the system openjdk.
|
||||
# We publish exactly that as `amy-<version>-jvm.tar.gz` (bin/amy + lib/*.jar,
|
||||
# no bundled runtime) from .github/workflows/create-release.yml.
|
||||
#
|
||||
# Before submitting: replace the version in the url and the sha256 with the
|
||||
# values for the actual published release asset:
|
||||
# curl -fsSL -o amy-jvm.tar.gz \
|
||||
# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amy-X.Y.Z-jvm.tar.gz
|
||||
# shasum -a 256 amy-jvm.tar.gz
|
||||
class Amy < Formula
|
||||
desc "Command-line Nostr client from the Amethyst project"
|
||||
homepage "https://github.com/vitorpamplona/amethyst"
|
||||
url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz"
|
||||
sha256 "REPLACE_WITH_RELEASE_ASSET_SHA256"
|
||||
license "MIT"
|
||||
|
||||
# Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new
|
||||
# stable GitHub release appears.
|
||||
livecheck do
|
||||
url :stable
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
depends_on "openjdk"
|
||||
|
||||
def install
|
||||
# Tarball top level is bin/ and lib/ (the Gradle installDist layout).
|
||||
libexec.install Dir["*"]
|
||||
# Wrapper on PATH that pins JAVA_HOME to Homebrew's openjdk so amy runs
|
||||
# regardless of the user's own Java setup.
|
||||
(bin/"amy").write_env_script libexec/"bin/amy", JAVA_HOME: Formula["openjdk"].opt_prefix
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "Amethyst command-line interface", shell_output("#{bin}/amy --help 2>&1")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
Hardened-runtime entitlements for amy's bundled JVM (runtime/bin/java).
|
||||
Required when codesigning + notarizing the macOS jlink image:
|
||||
- allow-jit / allow-unsigned-executable-memory: the JIT compiler writes
|
||||
and executes generated machine code.
|
||||
- disable-library-validation: amy loads the secp256k1 native .dylib that
|
||||
secp256k1-kmp-jni-jvm extracts from a jar at runtime; that dylib is not
|
||||
signed by our Team ID, so library validation would otherwise block it.
|
||||
- allow-dyld-environment-variables: the Gradle start script sets JVM env.
|
||||
Applied only to Mach-O *executables* in the image; plain dylibs are signed
|
||||
without entitlements. See .github/workflows/create-release.yml (build-cli).
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -442,6 +442,7 @@ private fun printUsage() {
|
||||
| store sweep-expired delete events past their NIP-40 expiration
|
||||
| store scrub rebuild idx/ from canonical events (after edits / crashes)
|
||||
| store compact drop dangling idx entries (canonical gone)
|
||||
| store reindex-fts rebuild the NIP-50 search index (after a searchable-kinds change)
|
||||
""".trimMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
@@ -45,19 +46,23 @@ import kotlin.io.path.exists
|
||||
* external edits.
|
||||
* - `compact` drop dangling `idx/` entries whose canonical is
|
||||
* gone. Cheaper than scrub.
|
||||
* - `reindex-fts` wipe and rebuild only the NIP-50 full-text search
|
||||
* index from the stored events. Run after a quartz
|
||||
* upgrade that changes which kinds are searchable.
|
||||
*/
|
||||
object StoreCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "store <stat|sweep-expired|scrub|compact>")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "store <stat|sweep-expired|scrub|compact|reindex-fts>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"stat" -> stat(dataDir)
|
||||
"sweep-expired" -> sweepExpired(dataDir)
|
||||
"scrub" -> scrub(dataDir)
|
||||
"compact" -> compact(dataDir)
|
||||
"reindex-fts" -> reindexFts(dataDir)
|
||||
else -> Output.error("bad_args", "store ${tail[0]}")
|
||||
}
|
||||
}
|
||||
@@ -161,6 +166,36 @@ object StoreCommands {
|
||||
0
|
||||
}
|
||||
|
||||
private suspend fun reindexFts(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
val ftsDir = dataDir.eventsDir.toPath().resolve("idx/fts")
|
||||
val before = countEntries(ftsDir)
|
||||
// Drive the resumable, batched path to completion so a huge
|
||||
// store is processed without holding the writer lock for the
|
||||
// whole pass. A real long-running caller would persist the
|
||||
// cursor between calls; here we just loop until done.
|
||||
var cursor: String? = null
|
||||
var processed = 0L
|
||||
var batches = 0
|
||||
do {
|
||||
val progress = store.reindexFullTextSearch(cursor, IEventStore.DEFAULT_FTS_REINDEX_BATCH)
|
||||
cursor = progress.cursor
|
||||
processed += progress.processedThisBatch
|
||||
batches++
|
||||
} while (!progress.done)
|
||||
val after = countEntries(ftsDir)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"ok" to true,
|
||||
"processed" to processed,
|
||||
"batches" to batches,
|
||||
"tokens_before" to before,
|
||||
"tokens_after" to after,
|
||||
),
|
||||
)
|
||||
0
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance verbs only need the store — not identity, not relays,
|
||||
* not the signer. Skip [Context.open] (which throws if no identity
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<string name="chats_history_subtitle">%1$s · %2$s · loaded since %3$s</string>
|
||||
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
|
||||
<string name="chats_history_waiting">waiting on %1$s</string>
|
||||
<string name="chats_history_incomplete">Some relays didn\'t respond</string>
|
||||
<string name="chats_history_incomplete">Some relays didn't respond</string>
|
||||
<string name="chats_history_incomplete_sub">%1$s unreachable · tap to see which</string>
|
||||
<string name="chats_history_relays_title">%1$s · history by relay</string>
|
||||
<string name="chats_history_relay_since">since %1$s</string>
|
||||
|
||||
+5
-1
@@ -49,7 +49,11 @@ class PublicChatChannel(
|
||||
var infoTags = EmptyTagList
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
override fun relays() = info.relays?.toSet() ?: super.relays()
|
||||
// An empty declared-relay list must behave like "no declared relays" and fall
|
||||
// back to the relays the channel was actually observed on. Without ifEmpty,
|
||||
// `emptyList()?.toSet()` short-circuits the elvis to an empty set, so the
|
||||
// channel reports zero relays and messages get published to nowhere.
|
||||
override fun relays() = info.relays?.ifEmpty { null }?.toSet() ?: super.relays()
|
||||
|
||||
fun relayHintUrls() = relays().take(3)
|
||||
|
||||
|
||||
+37
-28
@@ -23,16 +23,17 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L
|
||||
@@ -49,16 +50,14 @@ private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L
|
||||
* rebuilding the relay REQ — which would otherwise lose EOSE state and
|
||||
* trigger a refetch on return.
|
||||
*
|
||||
* The grace timer runs on a dedicated [Dispatchers.Default] scope driven by
|
||||
* [Lifecycle.currentStateFlow] rather than on the composition's frame-clock
|
||||
* coupled scope (`rememberCoroutineScope`). On a backgrounded app the UI
|
||||
* frame clock stops ticking, so a timer scheduled there could be starved and
|
||||
* the unsubscribe — and therefore the relay disconnect it triggers — might
|
||||
* never run. This is most visible on the relay feed, whose dedicated one-off
|
||||
* relay is kept connected by nothing else and would leak forever. Using a
|
||||
* plain coroutine dispatcher keeps the timer firing while backgrounded;
|
||||
* [collectLatest] cancels the pending delay automatically the moment the
|
||||
* lifecycle returns to STARTED.
|
||||
* Lifecycle transitions are observed with a main-thread [LifecycleEventObserver],
|
||||
* which fires synchronously during `onStop`/`onStart`. Detecting the transition
|
||||
* via a background-dispatched flow instead delivered `ON_STOP` up to ~60s late on
|
||||
* a backgrounded device (the collector only resumed on the next relay keep-alive
|
||||
* tick), leaving feeds connected long after the app was paused. Only the grace
|
||||
* *delay* runs on a [Dispatchers.Default] scope, so it isn't gated by the UI
|
||||
* frame clock (which stops ticking while backgrounded); returning to STARTED
|
||||
* cancels the pending unsubscribe before it fires.
|
||||
*
|
||||
* Use this for heavy feed subscriptions (home, video, discovery, chatroom list)
|
||||
* that should NOT run when the app is truly in the background. When an
|
||||
@@ -113,31 +112,41 @@ private fun LifecycleAwareSubscription(
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
|
||||
DisposableEffect(key, lifecycle) {
|
||||
// Background scope so the grace timer is not gated by the UI frame clock,
|
||||
// which stops ticking while the app is backgrounded.
|
||||
// Only the grace delay runs on a background scope so it isn't gated by the UI
|
||||
// frame clock, which stops ticking while the app is backgrounded.
|
||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
scope.launch {
|
||||
// `subscribed` is confined to this single collector coroutine, so no
|
||||
// cross-thread synchronization is needed for it.
|
||||
var subscribed = false
|
||||
lifecycle.currentStateFlow.collectLatest { current ->
|
||||
if (current.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
if (!subscribed) {
|
||||
// graceJob is only ever read/written from the main thread (observer callbacks),
|
||||
// so no synchronization is needed. subscribe()/unsubscribe() are idempotent
|
||||
// (reference-counted map ops), so re-issuing subscribe() on each ON_START is safe.
|
||||
var graceJob: Job? = null
|
||||
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
graceJob?.cancel()
|
||||
graceJob = null
|
||||
subscribe()
|
||||
subscribed = true
|
||||
}
|
||||
} else if (subscribed) {
|
||||
// Stopped: keep the REQ alive for a short grace period.
|
||||
// collectLatest cancels this delay if we return to STARTED first.
|
||||
delay(UNSUBSCRIBE_GRACE_MILLIS)
|
||||
unsubscribe()
|
||||
subscribed = false
|
||||
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
graceJob?.cancel()
|
||||
graceJob =
|
||||
scope.launch {
|
||||
if (UNSUBSCRIBE_GRACE_MILLIS > 0) delay(UNSUBSCRIBE_GRACE_MILLIS)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
scope.cancel()
|
||||
// Idempotent: removing an absent key is a cheap no-op. Guarantees the
|
||||
// subscription is released even if the grace timer was still pending.
|
||||
|
||||
+19
@@ -24,6 +24,7 @@ import androidx.compose.foundation.interaction.DragInteraction
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -56,6 +57,24 @@ abstract class LevelFeedViewModel(
|
||||
|
||||
val hasDragged = mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* Reply notes (by id hex) the user has collapsed in the thread view. A collapsed note
|
||||
* renders only a short preview of itself and hides all of its descendants. Backed by a
|
||||
* snapshot map so reads in composition recompose when the set changes.
|
||||
*/
|
||||
val collapsedReplies = mutableStateMapOf<String, Boolean>()
|
||||
|
||||
fun isCollapsed(idHex: String): Boolean = collapsedReplies[idHex] == true
|
||||
|
||||
fun toggleCollapsed(idHex: String) {
|
||||
if (collapsedReplies.remove(idHex) == null) {
|
||||
collapsedReplies[idHex] = true
|
||||
}
|
||||
// Collapsing is an explicit interaction, so stop the thread from auto-scrolling
|
||||
// to the focused note afterwards.
|
||||
hasDragged.value = true
|
||||
}
|
||||
|
||||
val selectedIDHex =
|
||||
llState.interactionSource.interactions
|
||||
.onEach {
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Reproduces the "public chat message not sent to the channel-declared relay" report.
|
||||
*
|
||||
* Walks the exact protocol path the app uses:
|
||||
* 1. build a kind-40 ChannelCreate carrying a declared relay in its content,
|
||||
* 2. feed it to a PublicChatChannel via updateChannelInfo (what LocalCache does),
|
||||
* 3. assert channel.relays() returns the declared relay (what both the send path
|
||||
* and computeRelaysForChannels/broadcast read),
|
||||
* 4. build a kind-42 message for that channel and assert channelId() resolves back
|
||||
* to the channel id (what getAnyChannel uses to find the channel at broadcast).
|
||||
*/
|
||||
class PublicChatChannelRelayTest {
|
||||
private val authorKey = "e".repeat(64)
|
||||
private val channelId = "a".repeat(64)
|
||||
private val msgId = "b".repeat(64)
|
||||
private val sig = "f".repeat(128)
|
||||
|
||||
private val declaredRelay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
|
||||
|
||||
private fun createEvent(relays: List<NormalizedRelayUrl>?): ChannelCreateEvent {
|
||||
val template = ChannelCreateEvent.build("My Channel", "about", null, relays)
|
||||
return ChannelCreateEvent(channelId, authorKey, template.createdAt, template.tags, template.content, sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelRelaysContainsDeclaredRelay() {
|
||||
val create = createEvent(listOf(declaredRelay))
|
||||
|
||||
// sanity: the relay survives the content round-trip
|
||||
assertEquals(listOf(declaredRelay), create.channelInfo().relays)
|
||||
|
||||
val channel = PublicChatChannel(channelId)
|
||||
channel.info = create.channelInfo() // exactly what updateChannelInfo does
|
||||
|
||||
assertTrue(channel.relays().contains(declaredRelay), "channel.relays() must contain the declared relay")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageResolvesBackToChannel() {
|
||||
val create = createEvent(listOf(declaredRelay))
|
||||
|
||||
val template = ChannelMessageEvent.message("hello", EventHintBundle(create, declaredRelay))
|
||||
val message = ChannelMessageEvent(msgId, authorKey, template.createdAt, template.tags, template.content, sig)
|
||||
|
||||
assertEquals(channelId, message.channelId(), "message must resolve to the channel id (getAnyChannel)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDeclaredRelayListFallsThroughToObservedRelays() {
|
||||
// Regression: info.relays = [] (empty, not null) must NOT short-circuit to an
|
||||
// empty set. It has to fall back to the relays the channel was observed on,
|
||||
// otherwise messages are published to nowhere.
|
||||
val create = createEvent(emptyList())
|
||||
|
||||
val channel = PublicChatChannel(channelId)
|
||||
channel.addRelaySync(declaredRelay) // observed relay
|
||||
channel.info = create.channelInfo() // exactly what updateChannelInfo does
|
||||
|
||||
assertTrue(channel.info.relays!!.isEmpty(), "precondition: declared relay list is empty")
|
||||
assertTrue(
|
||||
channel.relays().contains(declaredRelay),
|
||||
"relays() must fall back to observed relays when the declared list is empty",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Launch Benchmark
|
||||
|
||||
Single-JVM warm benchmark for the cold-boot critical path. See
|
||||
[`desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md`](../plans/2026-06-17-feat-app-launch-optimization-plan.md)
|
||||
for the design.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
AMETHYST_BENCH=true ./gradlew :desktopApp:test \
|
||||
--tests "*LaunchBenchmark.run" --rerun-tasks
|
||||
```
|
||||
|
||||
Without the env var the test class skips itself silently so a normal
|
||||
`./gradlew :desktopApp:test` stays fast.
|
||||
|
||||
## Output
|
||||
|
||||
A per-git-sha report lands at
|
||||
`desktopApp/build/benchmarks/launch-<git-sha>.txt`. The benchmark prints the
|
||||
report to stdout and atomically writes the file via
|
||||
`Files.move(... ATOMIC_MOVE)` so a killed run does not pollute the trend
|
||||
data with partial content.
|
||||
|
||||
## Reading the numbers
|
||||
|
||||
Each row reports `n`, min, q1, median, q3, max in milliseconds. The
|
||||
benchmark drives the slim "non-Compose" cold-boot scenario:
|
||||
`AccountManager.loadSavedAccount` → relay subscription via
|
||||
`InProcessWebsocketBuilder` (fixture relay) → `DesktopLocalCache.consume`.
|
||||
|
||||
- `t_account_logged_in` — `AccountManager.accountState` reaches
|
||||
`LoggedIn(isReadOnly=true)`.
|
||||
- `t_first_event` — first `kind:1` flows through `DesktopLocalCache.consume`.
|
||||
- `t_n_events` — `n`th (default `n=10`) `kind:1` flows through the cache.
|
||||
|
||||
Numbers are dominated by the harness floor (`InProcessWebSocket` channel
|
||||
hops, fixture-server REQ matching, coroutine dispatcher schedule). They
|
||||
are most useful as a **regression guard** for code already in the
|
||||
exercised path; they do **not** approximate a real Skia/Swing first paint.
|
||||
Layered Compose-driven and JVM-fork variants are tracked as deferred
|
||||
follow-ups in the plan.
|
||||
|
||||
## Snapshots committed here
|
||||
|
||||
- `baseline-main.txt` — pre-Phase-5.2 baseline.
|
||||
- `with-phase5-fixes.txt` — post-Phase-5.2 snapshot.
|
||||
|
||||
Diff manually with `diff -u baseline-main.txt with-phase5-fixes.txt`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# LaunchBenchmark report — PRE-PHASE-5.2 baseline
|
||||
# Captured BEFORE the Main.kt bootstrap-gate removal. The slim benchmark
|
||||
# does not drive the App() bootstrap subscription path, so this row is
|
||||
# primarily a harness-floor sanity reference for the cold-boot
|
||||
# relay→cache pipeline; the gate fix itself is validated by
|
||||
# SubscribeBeforeConnectTest rather than by a measurable delta here.
|
||||
# date 2026-06-18T07:46:13.631678Z
|
||||
# jvm 21.0.9 Homebrew
|
||||
# os Mac OS X 26.5 aarch64
|
||||
# cpus 10
|
||||
# max-heap-mb 512
|
||||
# git-sha 48726ee4df (after Phase 1 pyramid + Phase 5.1 icon fix)
|
||||
# iterations 5 (after 2 warmup, discarded)
|
||||
# fork-mode single-JVM (cold-fork driver deferred)
|
||||
|
||||
t_account_logged_in n=5 min= 0.27ms q1= 0.27ms median= 0.31ms q3= 0.34ms max= 0.47ms
|
||||
t_first_event n=5 min= 0.79ms q1= 0.87ms median= 0.95ms q3= 1.11ms max= 1.15ms
|
||||
t_n_events n=5 min= 1.27ms q1= 1.35ms median= 1.36ms q3= 1.48ms max= 2.38ms
|
||||
# events-consumed per iteration: [11, 12, 12, 12, 13]
|
||||
@@ -0,0 +1,21 @@
|
||||
# LaunchBenchmark report — POST-PHASE-5.2 (gate removal) snapshot
|
||||
# Captured AFTER the Main.kt bootstrap-gate removal. NOTE: the slim
|
||||
# benchmark does not actually exercise the gated code path (the gate is
|
||||
# inside the App() composable, which this harness does not drive). The
|
||||
# row remains a harness-floor sanity check; the gate-removal payoff
|
||||
# manifests during real App() boot when no relay has connected yet and
|
||||
# the previous 30s `withTimeoutOrNull` would otherwise idle the
|
||||
# subscription.
|
||||
# date 2026-06-18T07:57:51.519608Z
|
||||
# jvm 21.0.9 Homebrew
|
||||
# os Mac OS X 26.5 aarch64
|
||||
# cpus 10
|
||||
# max-heap-mb 512
|
||||
# git-sha 48726ee4df23bf97c6e302766af42e048c3060f0
|
||||
# iterations 5 (after 2 warmup, discarded)
|
||||
# fork-mode single-JVM (cold-fork driver deferred)
|
||||
|
||||
t_account_logged_in n=5 min= 0.21ms q1= 0.48ms median= 0.72ms q3= 0.87ms max= 1.15ms
|
||||
t_first_event n=5 min= 1.17ms q1= 1.34ms median= 1.46ms q3= 2.06ms max= 2.10ms
|
||||
t_n_events n=5 min= 1.51ms q1= 1.76ms median= 2.63ms q3= 2.75ms max= 3.55ms
|
||||
# events-consumed per iteration: [23, 50, 15, 28, 23]
|
||||
@@ -135,6 +135,36 @@ compose.desktop {
|
||||
macOS {
|
||||
bundleID = "com.vitorpamplona.amethyst.desktop"
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||
|
||||
// --- Developer ID code signing + notarization ---
|
||||
// Required for Homebrew's main cask (unsigned casks rejected after
|
||||
// 2026-09-01) and to clear macOS Gatekeeper without the right-click
|
||||
// dance. Gated on the signing-identity env var so local dev builds
|
||||
// and PR CI keep producing plain UNSIGNED DMGs exactly as before —
|
||||
// signing only kicks in when the release workflow exports these
|
||||
// (which it does only when the Apple secrets are present):
|
||||
//
|
||||
// AMETHYST_MAC_SIGN_IDENTITY "Developer ID Application: NAME (TEAMID)"
|
||||
// AMETHYST_NOTARY_APPLE_ID Apple ID email of the notary account
|
||||
// AMETHYST_NOTARY_PASSWORD app-specific password for that Apple ID
|
||||
// AMETHYST_NOTARY_TEAM_ID 10-char Apple Developer Team ID
|
||||
//
|
||||
// The Developer ID Application certificate must already be in the
|
||||
// build host's keychain (CI imports it from a base64 .p12 secret).
|
||||
// Compose ships default hardened-runtime entitlements that permit
|
||||
// the JVM's JIT, so no custom entitlements file is needed.
|
||||
val macSignIdentity = System.getenv("AMETHYST_MAC_SIGN_IDENTITY")
|
||||
if (!macSignIdentity.isNullOrBlank()) {
|
||||
signing {
|
||||
sign.set(true)
|
||||
identity.set(macSignIdentity)
|
||||
}
|
||||
notarization {
|
||||
appleID.set(System.getenv("AMETHYST_NOTARY_APPLE_ID"))
|
||||
password.set(System.getenv("AMETHYST_NOTARY_PASSWORD"))
|
||||
teamID.set(System.getenv("AMETHYST_NOTARY_TEAM_ID"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
windows {
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
---
|
||||
title: App Launch Optimization (Desktop, Foundation-first)
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-06-17
|
||||
origin: docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md
|
||||
deepened: 2026-06-17
|
||||
---
|
||||
|
||||
# App Launch Optimization (Desktop, Foundation-first)
|
||||
|
||||
## Progress Log
|
||||
|
||||
| Date | Phase | Outcome | Commit |
|
||||
|------------|-----------|------------------------------------------------------------------------------------------|-------------|
|
||||
| 2026-06-17 | 1.1 | `AccountManagerLoadStateTransitionsTest` — 2 tests pass | `ff55898ab` |
|
||||
| 2026-06-17 | 1.2 | `LocalRelayStoreHydrationTest` — 5 tests pass | `ff55898ab` |
|
||||
| 2026-06-17 | 1.3 | `LocalRelayStore` gains `homeDir` ctor param (default unchanged) | `ff55898ab` |
|
||||
| 2026-06-17 | 5.1 | `IconResources` collapses 4 sites + 2 `ImageIO.read` calls into one lazy each; 5 tests | `b338d7db4` |
|
||||
| 2026-06-18 | 2.1 / 2.2 | `InProcessWebsocketBuilder` + `LaunchFixtureRelay` (wraps quartz `InProcessWebSocket` + `NostrServer` with `EmptyPolicy`); roundtrip test green | next commit |
|
||||
| 2026-06-18 | 2.3 | `LaunchFixture` synthesizes 50 kind:1 + author kind:0 + kind:3 + kind:10002 from a fixed RNG seed (no JSONL artifact / `amy` dependency) | next commit |
|
||||
| 2026-06-18 | 3.1 | `LaunchMarkers` (single-threaded `mutableMapOf` + `TimeSource.Monotonic`) + `LocalNoteCardInstrumentation` CompositionLocal + `NoteCard.Modifier.testTag(NOTE_CARD_TEST_TAG).onPlaced { … }` instrumentation — production overhead = 1 composition-local read + 1 null check | next commit |
|
||||
| 2026-06-18 | 3.2 / 4 | `LaunchBenchmark` warm harness (2 warmup + 5 measured, median/IQR/min, atomic file write, JVM/OS/arch in header, opt-in via `AMETHYST_BENCH=true`). Baseline captured at `desktopApp/benchmarks/baseline-main.txt` | next commit |
|
||||
| 2026-06-18 | 5.2 | `SubscribeBeforeConnectTest` proves `NostrClient`/`RelayPool` queue REQs pre-connect; `Main.kt:1242` bootstrap gate `connectedRelays.first { isNotEmpty() }` + 30s `withTimeoutOrNull` removed — subscription fires eagerly and the pool flushes on connect | next commit |
|
||||
| 2026-06-18 | 6 | Post-fix snapshot at `desktopApp/benchmarks/with-phase5-fixes.txt` | `b14ee5ec5` |
|
||||
| 2026-06-18 | 1.4 / 2.4 / 5.2-tests | `LaunchTestOverrides` makes `relayManager` / `localCache` / `localRelayStore` / `torSettings` injectable into `App()`. `DesktopRelayConnectionManager` gains a secondary ctor taking a `WebsocketBuilder` so the fixture relay can substitute without relaxing `LocalRelayManager`'s composition-local type. `AppStateMachineTest` ships four tests: logged-out → LoginScreen; ViewOnly preloaded → LoggedIn; bootstrap-gate-removal verified against `NeverConnectsWebsocketBuilder`; no double-fire of the bootstrap REQ via `RecordingWebsocketBuilder`. | `48a8178c9` |
|
||||
|
||||
**All foundational and in-scope phases (1.1, 1.2, 1.3, 1.4, 2.1-2.4, 3.1, 3.2, 4, 5.1, 5.2, 6) are now landed.** 278/278 desktopApp tests pass.
|
||||
|
||||
**Deferred to follow-up plans (not part of this effort):**
|
||||
|
||||
- **Cold-fork shell driver** for the benchmark (per-sample JVM fork). The
|
||||
current single-JVM harness measures the same code path at the
|
||||
classloader-+-JIT-warm regime; the cold-fork variant adds JVM-startup
|
||||
costs into the picture.
|
||||
- **Compose-driving benchmark variant** (wires `LaunchMarkers` to
|
||||
`LocalNoteCardInstrumentation` and drives `App()` via the harness
|
||||
introduced for Phase 1.4 so the markers include the real composition +
|
||||
layout cost, not just the relay/cache pipeline).
|
||||
- **Phase 5.3** (sequential `remember` chain in `MainContent`) — a
|
||||
separate plan; only worth tackling if a real cold-boot profiler trace
|
||||
shows `MainContent` composition is on the critical path.
|
||||
- **Memory + warm-boot benchmark variants** — once the cold-fork driver
|
||||
and Compose-driving variant land, layering memory + pre-seeded `events.db`
|
||||
warm-boot is straightforward.
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened:** 2026-06-17 — 5 review agents (code-simplicity, architecture-strategist, performance-oracle, pattern-recognition, spec-flow-analyzer) plus repo-research-analyst.
|
||||
|
||||
**Key changes from initial plan:**
|
||||
|
||||
1. **Plan relocated** from `docs/plans/` (frozen per CLAUDE.md) to `desktopApp/plans/`.
|
||||
2. **Phase 5.3 (sequential `remember` chain) cut** — spawn separate plan if Phase 4 baseline justifies. Avoids investigation scope creep.
|
||||
3. **Warm-boot benchmark variant deferred** — all in-scope fixes target cold-boot; defer until a warm-path fix appears.
|
||||
4. **Memory metric deferred** — `runComposeUiTest` heap isn't representative of real Swing/Skia; JVM `System.gc()` semantics unreliable. Revisit after baseline.
|
||||
5. **Test fixtures placed in `:quartz` testFixtures** (not `:commons`) — `:quartz` already proves KMP + `java-test-fixtures` works (consumes `:geode` fixtures at `quartz/build.gradle.kts:352-358`). `:commons` KMP attempt was unnecessary risk.
|
||||
6. **Use `createComposeRule()`** (not `runComposeUiTest`) — repo convention per `DesktopLaunchSmokeTest.kt:24`.
|
||||
7. **Repo's existing test pattern**: `backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { ... toList(states) } + advanceUntilIdle()` (per `AccountManagerStateTransitionTest.kt:73-95`).
|
||||
8. **Cold benchmark forks JVM per sample** (shell-script driver) — same-JVM iterations measure JIT warmth, not cold boot.
|
||||
9. **Renamed `t_compose_first_frame` → `t_first_composition_apply`** with explicit "not a real Skia frame" disclaimer.
|
||||
10. **Event-count instrumentation via `Modifier.onPlaced` + `AtomicInteger`** — semantic-tree polling has 16ms quantization + tree-traversal bias.
|
||||
11. **Pinned JVM flags** (`-Xms512m -Xmx512m -XX:+UseG1GC`); control benchmark (empty `Box`) measures harness floor.
|
||||
12. **N=20 warm iterations, median + IQR + min** — Mann-Whitney U for fix delta significance, not t-test.
|
||||
13. **`AccountManagerLoadStateTransitionsTest`** renamed to avoid collision with existing `AccountManagerStateTransitionTest`.
|
||||
14. **Cut Internal/Remote AccountManager tests** — only ViewOnly drives the benchmark; deferred coverage is a separate scope.
|
||||
15. **Cut `tools/launch-fixture/capture.sh`** — manual one-shot fixture commit; document `amy` commands in README.
|
||||
|
||||
### New Considerations Discovered
|
||||
- `:quartz` already consumes `:geode` testFixtures across KMP boundary — KMP+testFixtures friction is overstated.
|
||||
- `runComposeUiTest` skips real Swing/Skia surface; `t_first_composition_apply` measures composition cost, not paint.
|
||||
- `RelayPool` queue-pre-connect behavior is the hinge for Phase 5.2 candidate (a) vs (b); must verify before refactor.
|
||||
- Both home + DM subscriptions share the same gate (`Main.kt:1283-1326` + `1330`); fix must share a helper.
|
||||
- `InProcessWebSocket` latency floor (~10-50ms) may swallow icon-decode delta on `t_n_events`; microbench the ImageIO.read cost directly.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Build the **testing + benchmarking foundation** needed to safely refactor Amethyst Desktop's launch path, capture a quantitative baseline, then ship two targeted launch-path fixes guided by the resulting numbers. Android is explicitly deferred to a follow-up plan; sequential `remember` chain refactor (originally Phase 5.3) is deferred to a separate plan post-baseline.
|
||||
|
||||
Phased delivery (Approach A from brainstorm):
|
||||
|
||||
1. **Test pyramid foundation** — unit tests around `AccountManager.loadSavedAccount` (ViewOnly path only) + `LocalRelayStore.hydrate`; three `App()`-level Compose UI smoke tests. No launch-path code change beyond an optional `homeDir` ctor param on `LocalRelayStore`.
|
||||
2. **Deterministic relay seam** — `InProcessWebsocketBuilder` + `FixtureNostrServer` in `:quartz` testFixtures. Real-world snapshot fixture (50 kind:1 + metadata for one well-known npub).
|
||||
3. **Benchmark harness** — JVM-only harness driving `App()` via `createComposeRule` + onPlaced markers. Two harnesses: cold (fork-per-sample shell driver, N=10) and warm (single JVM, N=20). Memory + warm-boot DEFERRED.
|
||||
4. **Baseline capture** — run benchmark on `main`, commit numbers as the reference point.
|
||||
5. **Targeted fixes** — icon decode (cheap, microbench independent), feed bootstrap relay gate (medium refactor); each re-benchmarked after.
|
||||
|
||||
(see brainstorm: `docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Cold-boot perception is a primary UX signal and currently **unmeasured**. No timing markers exist in `Main.kt`, `App()`, or `AccountManager.loadSavedAccount`. The desktop launch path has identified bottlenecks (`Main.kt:218,302,988` triple icon decode on main thread; `Main.kt:1283-1330` home + DM subscriptions both gated on `connectedRelays.first { isNotEmpty() }` with a 30s timeout) — but refactoring boot code without tests is exactly how regressions ship.
|
||||
|
||||
The only existing test that touches launch wiring is `DesktopLaunchSmokeTest.kt:24` (drives `LoginScreen` only via `createComposeRule`); nothing covers `App()`, `MainContent`, or the `AccountState` transitions. We need the safety net **before** the refactors.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Foundation-first: write tests before touching launch code. Inject a deterministic in-memory relay via the already-open `WebsocketBuilder` constructor seam on `RelayConnectionManager` (`Main.kt:834`). Drive the read-only npub flow (`AccountManager.loadReadOnlyAccount` — pure, no I/O, no keychain) as the headline benchmark scenario. Measure four time metrics (composition apply, account hydration done, first event, N=10 events; memory DEFERRED). Capture cold (fork-per-sample) and warm (single-JVM N=20) runs locally. Then fix one bottleneck at a time and prove the delta with numbers.
|
||||
|
||||
Key insight (deepen-plan): **most seams already exist.** `RelayConnectionManager` is `open class` taking `WebsocketBuilder`. `App()` is window-agnostic. Quartz already ships `InProcessWebSocket` + `NostrServer`. `:quartz` already consumes `:geode` testFixtures across the KMP boundary. The brainstorm's "FakeWebsocketBuilder" is a 20-line wrapper.
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Test JVM (jvmTest)
|
||||
├── LaunchMarkers (single-threaded, mutableMapOf<String, Duration>)
|
||||
├── createComposeRule { setContent { MaterialTheme { App(...) } } }
|
||||
│ └── App() with injected deps:
|
||||
│ ├── AccountManager — temp homeDir, no keychain (ViewOnly only)
|
||||
│ ├── LocalRelayStore — temp homeDir param (new ctor arg)
|
||||
│ ├── DesktopRelayConnectionManager — InProcessWebsocketBuilder(fakeServer)
|
||||
│ └── LocalRelayMaintenance.start() suppressed in tests (no refactor)
|
||||
├── InProcessWebsocketBuilder (:quartz testFixtures)
|
||||
│ └── wraps InProcessWebSocket → FixtureNostrServer
|
||||
│ └── loads fixtures/launch/<name>.jsonl, matches REQ via Filter.match
|
||||
└── Fixture: quartz/src/testFixtures/resources/fixtures/launch/fiatjaf-50.jsonl
|
||||
```
|
||||
|
||||
### Module Placement
|
||||
|
||||
- `:quartz` testFixtures: `InProcessWebsocketBuilder`, `FixtureNostrServer`, fixture JSONL.
|
||||
- `:desktopApp:jvmTest`: `AccountManagerLoadStateTransitionsTest`, `LocalRelayStoreHydrationTest`, `AppStateMachineTest`, `LaunchMarkers`, `LaunchBenchmark`.
|
||||
- `:commons/commonMain`: `NoteCardTags` constants object (so Android can reuse).
|
||||
- `:desktopApp:jvmMain`: `NoteCard` adds `Modifier.testTag(NoteCardTags.ROOT).onPlaced { ... }` — one-line change with negligible production cost.
|
||||
|
||||
Consumer wiring: `desktopApp/build.gradle.kts` adds `jvmTestImplementation(testFixtures(project(":quartz")))`. Pattern proven at `quartz/build.gradle.kts:352-358` consuming `testFixtures(project(":geode"))`.
|
||||
|
||||
### Marker Registry
|
||||
|
||||
```kotlin
|
||||
// desktopApp/src/jvmTest/kotlin/.../benchmark/LaunchMarkers.kt
|
||||
object LaunchMarkers {
|
||||
private val timestamps = mutableMapOf<String, Duration>()
|
||||
private var start: TimeMark? = null
|
||||
|
||||
fun start() { timestamps.clear(); start = TimeSource.Monotonic.markNow() }
|
||||
fun mark(name: String) {
|
||||
if (name !in timestamps) timestamps[name] = start!!.elapsedNow()
|
||||
}
|
||||
fun snapshot(): Map<String, Duration> = timestamps.toMap()
|
||||
}
|
||||
```
|
||||
|
||||
Single-threaded — Gradle test parallelism disabled for benchmark suite (`maxParallelForks = 1` filtered to `*LaunchBenchmark*`).
|
||||
|
||||
Observation points (all in test code, zero production instrumentation):
|
||||
|
||||
- **`t_first_composition_apply`** — first `composeTestRule.waitForIdle()` returns after `setContent`. (Renamed from `t_compose_first_frame` — does NOT represent real Skia/GPU frame.)
|
||||
- **`t_account_logged_in`** — observed via `backgroundScope.launch(UnconfinedTestDispatcher) { accountManager.accountState.toList(states) }` — marker recorded when `AccountState.LoggedIn` arrives.
|
||||
- **`t_first_event`** — `Modifier.onPlaced` callback on `NoteCard` increments an `AtomicInteger`; marker recorded when counter goes 0→1.
|
||||
- **`t_n_events`** — same counter reaches 10.
|
||||
|
||||
NoteCard production change is small:
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/.../ui/note/NoteCardTags.kt (new)
|
||||
object NoteCardTags {
|
||||
const val ROOT = "amethyst.note_card.root"
|
||||
}
|
||||
|
||||
// desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97
|
||||
fun NoteCard(...) {
|
||||
Surface(modifier = Modifier
|
||||
.testTag(NoteCardTags.ROOT)
|
||||
.onPlacedHook() // no-op in production, calls LaunchInstrumentation in tests
|
||||
...) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`onPlacedHook` is a `Modifier` extension defined in `commons/commonMain` that's a no-op by default; tests swap it via a CompositionLocal `LocalLaunchInstrumentation` (default = `LaunchInstrumentation.Noop`). One-line override in `AppStateMachineTest` and `LaunchBenchmark`.
|
||||
|
||||
### Fake Relay Wire Path
|
||||
|
||||
```
|
||||
Test sets up: FixtureNostrServer(fixture = "fiatjaf-50.jsonl")
|
||||
↓
|
||||
Test constructs: DesktopRelayConnectionManager(InProcessWebsocketBuilder(server))
|
||||
↓
|
||||
App() → RelayConnectionManager → NostrClient → RelayPool → BasicRelayClient
|
||||
↓ (uses InProcessWebsocketBuilder)
|
||||
InProcessWebSocket connects to FixtureNostrServer
|
||||
↓
|
||||
Test sets account.relays to listOf(NormalizedRelayUrl("wss://test.invalid"))
|
||||
↓
|
||||
NostrClient sends REQ frames; FixtureNostrServer matches by Filter, replays EVENTs + EOSE
|
||||
↓
|
||||
DesktopLocalCache.consume(event, relay = "wss://test.invalid", wasVerified = true)
|
||||
↓
|
||||
LocalFeedProvider observes; FeedScreen LazyColumn composes NoteCards
|
||||
↓
|
||||
Modifier.onPlaced fires per NoteCard, AtomicInteger inc, LaunchMarkers records t_first_event then t_n_events
|
||||
```
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1 — Test pyramid foundation
|
||||
|
||||
**1.1 — `AccountManagerLoadStateTransitionsTest` (ViewOnly only)**
|
||||
|
||||
New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt`
|
||||
|
||||
Renamed to avoid collision with existing `AccountManagerStateTransitionTest.kt`. Covers ViewOnly happy path + one decode-failure path (corrupt `accounts.json.enc`). Internal + Remote deferred — they are unrelated to the benchmark, and adding them is scope creep.
|
||||
|
||||
Pattern follows repo convention (`AccountManagerStateTransitionTest.kt:73-95`):
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun viewOnlyAccountTransitionsThroughLoadingToLoggedIn() = runTest {
|
||||
val storage = mockk<SecureKeyStorage>(relaxed = true)
|
||||
val tempDir = createTempDirectory("acctmgr-load-state").toFile()
|
||||
writeAccountsJsonEnc(tempDir, viewOnlyAccountInfo(testNpub))
|
||||
val mgr = AccountManager(storage, tempDir)
|
||||
|
||||
val states = mutableListOf<AccountState>()
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
mgr.accountState.toList(states)
|
||||
}
|
||||
mgr.loadSavedAccount()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(states.size >= 3)
|
||||
assertTrue(states.first() is AccountState.Loading)
|
||||
assertTrue(states.last() is AccountState.LoggedIn)
|
||||
assertEquals(true, (states.last() as AccountState.LoggedIn).isReadOnly)
|
||||
}
|
||||
```
|
||||
|
||||
Acceptance: 2 tests pass (happy + decode failure).
|
||||
|
||||
**1.2 — `LocalRelayStoreHydrationTest`**
|
||||
|
||||
Covers `LocalRelayStore.hydrate` invariants (`LocalRelayStore.kt:115-161`). 5 tests:
|
||||
|
||||
- kind:3 (contact list) consumed before kind:0 (metadata).
|
||||
- kind:0 author metadata before activity events.
|
||||
- All consumed events tagged with `LOCAL_RELAY_URL` + `wasVerified=true`.
|
||||
- Empty DB hydrates without throwing; cache receives no events.
|
||||
- Replaceable (kind:0/3): only most recent kept.
|
||||
|
||||
**1.3 — `LocalRelayStore` `homeDir` ctor seam**
|
||||
|
||||
File: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt`
|
||||
|
||||
```kotlin
|
||||
class LocalRelayStore(
|
||||
private val scope: CoroutineScope,
|
||||
private val homeDir: File = File(System.getProperty("user.home")),
|
||||
) : AutoCloseable {
|
||||
private fun dbDir(pubKeyHex: String): File =
|
||||
File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`LocalRelayMaintenance` refactor **deferred** — tests just don't call `maintenance.start()`. If/when it becomes a blocker, refactor then.
|
||||
|
||||
**1.4 — `AppStateMachineTest` (Compose UI smoke)**
|
||||
|
||||
New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt`
|
||||
|
||||
Uses `createComposeRule()` (repo convention). Wraps `MaterialTheme {}` per `DesktopLaunchSmokeTest.kt:69` pattern. Three tests:
|
||||
|
||||
- `loggedOutShowsLoginScreen()` — no `accounts.json.enc`.
|
||||
- `viewOnlyAccountReachesLoggedIn()` — pre-write ViewOnly account.
|
||||
- `forceLogoutReasonShowsDialog()` — pre-populate `forceLogoutReason`.
|
||||
|
||||
Temporary minimal `WebsocketBuilder` fake (replaced by `InProcessWebsocketBuilder` in 2.4).
|
||||
|
||||
Acceptance: 3 tests pass, ≤ 5s each.
|
||||
|
||||
#### Phase 2 — Deterministic relay seam
|
||||
|
||||
**2.1 — `InProcessWebsocketBuilder` in `:quartz` testFixtures**
|
||||
|
||||
Add `java-test-fixtures` plugin + testFixtures source set to `:quartz` if not already. (Verify: research found `quartz/build.gradle.kts:352-358` already consumes `testFixtures(project(":geode"))`, so the plugin DSL is in use somewhere in the chain — may need to be enabled on `:quartz` for it to produce fixtures.)
|
||||
|
||||
New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/InProcessWebsocketBuilder.kt`
|
||||
|
||||
```kotlin
|
||||
class InProcessWebsocketBuilder(
|
||||
private val server: NostrServer,
|
||||
) : WebsocketBuilder {
|
||||
override fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket =
|
||||
InProcessWebSocket(url, out, server)
|
||||
}
|
||||
```
|
||||
|
||||
Unit test verifies `RelayPool(builder).request(...)` round-trips REQ → EVENT + EOSE within 100ms.
|
||||
|
||||
**2.2 — `FixtureNostrServer`**
|
||||
|
||||
New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/FixtureNostrServer.kt`
|
||||
|
||||
Spec (edge cases from spec-flow review baked in):
|
||||
|
||||
- Load fixture JSONL at construction; fail fast with `FixtureParseException(line, lineNumber, cause)` on malformed line.
|
||||
- On REQ: match `Filter` per fixture event via `Filter.match(event)`. Send matching events in fixture order.
|
||||
- **Always send EOSE** after replay, even if no events match (prevents benchmark hang).
|
||||
- Per-connection `Mutex` to serialize REQ handling — safe under concurrent home + DM subs.
|
||||
- Configurable response delay (default 5ms) to model relay RTT.
|
||||
|
||||
Tests: 50-event fixture + Filter for kinds=[1] authors=[pubKey] → 50 events + EOSE within 100ms; malformed line → `FixtureParseException`; empty match → bare EOSE.
|
||||
|
||||
**2.3 — Real-world snapshot fixture (manual capture)**
|
||||
|
||||
Per simplicity reviewer: no capture script. One-shot manual capture using `amy`. Document the exact commands in `quartz/src/testFixtures/resources/fixtures/launch/README.md`:
|
||||
|
||||
```bash
|
||||
amy fetch --kinds 1 --author <fiatjaf-npub> --limit 50 --json > fiatjaf-50.jsonl
|
||||
amy fetch --kinds 0 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
|
||||
# For each p-tag referenced pubkey:
|
||||
amy fetch --kinds 0 --author <ref-npub> --json >> fiatjaf-50.jsonl
|
||||
amy fetch --kinds 3 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
|
||||
amy fetch --kinds 10002 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
|
||||
```
|
||||
|
||||
Commit `fiatjaf-50.jsonl` as an immutable artifact. Re-capture only via explicit human action; PR that recaptures must include new baseline numbers.
|
||||
|
||||
Acceptance: fixture present, ≥ 50 events, all valid signatures, FixtureNostrServer loads it without error.
|
||||
|
||||
**2.4 — Replace temporary fake in 1.4 tests**
|
||||
|
||||
Update `AppStateMachineTest.viewOnlyAccountReachesLoggedIn()` to use `InProcessWebsocketBuilder(FixtureNostrServer.load("fiatjaf-50.jsonl"))`. Validates full subscription wiring end-to-end.
|
||||
|
||||
Acceptance: existing Phase 1.4 tests still pass with real fixture data, no time blow-up (≤ 8s).
|
||||
|
||||
#### Phase 3 — Benchmark harness
|
||||
|
||||
**3.1 — `LaunchMarkers` + instrumentation hooks**
|
||||
|
||||
- `LaunchMarkers` (simple, single-threaded, per above).
|
||||
- `NoteCardTags` const object in `commons/commonMain`.
|
||||
- `LocalLaunchInstrumentation` CompositionLocal in `commons/commonMain` (default = `Noop`).
|
||||
- `Modifier.onPlacedHook()` extension reading the CompositionLocal.
|
||||
- NoteCard adds `.testTag(NoteCardTags.ROOT).onPlacedHook()` to its root surface (one line each, prod-safe).
|
||||
|
||||
Acceptance: launching `App()` against fixture relay produces 4 named markers within 10s.
|
||||
|
||||
**3.2 — Two benchmark harnesses (cold + warm)**
|
||||
|
||||
Per perf reviewer: cold and warm need different statistical regimes.
|
||||
|
||||
**Cold harness** — `desktopApp/benchmarks/cold-launch.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
ITERATIONS=${1:-10}
|
||||
mkdir -p desktopApp/build/benchmarks
|
||||
out=desktopApp/build/benchmarks/cold-$(git rev-parse --short HEAD).tmp
|
||||
echo "# cold-launch $(date -u +%FT%TZ) $(java --version | head -1)" > "$out"
|
||||
echo "# host: $(uname -a)" >> "$out"
|
||||
echo "# jvm-flags: -Xms512m -Xmx512m -XX:+UseG1GC" >> "$out"
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
./gradlew --no-daemon :desktopApp:jvmTest \
|
||||
--tests "*ColdLaunchBenchmark.runOnce" \
|
||||
-Dorg.gradle.jvmargs="-Xms512m -Xmx512m -XX:+UseG1GC" \
|
||||
-Pbench.output="$out"
|
||||
done
|
||||
mv "$out" "${out%.tmp}.txt"
|
||||
desktopApp/benchmarks/report.py "${out%.tmp}.txt"
|
||||
```
|
||||
|
||||
Each iteration forks a fresh JVM (no daemon). `ColdLaunchBenchmark.runOnce` appends one row per metric (median is just the value; no in-JVM warmup). Output is moved atomically once complete (spec-flow gap #12 — atomic write).
|
||||
|
||||
**Warm harness** — `WarmLaunchBenchmark` JUnit test, single JVM:
|
||||
|
||||
- N=20 iterations within one test method.
|
||||
- Discard first 5 iterations (JIT warmup).
|
||||
- Report median + IQR + min on remaining 15.
|
||||
- Atomic write to `desktopApp/build/benchmarks/warm-${git_sha}.txt`.
|
||||
|
||||
Both harnesses include a **control benchmark**: `setContent { Box {} }` with onPlaced + LaunchMarkers, to measure harness floor. Reported alongside; reviewers can read SNR.
|
||||
|
||||
`desktopApp/benchmarks/report.py` parses output, computes Mann-Whitney U vs. baseline if present, prints summary table.
|
||||
|
||||
Acceptance:
|
||||
- Cold harness completes in ≤ 4 min (10 × ~25s/fork) on a developer laptop.
|
||||
- Warm harness completes in ≤ 2 min.
|
||||
- Output files include git SHA, JVM version, OS, host info in header.
|
||||
- Control benchmark < 50ms on warm; documented as the harness floor.
|
||||
|
||||
**3.3 (DEFERRED) — Memory metric.** Not part of this plan. JVM heap post-`System.gc()` is unreliable; `TestComposeWindow` heap not representative of real Swing/Skia; out of scope.
|
||||
|
||||
**3.4 (DEFERRED) — Warm-boot variant (pre-seeded events.db).** Not part of this plan. None of the in-scope fixes target warm-path code; revisit when a warm-path fix appears.
|
||||
|
||||
#### Phase 4 — Baseline capture
|
||||
|
||||
Run `desktopApp/benchmarks/cold-launch.sh 10` and `./gradlew :desktopApp:jvmTest --tests "WarmLaunchBenchmark"` on a clean `main`. Commit both output files as:
|
||||
|
||||
- `desktopApp/benchmarks/baseline-main-cold.txt`
|
||||
- `desktopApp/benchmarks/baseline-main-warm.txt`
|
||||
|
||||
Append a "Baseline" section to this plan with numbers. No code change.
|
||||
|
||||
Acceptance: baselines committed, plan updated, deltas on re-run ≤ 15% (median-to-median).
|
||||
|
||||
#### Phase 5 — Targeted fixes
|
||||
|
||||
Each lands in its own PR with before/after numbers in the description.
|
||||
|
||||
**5.1 — Icon decode**
|
||||
|
||||
Files: `desktopApp/src/jvmMain/.../Main.kt:218, 302, 988`.
|
||||
|
||||
Top-level shared lazy:
|
||||
|
||||
```kotlin
|
||||
// desktopApp/src/jvmMain/.../IconResources.kt (new)
|
||||
val DesktopAppIcon: BufferedImage by lazy {
|
||||
requireNotNull(IconResources::class.java.getResourceAsStream("/icon.png")) {
|
||||
"icon.png not found"
|
||||
}.use { ImageIO.read(it) }
|
||||
}
|
||||
```
|
||||
|
||||
Replace all three sites. Default `lazy` mode (`SYNCHRONIZED`) is fine — three call sites span main + AWT threads.
|
||||
|
||||
**Microbench (separate from end-to-end):** add `IconResourcesBenchmark` running `ImageIO.read` N=1000 times to measure absolute decode cost. Report savings independently in case `InProcessWebSocket` floor (perf reviewer #6) hides the delta on `t_n_events`.
|
||||
|
||||
Unit test: `IconResourcesTest` asserts `ImageIO.read` invoked once per process (counter wrapper around `getResourceAsStream`).
|
||||
|
||||
Re-run benchmark: expected delta = (decode_cost × 2). Microbench delta = decode_cost × 999.
|
||||
|
||||
**5.2 — Feed bootstrap relay gate**
|
||||
|
||||
Files: `desktopApp/src/jvmMain/.../Main.kt:1283-1326, 1330` (home + DM subs).
|
||||
|
||||
**Investigation step (in the PR):** check whether `RelayPool` already queues REQs pre-connect (`quartz/.../RelayPool.kt`). Two outcomes:
|
||||
|
||||
- **(a) Pool queues:** delete the `connectedRelays.first { isNotEmpty() }` gate entirely. Subscriptions fire eagerly; pool flushes on connect. **Preferred.**
|
||||
- **(b) Pool does not queue:** change predicate to `connectedRelays.first { any { it.state in setOf(CONNECTING, CONNECTED) } }`. Lower-risk fallback.
|
||||
|
||||
Reject (c) `combine + debounce(50ms)` — adds artificial 50ms to the critical path we're shortening (arch reviewer).
|
||||
|
||||
**Single shared helper** between home + DM gate (arch reviewer flag) — both must point at the same logic to avoid drift.
|
||||
|
||||
New tests (spec-flow + perf): all use Phase 2 fixture relay infrastructure.
|
||||
|
||||
- `slowRelayDoesNotStallFeed()` — `FixtureNostrServer(responseDelay = 1.seconds)`; bootstrap REQ flushes within 1.1s, not 30s.
|
||||
- `noRelaysAvailableShowsErrorState()` — empty relay list; assert UI reports error within ≤ 10s.
|
||||
- `relayListArrivesLateStartsSubscriptionThen()` — relay list emitted after `loadSavedAccount` returns; sub fires post-emit, not pre-emit.
|
||||
- `relaysAddedMidLoadDoNotDoubleSubscribe()` — flip relay list during boot; verify single REQ.
|
||||
|
||||
Re-run benchmark: expected delta = large on `t_first_event` and `t_n_events` (gate currently blocks critical path).
|
||||
|
||||
**5.3 (CUT)** — sequential `remember` chain refactor. Out of scope this plan. If Phase 4 baseline shows `MainContent` composition cost > 50ms, spawn `desktopApp/plans/<date>-feat-mainContent-state-holder-refactor-plan.md` separately. Reason: scope creep risk + composite-holder choice depends on consumer audit.
|
||||
|
||||
---
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
**B — Vertical slice per fix.** Per-bottleneck test+bench+fix. Rejected: contradicts "testing before refactor" priority; sequential `remember` lacks broader safety net. (see brainstorm § "Approach B")
|
||||
|
||||
**C — Instrument-first, fix-later.** Markers behind a flag; tests + refactors split into follow-ups. Rejected: same reason. (see brainstorm § "Approach C")
|
||||
|
||||
**Macrobenchmark / Android-first.** `androidx.benchmark.macro.StartupTimingMetric`. Deferred to a separate plan. (see brainstorm § "Q1 Platform Priority")
|
||||
|
||||
**Real WebSocket loopback to a test relay (Docker).** Rejected: network jitter + container startup; in-process is deterministic.
|
||||
|
||||
**JMH for benchmarks.** Rejected: doesn't compose with `createComposeRule`. Used independently for the icon microbench in Phase 5.1.
|
||||
|
||||
**Fakes in `:commons` testFixtures (original plan).** Rejected per arch review: `:commons` is KMP, unproven with `java-test-fixtures` here; `:quartz` already proves the pattern works.
|
||||
|
||||
**`testFixtures` for fakes vs hand-rolled constants.** Hand-rolled would re-invent `Filter.match`; `FixtureNostrServer` reuses existing logic. Keep testFixtures route.
|
||||
|
||||
**CompositionLocal `Instrumentation` interface.** Rejected per arch review: would add production API surface for a single test concern.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
Phase 1-4: production interaction graph **unchanged**. Tests observe production flows.
|
||||
|
||||
Phase 5 changes:
|
||||
|
||||
- **5.1 icon decode**: three `ImageIO.read` calls collapse to one `lazy`. `Coil`, Compose `Image`, `DesktopImageLoaderSetup` untouched. Thread safety: `lazy` SYNCHRONIZED default.
|
||||
- **5.2 feed bootstrap gate**: `RelayConnectionManager` → `NostrClient` → `RelayPool` chain unchanged. The gating predicate in `Main.kt:1283-1326` shifts (or is removed). DM sub at `Main.kt:1330` uses same helper.
|
||||
|
||||
### Error & Failure Propagation
|
||||
|
||||
- Test errors: `LaunchMarkers.snapshot()` exposes recorded markers so far on any test failure for diagnostic clarity.
|
||||
- Fixture errors: `FixtureNostrServer` throws on unknown filter and on malformed line at load — no silent empty EOSE.
|
||||
- Production 5.2: REQ queued pre-connect (case a) relies on `RelayPool` retry semantics; verify before merge.
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
- Test isolation: each iteration uses fresh temp `homeDir` (cleaned via `@After` + `Files.walk(...).sorted(reverseOrder()).forEach(Files::delete)`).
|
||||
- `Preferences.userRoot()` write in `LocalRelayMaintenance`: tests don't call `start()`. Production code path unchanged.
|
||||
- Marker registry: single-threaded; benchmark suite serialized via Gradle `maxParallelForks = 1` filter on `*LaunchBenchmark*`.
|
||||
- Fixture file stale: re-capture is manual; PR includes new numbers.
|
||||
- Phase 5.2 risk: subscription firing before relay list emit — explicit `relayListArrivesLateStartsSubscriptionThen` test pins behavior.
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
- `WebsocketBuilder` already stable across `RelayConnectionManager`, `NostrClient`, `RelayPool`. Test substitution touches no production code.
|
||||
- `LocalRelayStore` ctor change is additive (default param). Existing callers compile unchanged.
|
||||
- `NoteCardTags`/`testTag` additive; KDoc states "stable identifier for UI tests; do not key behavior off this."
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
Five scenarios unit tests with mocks won't catch:
|
||||
|
||||
1. **Cold boot ViewOnly → 10 events visible against fixture** — primary benchmark; end-to-end through AccountManager + LocalRelayStore + RelayConnectionManager + NostrClient + LocalCache + FeedScreen.
|
||||
2. **Slow relay** (Phase 5.2 test) — fixture delays REQ response by 1s; assert subscription completion within 1.1s post-fix.
|
||||
3. **Relay list arrives late** — exposes ordering bug if Phase 5.2 case (a) fires REQ before list populated.
|
||||
4. **No relays available** — empty list; UI reports error rather than hang.
|
||||
5. **Logged-out → LoginScreen** — no `accounts.json.enc`.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [x] **Phase 1.1**: 2 tests (ViewOnly happy + decode failure) pass using repo's existing pattern (`backgroundScope.launch(UnconfinedTestDispatcher(testScheduler))` + `advanceUntilIdle()`). _Landed 2026-06-17 — `AccountManagerLoadStateTransitionsTest` (commit `ff55898ab`)._
|
||||
- [x] **Phase 1.2**: 5 `LocalRelayStoreHydrationTest` cases pass. _Landed 2026-06-17 (commit `ff55898ab`)._
|
||||
- [x] **Phase 1.3**: `LocalRelayStore` accepts optional `homeDir`; existing callers unchanged; `LocalRelayMaintenance` untouched. _Landed 2026-06-17 (commit `ff55898ab`)._
|
||||
- [x] **Phase 1.4**: 4 `AppStateMachineTest` Compose UI tests pass via `createComposeRule()` with `MaterialTheme {}` wrap (commit `48a8178c9`). Slot was opened by adding `LaunchTestOverrides` to `App()` so `relayManager` / `localCache` / `localRelayStore` / `torSettings` can be injected, plus a secondary `DesktopRelayConnectionManager(WebsocketBuilder)` ctor that lets tests substitute the in-process fixture relay without relaxing the `LocalRelayManager` composition-local type. Production callers pass `null` and follow the existing `remember { … }` path.
|
||||
- [ ] **Phase 2.1**: `InProcessWebsocketBuilder` in `quartz/src/testFixtures/`; round-trip test passes.
|
||||
- [ ] **Phase 2.2**: `FixtureNostrServer` correctly matches `Filter`, always emits EOSE (incl. empty match), fails fast on malformed JSONL via `FixtureParseException`, per-connection Mutex.
|
||||
- [ ] **Phase 2.3**: `fiatjaf-50.jsonl` committed under `quartz/src/testFixtures/resources/fixtures/launch/`, ≥ 50 events, all valid signatures; README documents recapture commands.
|
||||
- [x] **Phase 2.4**: Phase 1.4 tests run against `LaunchFixtureRelay.open(fixture.events)` wired through the `LaunchTestOverrides.relayManager` field (commit `48a8178c9`).
|
||||
- [ ] **Phase 3.1**: `LaunchMarkers` produces 4 named markers within 10s of a fixture cold boot. `NoteCardTags.ROOT` in `commons/commonMain`. NoteCard `Modifier.testTag().onPlacedHook()` added; production cost ≤ 1 SemanticsModifier allocation per card.
|
||||
- [ ] **Phase 3.2**: Cold harness (shell-script, fork per sample, N=10) and warm harness (single JVM, N=20, discard 5 warmup) both run reproducibly. Pinned JVM flags. Output headers include git SHA, JVM/OS/arch. Control benchmark (`setContent { Box {} }`) reports harness floor.
|
||||
- [ ] **Phase 4**: `desktopApp/benchmarks/baseline-main-cold.txt` and `-warm.txt` committed; plan updated with numbers. Re-run delta ≤ 15% median-to-median.
|
||||
- [x] **Phase 5.1**: icon decoded exactly once per process (unit test); microbench delta reported; end-to-end delta reported. _Code + unit test landed 2026-06-17 (commit `b338d7db4`). Delta numbers pending Phase 3 benchmark harness._
|
||||
- [x] **Phase 5.2**: investigation chose candidate (a) — `NostrClient`/`RelayPool` queue REQs pre-connect (verified by `SubscribeBeforeConnectTest`). The bootstrap gate at `Main.kt:1242` is removed and the subscription fires eagerly. Two of the four originally-named regression tests landed via `AppStateMachineTest` (commit `48a8178c9`) — `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` covers the "no relays available" scenario, and `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` covers the "no double-subscribe" scenario. The other two named cases (`slowRelay`, `relayListArrivesLate`) are now trivial to add on top of the same harness if a future regression motivates them, but were not necessary for the in-scope invariant. The DM gate at `Main.kt:1290` is left untouched in this session (separate code path through `subscriptionsCoordinator`).
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] Warm benchmark variance ≤ 15% on `t_n_events` (median-to-median across 5 runs of the suite).
|
||||
- [ ] `InProcessWebsocketBuilder` adds ≤ 50ms RTT vs direct in-process call.
|
||||
- [ ] Cold harness total wall clock ≤ 4 min, warm ≤ 2 min, on a developer M1/M2/M3 laptop.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] All new tests pass `./gradlew :desktopApp:jvmTest` and `./gradlew :quartz:test`.
|
||||
- [ ] Existing tests unaffected.
|
||||
- [ ] `./gradlew spotlessApply` clean. `.spotless/copyright.kt` header on every new `.kt`.
|
||||
- [ ] Pre-commit hook passes without `--no-verify`.
|
||||
- [ ] Each Phase 5 PR includes before/after numbers table in description.
|
||||
- [ ] No new `runBlocking { ... }` calls in launch path.
|
||||
- [ ] No production code references `LaunchMarkers`.
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
Per brainstorm (Q4 + Q6) with deepen-plan adjustments:
|
||||
|
||||
| Metric | What | Reporting |
|
||||
|--------|------|-----------|
|
||||
| `t_first_composition_apply` | First `setContent` reaches idle (NOT real Skia frame) | ms, cold + warm |
|
||||
| `t_account_logged_in` | `AccountState.LoggedIn` emitted | ms, cold + warm |
|
||||
| `t_first_event` | First `NoteCard.onPlaced` fires | ms, cold + warm |
|
||||
| `t_n_events` (N=10) | 10th `NoteCard.onPlaced` fires | ms, cold + warm — **headline** |
|
||||
|
||||
(Memory + warm-boot variant DEFERRED.)
|
||||
|
||||
Cold = shell-script, fork per sample, N=10, no warmup. Warm = single JVM, N=20, discard first 5. Report **median + IQR + min**. For fix deltas, **Mann-Whitney U** with Cliff's delta > 0.33 threshold (perf reviewer).
|
||||
|
||||
**Phase 5 success:** each fix produces positive delta on `t_n_events` (or for 5.1, on the microbench if end-to-end is below the `InProcessWebSocket` floor). Sum target ≥ 20% reduction in cold `t_n_events` — aspirational, actual depends on baseline.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
### Risks (prioritized)
|
||||
|
||||
**Risk #1 (mitigated) — `:quartz` + `java-test-fixtures` setup.** `:quartz` already consumes geode testFixtures (`quartz/build.gradle.kts:352-358`). Producing fixtures from `:quartz` requires the `java-test-fixtures` plugin on `:quartz` itself, which is a separate enablement. **Mitigation**: time-box to 1 hour; fall back to `:desktopApp:jvmTest` directly if Gradle friction blocks.
|
||||
|
||||
**Risk #2 (acknowledged) — `runComposeUiTest` / `createComposeRule` doesn't capture real Swing/Skia render time.** Headline metric `t_first_composition_apply` measures composition only, not paint. **Mitigation**: explicit "out-of-scope: GPU/paint timing" disclosure in benchmark output header. Real-window benchmark named as follow-up: `desktopApp/plans/<future>-real-window-launch-benchmark.md`.
|
||||
|
||||
**Risk #3 — Fixture staleness.** Commit as immutable; recapture explicit human action only; PR includes new numbers.
|
||||
|
||||
**Risk #4 — Phase 5.2 eager subscription causes double-subscribe on relay list reorder.** Test: `relaysAddedMidLoadDoNotDoubleSubscribe`.
|
||||
|
||||
**Risk #5 — `InProcessWebSocket` floor swallows fix delta.** Microbench for icon decode in 5.1; Mann-Whitney U test for significance.
|
||||
|
||||
**Risk #6 — Cold harness fork cost (~25s/sample × N=10 = ~4 min).** Acceptable for local-manual runs; document in README.
|
||||
|
||||
**Risk #7 — JVM/OS/arch variation across developers.** Output header records env; cross-machine numbers informational only.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `quartz`: `InProcessWebSocket`, `NostrServer`, `Filter`, `WebsocketBuilder` — all exist.
|
||||
- `commons`: `EventStore`, `DesktopLocalCache` — exist.
|
||||
- `desktopApp`: `AccountManager`, `LocalRelayStore`, `RelayConnectionManager`, `App()` — exist with needed seams post 1.3.
|
||||
- Compose UI test: `compose.desktop.uiTestJUnit4` on classpath (`desktopApp/build.gradle.kts:87`).
|
||||
- Gradle `java-test-fixtures` plugin (proven in `:geode`; needs enablement on `:quartz`).
|
||||
|
||||
---
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
Solo engineer, rough sizing:
|
||||
|
||||
| Phase | Effort |
|
||||
|-------|--------|
|
||||
| 1.1 | ~half day |
|
||||
| 1.2 | ~half day |
|
||||
| 1.3 | ~hour |
|
||||
| 1.4 | ~day |
|
||||
| 2.1 | ~hour |
|
||||
| 2.2 | ~half day |
|
||||
| 2.3 | ~hour (manual fixture capture + README) |
|
||||
| 2.4 | ~hour |
|
||||
| 3.1 | ~half day |
|
||||
| 3.2 | ~day (two harnesses + report.py) |
|
||||
| 4 | ~hour |
|
||||
| 5.1 | ~hour + microbench |
|
||||
| 5.2 | ~day (investigation + refactor + 4 tests) |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Real-render benchmark** (`desktopApp/plans/<future>-real-window-launch-benchmark.md`) — `application { Window { App() } }` with file-marker IPC. Catches GPU regressions.
|
||||
- **Phase 5.3 follow-up** (`desktopApp/plans/<future>-feat-maincontent-state-holder-refactor-plan.md`) — only if Phase 4 baseline shows `MainContent` cost > 50ms.
|
||||
- **Android port** (brainstorm Q14) — separate brainstorm, `androidx.benchmark.macro.StartupTimingMetric`.
|
||||
- **Memory metric** — add after baseline shows it's worth measuring; pin GC mode, parse JFR.
|
||||
- **Larger fixture for stress (`fiatjaf-5000.jsonl`)** — catches O(n²) regressions; defer until needed.
|
||||
- **CI integration** — track baseline on `main` for trend visibility (no PR gating).
|
||||
- **Tor cold boot** — measure separately; out of scope (Tor splash gate is shutdown-shape, not boot-shape).
|
||||
|
||||
---
|
||||
|
||||
## Documentation Plan
|
||||
|
||||
- This plan — primary reference.
|
||||
- `quartz/src/testFixtures/resources/fixtures/launch/README.md` — fixture recapture commands.
|
||||
- `desktopApp/benchmarks/README.md` — how to run benchmarks, interpret numbers, known caveats.
|
||||
- KDoc on `LaunchMarkers`, `InProcessWebsocketBuilder`, `FixtureNostrServer`, `NoteCardTags`.
|
||||
- Plan update after Phase 4 with baselines.
|
||||
- Plan update after Phase 5.1 and 5.2 with deltas.
|
||||
|
||||
---
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm:** [`docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`](../../docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md). Key decisions carried forward: Approach A (foundation-first); Desktop-first / Android deferred; layered pyramid; in-process fake relay; fresh-boot fixture; N=10 events visible as headline; local-only manual runs; cold + warm reported separately; real-world fixture; 3 in-scope fixes (now 2 in-scope, 1 deferred).
|
||||
|
||||
### Internal References
|
||||
|
||||
- `desktopApp/src/jvmMain/.../Main.kt:218,302,988` — 3× ImageIO.read of /icon.png.
|
||||
- `desktopApp/src/jvmMain/.../Main.kt:1283-1330` — home + DM subscriptions gated on first connected relay.
|
||||
- `desktopApp/src/jvmMain/.../Main.kt:834` — production `WebsocketBuilder` injection site.
|
||||
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:71-86` — `AccountState`.
|
||||
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:240-266` — `loadSavedAccount`.
|
||||
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:676-695` — `loadReadOnlyAccount`.
|
||||
- `desktopApp/src/jvmMain/.../relay/LocalRelayStore.kt:38-103,115-161` — store ctor + hydrate.
|
||||
- `desktopApp/src/jvmMain/.../network/RelayConnectionManager.kt:56-58` — `open class` taking `WebsocketBuilder`.
|
||||
- `desktopApp/src/jvmMain/.../network/DesktopRelayConnectionManager.kt:30-34`.
|
||||
- `quartz/src/commonMain/.../sockets/WebsocketBuilder.kt:25-30`.
|
||||
- `quartz/src/commonMain/.../relay/server/inprocess/InProcessWebSocket.kt:55`.
|
||||
- `quartz/src/commonMain/.../relay/client/NostrClient.kt:80-82`.
|
||||
- `quartz/src/commonMain/.../relay/filters/Filter.kt:51-61`.
|
||||
- `desktopApp/src/jvmTest/.../ui/DesktopLaunchSmokeTest.kt:24,57,58,69` — existing UI test pattern.
|
||||
- `desktopApp/src/jvmTest/.../account/AccountManagerStateTransitionTest.kt:73-95` — existing state-transition test pattern.
|
||||
- `desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97` — testTag/onPlaced injection site.
|
||||
- `quartz/build.gradle.kts:352-358` — `:quartz` consuming `testFixtures(project(":geode"))`.
|
||||
- `geode/build.gradle.kts:7,35-37,81-83` — `java-test-fixtures` precedent.
|
||||
- `.spotless/copyright.kt` — required header for new files.
|
||||
|
||||
### Related Work
|
||||
|
||||
- `docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md` — adjacent viewport-aware metadata.
|
||||
- `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md` — `LocalRelayStore` infrastructure this plan reuses.
|
||||
|
||||
### External References
|
||||
|
||||
- Compose UI test: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
|
||||
- kotlinx-coroutines test: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/
|
||||
- Gradle `java-test-fixtures`: https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user