mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge branch 'main' into codex/chase-nip5d-naps
Brings the NIP-5D napplet-host alignment up to current main, which had moved
98 commits ahead of the branch point (d3bd7a45b9). No conflicts: only one
commit on main touched napplet files in that window, and it was a broad
suspend-chain refactor rather than napplet work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,12 @@ env:
|
||||
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
|
||||
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
|
||||
# embeds the AppDir as-is, which is what we actually want.
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
#
|
||||
# Both arch binaries come from the same appimagetool release so their SHA256
|
||||
# values move in lockstep on version bumps.
|
||||
APPIMAGETOOL_VERSION: '1.9.0'
|
||||
APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -38,11 +42,32 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
|
||||
# ubuntu-24.04-arm label is a standard free public-repo runner as of
|
||||
# early 2025). Windows arm64 uses windows-11-arm, added to the free
|
||||
# public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64).
|
||||
# jpackage / jlink / Compose Multiplatform 1.11 all produce
|
||||
# host-native artifacts — no cross-compilation needed.
|
||||
#
|
||||
# The arm64 Windows leg builds the portable .zip ONLY — no MSI.
|
||||
# jpackage --type msi shells out to WiX 3's heat/candle/light, and the
|
||||
# windows-11-arm runner image ships no WiX (the windows-latest image
|
||||
# has WiX 3.14 preinstalled, which is why the x64 leg can package an
|
||||
# MSI). Installing it here would mean pulling an archived, x86-only
|
||||
# toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped
|
||||
# the candle/light CLI that JDK 21's jpackage requires) into the job
|
||||
# that publishes signed release assets. The portable zip is the
|
||||
# documented Windows install path for amy/geode already, so arm64
|
||||
# Windows users get that until either the runner image gains WiX or
|
||||
# jpackage learns the WiX 4+ CLI.
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
|
||||
defaults:
|
||||
@@ -93,13 +118,21 @@ jobs:
|
||||
set -euo pipefail
|
||||
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
|
||||
sudo apt-get update && sudo apt-get install -y desktop-file-utils
|
||||
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
|
||||
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
|
||||
# Map runner arch → upstream AppImage suffix (x86_64 / aarch64).
|
||||
case "${{ matrix.arch }}" in
|
||||
x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;;
|
||||
arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;;
|
||||
*) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;;
|
||||
esac
|
||||
URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage"
|
||||
DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage"
|
||||
curl -fsSL --retry 3 "$URL" -o "$DEST"
|
||||
actual=$(sha256sum "$DEST" | awk '{print $1}')
|
||||
if [[ "$actual" != "$EXPECTED_SHA" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
chmod +x "$DEST"
|
||||
|
||||
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
|
||||
# (runtime-version is greped from the manifest so this never drifts).
|
||||
@@ -203,17 +236,32 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
|
||||
# against the bundled JRE under lib/runtime/, NOT the app payload under
|
||||
# lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike
|
||||
# the x64 skiko which only links libGL.so.1), so a minimal aarch64
|
||||
# install without EGL crashes at startup with:
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
if: matrix.family == 'linux' && matrix.arch == 'arm64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Build portable archives (windows + linux-portable)
|
||||
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
ARCH="${{ matrix.arch }}"
|
||||
APP="desktopApp/build/compose/binaries/main-release/app"
|
||||
mkdir -p desktopApp/build/portable
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ )
|
||||
else
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ )
|
||||
fi
|
||||
|
||||
# Flatpak bundle: wraps the same createReleaseDistributable tree the
|
||||
@@ -230,6 +278,17 @@ jobs:
|
||||
PKG="desktopApp/packaging/flatpak"
|
||||
APP_ID="com.vitorpamplona.amethyst.Desktop"
|
||||
OUT="desktopApp/build/flatpak"
|
||||
# AppImage-style arch names for the bundle filename.
|
||||
case "${{ matrix.arch }}" in
|
||||
x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;;
|
||||
arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;;
|
||||
*) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;;
|
||||
esac
|
||||
# Rewrite the arch-specific GStreamer plugin path in the manifest
|
||||
# (checked-in default is x86_64-linux-gnu). Idempotent — the sed only
|
||||
# matches the original triplet.
|
||||
sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
# Inject the AppStream <release> entry for this build (the checked-in
|
||||
# metainfo deliberately carries none — CI is the source of truth).
|
||||
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
|
||||
@@ -241,7 +300,7 @@ jobs:
|
||||
"${OUT}/build-dir" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
flatpak build-bundle "${OUT}/repo" \
|
||||
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
|
||||
"${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \
|
||||
"$APP_ID" \
|
||||
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
ls -la "$OUT"
|
||||
@@ -325,8 +384,17 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types
|
||||
# and jpackageMsi for a CLI is deferred (the portable zip is the
|
||||
# documented Windows install path). The launcher script writes both
|
||||
# `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs
|
||||
# under bash on GH windows runners (git-bash is on PATH). collect_cli_assets
|
||||
# zips the image on Windows instead of tar.gz.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -574,8 +642,16 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is
|
||||
# deferred (portable zip covers the primary use — operators still
|
||||
# deploy geode via the Docker image or the tarball on Linux). The
|
||||
# image writes both `bin/geode` (sh) and `bin/geode.bat`, and the
|
||||
# smoke test below runs under bash on the windows runner.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -630,12 +706,23 @@ jobs:
|
||||
# module list is complete for the real relay path (Ktor CIO + SQLite +
|
||||
# NIP-11 serialization) — a too-tight module list links fine but fails
|
||||
# here with NoClassDefFound instead of on an operator's machine.
|
||||
#
|
||||
# On the Windows legs we invoke bin/geode.bat instead of bin/geode. The
|
||||
# tmp path also differs between git-bash on Windows (which resolves /tmp
|
||||
# to a mingw path that curl -o accepts) and POSIX runners; kept identical
|
||||
# because the workflow's `defaults.run.shell: bash` uses git-bash on
|
||||
# Windows and /tmp is a valid mingw path there.
|
||||
- name: Smoke-test the geode image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="geode/build/geode-image/geode"
|
||||
"$IMG/bin/geode" --version
|
||||
"$IMG/bin/geode" --port 17447 &
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
LAUNCHER="$IMG/bin/geode.bat"
|
||||
else
|
||||
LAUNCHER="$IMG/bin/geode"
|
||||
fi
|
||||
"$LAUNCHER" --version
|
||||
"$LAUNCHER" --port 17447 &
|
||||
PID=$!
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
|
||||
@@ -49,9 +49,17 @@ jobs:
|
||||
# package, installs it, and verifies the process stays alive for 10s.
|
||||
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
|
||||
# (java.management, java.prefs), and native lib bundling issues.
|
||||
#
|
||||
# Runs on both x64 and arm64 hosted runners so release-time arm64 breakage
|
||||
# (e.g. ProGuard rules missing an arch-specific reflection root) is caught
|
||||
# at PR time instead of on the tag build.
|
||||
# -------------------------------------------------------------------------
|
||||
release-deb-launch:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -139,5 +147,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Release DEB (smoke-tested)
|
||||
# Artifact names must be unique across a run — disambiguate per arch.
|
||||
name: Release DEB (smoke-tested, ${{ matrix.os }})
|
||||
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
+33
-12
@@ -35,7 +35,12 @@ All platforms:
|
||||
Platform-specific:
|
||||
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`.
|
||||
Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner
|
||||
and produce the portable `.zip` only — that image ships no WiX, so CI cannot
|
||||
package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with
|
||||
WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries
|
||||
themselves are x86 and run under emulation).
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
|
||||
`flatpak-builder` for the Flatpak bundle (see
|
||||
@@ -57,9 +62,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified):
|
||||
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
|
||||
sudo apt-get install -y desktop-file-utils
|
||||
|
||||
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
|
||||
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
# createReleaseAppImage picks appimagetool-<arch>.AppImage matching the JVM's
|
||||
# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM).
|
||||
ARCH="$(uname -m)"
|
||||
curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage"
|
||||
chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -110,8 +118,8 @@ are **not** required to build Amethyst from the committed sources.
|
||||
| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` |
|
||||
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
|
||||
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-<arch>.AppImage` (x86_64 or aarch64, from host) |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-<arch>.flatpak` (CI; x86_64 or aarch64) |
|
||||
| Windows `.zip` portable | See below (inline `7z`) | — |
|
||||
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
|
||||
|
||||
@@ -325,14 +333,27 @@ Quartz library in one pipeline.
|
||||
|
||||
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
|
||||
4. **Verify** — the GH Release should hold **31 assets**:
|
||||
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
|
||||
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
|
||||
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
|
||||
configured, so macOS ships arm64-only.
|
||||
4. **Verify** — the GH Release should hold **47 assets**:
|
||||
- **14 desktop**, one per matrix leg × format:
|
||||
- macOS arm64: `dmg` (1)
|
||||
- Windows x64: `msi` + portable `zip` (2)
|
||||
- Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below
|
||||
- Linux x64 / arm64: `deb` + `rpm` (4)
|
||||
- Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6)
|
||||
|
||||
There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile
|
||||
and no Intel runner leg is configured, so macOS ships arm64-only.
|
||||
There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to
|
||||
WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image
|
||||
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
|
||||
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
|
||||
jpackage learns the WiX 4+ `wix build` CLI.
|
||||
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
|
||||
F-Droid `.apks` set built for Accrescent.
|
||||
- **5 amy** + **5 geode** bundles.
|
||||
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
|
||||
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
|
||||
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
|
||||
- **10 geode** — same shape as amy.
|
||||
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
|
||||
- Android flow unchanged
|
||||
|
||||
|
||||
@@ -723,6 +723,7 @@ class AppModules(
|
||||
torManager.status,
|
||||
client,
|
||||
applicationIOScope,
|
||||
onTrigger = { cause -> resourceUsage.add(UsageKeys.relayTrigger(cause), 1) },
|
||||
)
|
||||
|
||||
// Verifies and inserts in the cache from all relays, all subscriptions
|
||||
|
||||
@@ -150,6 +150,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
@@ -955,6 +956,9 @@ class Account(
|
||||
*/
|
||||
fun applyBottomBarItems(items: List<BottomBarEntry>): Boolean = settings.changeBottomBarItems(items)
|
||||
|
||||
/** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */
|
||||
fun applyHiddenDrawerItems(items: Set<NavBarItem>): Boolean = settings.changeHiddenDrawerItems(items)
|
||||
|
||||
suspend fun toggleChatroomPin(room: ChatroomKey) {
|
||||
settings.toggleChatroomPin(room)
|
||||
sendNewAppSpecificData()
|
||||
@@ -3559,6 +3563,9 @@ class Account(
|
||||
refreshConcordChannelIndex()
|
||||
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
|
||||
runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
|
||||
// A promotion to staff delivers the Control Plane write key inside the Grant
|
||||
// itself (CORD-04 §3), so the fold that seats the role is also when it arrives.
|
||||
runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) }
|
||||
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
|
||||
// found by re-resolving the invite link we joined through. Rate-limited internally.
|
||||
runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
|
||||
|
||||
@@ -24,20 +24,29 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -68,6 +77,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||
*/
|
||||
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
|
||||
*
|
||||
* 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44
|
||||
* encryptions at the ceiling — heavy but survivable on a phone, and far above any real community.
|
||||
* Raising it raises the cost of the attack it exists to bound, not the safety.
|
||||
*/
|
||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||
|
||||
/**
|
||||
* Concord (encrypted communities) orchestration for an [Account]: join/create/
|
||||
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
|
||||
@@ -138,6 +156,10 @@ class AccountConcordActions(
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
// The creator is the founding staff member (CORD-02 §2): it keeps the write
|
||||
// secret and publishes only the derived pubkey to everyone else.
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
relays = relayUrls,
|
||||
name = name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
@@ -159,6 +181,21 @@ class AccountConcordActions(
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return null
|
||||
// CREATE_INVITE, and not while banned. This used to check only that we held the community,
|
||||
// which made minting the one moderation-free action in the app: a member the owner had just
|
||||
// banned could tap the invite button and hand out a working link to the community they were
|
||||
// removed from, and every account they invited arrived as a fresh un-banned npub.
|
||||
//
|
||||
// Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control
|
||||
// entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published
|
||||
// OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is.
|
||||
// The owner is proven by the community id (CORD-02), so they are read off the entry and can
|
||||
// mint before the session exists — the session is built asynchronously off the joined list,
|
||||
// and requiring it here would have made the owner's own invite button fail on a cold start.
|
||||
// Everyone else needs the folded roster, so no session means no invite.
|
||||
val session = account.concordSessions.sessionFor(communityId)
|
||||
val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true)
|
||||
if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null
|
||||
val invite =
|
||||
ConcordActions.inviteFor(
|
||||
communityIdHex = entry.id,
|
||||
@@ -168,6 +205,9 @@ class AccountConcordActions(
|
||||
rootEpoch = entry.rootEpoch,
|
||||
name = entry.name,
|
||||
relays = entry.relays,
|
||||
// The joiner can never derive the Control Plane address, so the bundle carries
|
||||
// it (CORD-05 §1). Null on a legacy community, which has none to carry.
|
||||
controlPk = entry.controlPk,
|
||||
)
|
||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
|
||||
|
||||
@@ -245,6 +285,9 @@ class AccountConcordActions(
|
||||
ownerSalt = bundle.ownerSalt,
|
||||
root = bundle.communityRoot,
|
||||
rootEpoch = bundle.rootEpoch,
|
||||
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
|
||||
// community is still pre-split, so we fold it at the legacy address.
|
||||
controlPk = bundle.controlPk,
|
||||
relays = bundle.relays,
|
||||
name = bundle.name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
@@ -412,7 +455,17 @@ class AccountConcordActions(
|
||||
channelIdHex: String,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return
|
||||
// A ban hides every message we send, so continuing to announce that we are typing them is
|
||||
// both noise and a contradiction of what the ban told the room. Filtered on the receive side
|
||||
// too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending.
|
||||
if (session.state.value
|
||||
?.authority
|
||||
?.isBanned(account.signer.pubKey) == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
val entry = session.entry
|
||||
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
@@ -451,6 +504,67 @@ class AccountConcordActions(
|
||||
// every client's AuthorityResolver, so a call by someone who doesn't outrank the
|
||||
// target is simply dropped on fold. Owner-authored calls always take effect.
|
||||
|
||||
/**
|
||||
* The Control Plane keys for a moderation write, or null when this account cannot
|
||||
* publish there: on a split epoch only `control_root` holders can mint a wrap that
|
||||
* verifies at the plane's address (CORD-02 §2), and wrapping without the secret
|
||||
* throws rather than missigning. Rank and key possession can diverge — a freshly
|
||||
* promoted staffer writes only once their `control_wrap` is adopted (CORD-04 §3),
|
||||
* and the UI gates on rank — so every moderation verb no-ops through this check
|
||||
* instead of crashing on a rank-gated action.
|
||||
*/
|
||||
private fun controlKeysForWrite(session: ConcordCommunitySession): ControlPlaneKeys? {
|
||||
val cp = session.controlPlaneKeys()
|
||||
if (!cp.canWrite) {
|
||||
Log.w("Concord") { "Control write refused for ${session.entry.id}: control_root not held at epoch ${session.entry.rootEpoch} (CORD-02 §2)" }
|
||||
return null
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account may take the action guarded by [bit] in [session] — and, when [target] is
|
||||
* given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal").
|
||||
*
|
||||
* Every moderation verb below funnels through this. It used to live only in the composables that
|
||||
* drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which
|
||||
* ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere
|
||||
* else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` —
|
||||
* a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced.
|
||||
*
|
||||
* Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry]
|
||||
* rather than from the fold, because the community id proves them (CORD-02) and they must stay able
|
||||
* to moderate before their Control Plane has finished folding — or through a fold a rogue has
|
||||
* damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else
|
||||
* anything.
|
||||
*/
|
||||
private fun isAuthorizedFor(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): Boolean {
|
||||
val me = account.signer.pubKey
|
||||
if (session.entry.owner.equals(me, ignoreCase = true)) return true
|
||||
val authority = session.state.value?.authority ?: return false
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone and would let a
|
||||
// banned staffer keep acting for as long as they hold the key.
|
||||
val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit)
|
||||
if (!allowed) {
|
||||
Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" }
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
/** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */
|
||||
private fun controlKeysForAction(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): ControlPlaneKeys? {
|
||||
if (!isAuthorizedFor(session, bit, target)) return null
|
||||
return controlKeysForWrite(session)
|
||||
}
|
||||
|
||||
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
|
||||
suspend fun grantConcordRole(
|
||||
communityId: String,
|
||||
@@ -459,7 +573,23 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val wrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
// A Grant that first makes its member staff must deliver the control_root in the same
|
||||
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
|
||||
// roles carry a Control-writing bit and we hold the secret to hand over.
|
||||
val wrap =
|
||||
ConcordModeration.grantWithStaffDelivery(
|
||||
actor = account.signer,
|
||||
controlPlane = cp,
|
||||
communityId = communityId.hexToByteArray(),
|
||||
member = member,
|
||||
roleIds = roleIds,
|
||||
current = session.controlEditions(),
|
||||
createdAt = TimeUtils.now(),
|
||||
owner = session.entry.owner,
|
||||
controlRoot = session.entry.controlRoot?.hexToByteArray(),
|
||||
epoch = session.entry.rootEpoch,
|
||||
)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -494,11 +624,11 @@ class AccountConcordActions(
|
||||
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
|
||||
if (author == account.signer.pubKey) return null
|
||||
val communityId = channel.channelId.communityId
|
||||
val state =
|
||||
account.concordSessions
|
||||
.sessionFor(communityId)
|
||||
?.state
|
||||
?.value ?: return null
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return null
|
||||
val state = session.state.value ?: return null
|
||||
// Rank alone isn't enough on a split epoch: the Grant edition takes the control_root
|
||||
// (CORD-02 §2), so don't offer an action the verb would refuse.
|
||||
if (!session.controlPlaneKeys().canWrite) return null
|
||||
if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null
|
||||
val adminRoleId =
|
||||
state.roles.entries
|
||||
@@ -515,7 +645,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = session.controlPlaneKey()
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
|
||||
val existing =
|
||||
session.state.value
|
||||
@@ -530,7 +660,22 @@ class AccountConcordActions(
|
||||
roleId.toHexKey()
|
||||
}
|
||||
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
// Admin carries every management bit, so this Grant makes its member staff: it must
|
||||
// deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds
|
||||
// authority it cannot publish under.
|
||||
val grantWrap =
|
||||
ConcordModeration.grantWithStaffDelivery(
|
||||
actor = account.signer,
|
||||
controlPlane = cp,
|
||||
communityId = communityId.hexToByteArray(),
|
||||
member = member,
|
||||
roleIds = listOf(roleIdHex),
|
||||
current = session.controlEditions(),
|
||||
createdAt = TimeUtils.now(),
|
||||
owner = session.entry.owner,
|
||||
controlRoot = session.entry.controlRoot?.hexToByteArray(),
|
||||
epoch = session.entry.rootEpoch,
|
||||
)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
@@ -542,7 +687,8 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val grantWrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
@@ -568,12 +714,11 @@ class AccountConcordActions(
|
||||
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
|
||||
if (author == account.signer.pubKey) return null
|
||||
val communityId = channel.channelId.communityId
|
||||
val authority =
|
||||
account.concordSessions
|
||||
.sessionFor(communityId)
|
||||
?.state
|
||||
?.value
|
||||
?.authority ?: return null
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return null
|
||||
val authority = session.state.value?.authority ?: return null
|
||||
// Rank alone isn't enough on a split epoch: the banlist edition takes the control_root
|
||||
// (CORD-02 §2), so don't offer an action the verb would refuse.
|
||||
if (!session.controlPlaneKeys().canWrite) return null
|
||||
if (authority.isOwner(author)) return null
|
||||
// The owner short-circuits rather than going through canActOn: canActOn starts at
|
||||
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
|
||||
@@ -590,7 +735,8 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val wrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -602,7 +748,8 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val wrap = ConcordModeration.unban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -632,16 +779,32 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
val state = session.state.value ?: return false
|
||||
val authority = state.authority
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN)
|
||||
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
|
||||
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
|
||||
// shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same
|
||||
// ban-aware predicate), but that is a race against banlist propagation, not a check.
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN)
|
||||
if (!iCanBan) return false
|
||||
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
|
||||
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
|
||||
// Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin
|
||||
// cannot Refound a peer admin out of the community any more than they could ban one. The owner
|
||||
// short-circuits, as everywhere else, because canActOn starts at hasPermission.
|
||||
if (!authority.isOwner(account.signer.pubKey) &&
|
||||
removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) }
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// A Refounding writes the current plane (the pre-rotation bans) and the new one (the
|
||||
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
|
||||
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
|
||||
// 1. Ban the removed members on the current Control Plane so the compacted snapshot —
|
||||
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
|
||||
// first, so each subsequent edition chains onto the updated banlist head.
|
||||
for (target in removedLower) {
|
||||
val banWrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, banWrap)
|
||||
}
|
||||
|
||||
@@ -662,22 +825,34 @@ class AccountConcordActions(
|
||||
.apply {
|
||||
removeAll(removedLower)
|
||||
removeAll(authority.bannedMembers())
|
||||
}.toList()
|
||||
}.let { candidates -> boundRecipients(candidates, authority) }
|
||||
|
||||
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
|
||||
val entry = session.entry
|
||||
val newRoot = RandomInstance.bytes(32)
|
||||
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2),
|
||||
// so a demoted staffer's retained secret dies with the epoch — and a legacy community
|
||||
// upgrades to the split as a side effect of its next ban (CORD-06 §3).
|
||||
val newControlRoot = RandomInstance.bytes(32)
|
||||
// The staff set the new secret goes to: the owner plus everyone holding a
|
||||
// Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other
|
||||
// recipient the 104-byte one carrying the pubkey alone. (The builder mints a
|
||||
// blob per recipient, so staff who aren't recipients are simply never reached.)
|
||||
val staff = authority.staffMembers()
|
||||
val build =
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = account.signer,
|
||||
communityId = communityId,
|
||||
priorRoot = entry.root.hexToByteArray(),
|
||||
newRoot = newRoot,
|
||||
newControlRoot = newControlRoot,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
priorControlWraps = session.controlPlaneWraps(),
|
||||
priorControlKey = session.controlPlaneKey(),
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = staff,
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = entry.owner,
|
||||
)
|
||||
|
||||
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
|
||||
@@ -690,10 +865,53 @@ class AccountConcordActions(
|
||||
|
||||
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and
|
||||
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
|
||||
adoptConcordRoot(entry, newRoot, build.newEpoch)
|
||||
adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps the Refounding recipient set, keeping the members whose standing we can actually vouch
|
||||
* for when there are too many.
|
||||
*
|
||||
* `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are
|
||||
* unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every
|
||||
* author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway
|
||||
* npub someone posts from, or simply announces, becomes one more mandatory blob in the next
|
||||
* Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only
|
||||
* hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`.
|
||||
*
|
||||
* The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be
|
||||
* padded from outside. The remainder fills the budget, and anything dropped is **logged rather
|
||||
* than silently truncated** — a dropped member is stranded on the dead epoch and their only way
|
||||
* back is a recovery path that needs to know it happened.
|
||||
*/
|
||||
private fun boundRecipients(
|
||||
candidates: Set<HexKey>,
|
||||
authority: AuthorityResolver,
|
||||
): List<HexKey> {
|
||||
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
|
||||
|
||||
// The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it
|
||||
// cannot be padded from outside, and dropping an admin to make room for a stranger inverts
|
||||
// the point of the cap.
|
||||
val vouched = authority.roleHolders() + authority.staffMembers()
|
||||
val kept = LinkedHashSet<HexKey>()
|
||||
candidates.filterTo(kept) { it in vouched }
|
||||
for (candidate in candidates) {
|
||||
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
|
||||
kept.add(candidate)
|
||||
}
|
||||
val dropped = candidates.size - kept.size
|
||||
if (dropped > 0) {
|
||||
Log.w("Concord") {
|
||||
"Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " +
|
||||
"(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " +
|
||||
"stranded on the prior epoch"
|
||||
}
|
||||
}
|
||||
return kept.toList()
|
||||
}
|
||||
|
||||
// Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered
|
||||
// in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not
|
||||
// adopted — and re-published — twice on successive revision ticks.
|
||||
@@ -710,9 +928,14 @@ class AccountConcordActions(
|
||||
entry: ConcordCommunityListEntry,
|
||||
newRoot: ByteArray,
|
||||
newEpoch: Long,
|
||||
newControlPk: ByteArray? = null,
|
||||
newControlRoot: ByteArray? = null,
|
||||
) {
|
||||
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch }
|
||||
// The epoch we're leaving is banked with the address it was folded at, so its Control
|
||||
// Plane stays subscribable for the anti-rollback floor (a split epoch's address can
|
||||
// never be re-derived, only remembered — CORD-02 §2).
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
|
||||
val next =
|
||||
ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
@@ -720,6 +943,11 @@ class AccountConcordActions(
|
||||
ownerSalt = entry.ownerSalt,
|
||||
root = newRoot.toHexKey(),
|
||||
rootEpoch = newEpoch,
|
||||
// A rotation that delivered no control material is a legacy, pre-split one
|
||||
// (CORD-06 §3): the new epoch keeps folding at the legacy address, and the
|
||||
// stale prior-epoch values must NOT be carried into it.
|
||||
controlPk = newControlPk?.toHexKey(),
|
||||
controlRoot = newControlRoot?.toHexKey(),
|
||||
heldRoots = held,
|
||||
privateChannels = entry.privateChannels,
|
||||
relays = entry.relays,
|
||||
@@ -769,6 +997,7 @@ class AccountConcordActions(
|
||||
wraps = wraps,
|
||||
baseRekey = session.nextBaseRekeyKey(),
|
||||
recipientSigner = account.signer,
|
||||
communityId = entry.id,
|
||||
priorRoot = entry.root.hexToByteArray(),
|
||||
rootEpoch = entry.rootEpoch,
|
||||
) ?: continue
|
||||
@@ -779,7 +1008,62 @@ class AccountConcordActions(
|
||||
// who has themselves been banned could still rotate the whole community.
|
||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
||||
if (!authorized) continue
|
||||
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
|
||||
adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a `control_root` delivered to us by a staff-making Grant (CORD-04 §3): the
|
||||
* promoting edition carries the secret in `control_wrap`, NIP-44-encrypted under the
|
||||
* granter↔member pairwise key, so promotion and key delivery are one signed edition
|
||||
* with nothing separate to watch an inbox for.
|
||||
*
|
||||
* Adoption is gated twice and fails closed both times. The secret is adopted only if
|
||||
* it derives to exactly the `control_pk` we already hold for the named epoch — a
|
||||
* garbage wrap is attributable griefing, nothing worse — and only from a Grant our own
|
||||
* fold honors, so a rogue cannot feed us a key by minting an edition nobody accepts.
|
||||
* The epoch check matters because compaction re-wraps a Grant head verbatim across
|
||||
* Refoundings, so a folded head can legitimately carry a wrap minted for a prior epoch.
|
||||
*
|
||||
* Idempotent: once the entry holds the secret there is nothing to adopt. Runs on the
|
||||
* revision tick, like the rekey drain.
|
||||
*/
|
||||
internal suspend fun drainConcordStaffGrants() {
|
||||
if (!account.isWriteable()) return
|
||||
val me = account.signer.pubKey.lowercase()
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val entry = session.entry
|
||||
// Already staff at this epoch, or a legacy community with no split to join.
|
||||
val heldControlPk = entry.controlPk
|
||||
if (entry.controlRoot != null || heldControlPk == null) continue
|
||||
val state = session.state.value ?: continue
|
||||
// Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing.
|
||||
if (!state.authority.isStaff(me)) continue
|
||||
|
||||
val myGrantCoordinate =
|
||||
ConcordKeyDerivation
|
||||
.grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray())
|
||||
.toHexKey()
|
||||
val delivered =
|
||||
session
|
||||
.controlEditions()
|
||||
.filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate }
|
||||
// Newest first: a re-issued Grant (a lost key, a head superseded before we
|
||||
// fetched it) carries the fresher wrap.
|
||||
.sortedByDescending { it.version }
|
||||
.firstNotNullOfOrNull { edition ->
|
||||
val wrap = ConcordJson.decodeOrNull<GrantEntity>(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null
|
||||
val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null
|
||||
if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null
|
||||
// Fails closed: a secret that doesn't derive to the pk we hold is dropped,
|
||||
// never adopted — we will not split ourselves off from the plane's readers.
|
||||
if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null
|
||||
opened.controlRoot
|
||||
} ?: continue
|
||||
|
||||
account.sendMyPublicAndPrivateOutbox(
|
||||
account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +1120,29 @@ class AccountConcordActions(
|
||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
|
||||
// A removed member holds the link's unlock token forever, so without this the sweep
|
||||
// walks them straight back into the epoch they were rotated out of — see A2 in
|
||||
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
|
||||
// one whose Control Plane we can still fold.
|
||||
//
|
||||
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
|
||||
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
|
||||
// — so a banned member's own client would have hit that window on cold start and
|
||||
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
|
||||
// means no recovery; the next sweep retries once the roster is known.
|
||||
val authority =
|
||||
account.concordSessions
|
||||
.sessionFor(entry.id)
|
||||
?.state
|
||||
?.value
|
||||
?.authority
|
||||
if (authority == null) {
|
||||
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
|
||||
lastConcordRecoveryCheck.remove(entry.id)
|
||||
continue
|
||||
}
|
||||
val bannedHere = authority.isBanned(account.signer.pubKey)
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
|
||||
@@ -859,8 +1165,9 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
|
||||
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -876,9 +1183,10 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -891,6 +1199,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Carry the standing definition forward and change only the name. A ChannelEntity built from
|
||||
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
|
||||
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
|
||||
@@ -900,7 +1209,7 @@ class AccountConcordActions(
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -913,6 +1222,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Same as rename: preserve the standing flags so a tombstone does not also silently
|
||||
// reclassify the channel it retires.
|
||||
val standing =
|
||||
@@ -921,7 +1231,7 @@ class AccountConcordActions(
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
@@ -501,6 +503,18 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeHiddenDrawerItems(newItems: Set<NavBarItem>): Boolean {
|
||||
// Sanitize on the way in as well as on the way out: a caller must never be able to persist
|
||||
// Settings as hidden, which would leave no route back to the screen that hides rows.
|
||||
val sanitized = DrawerItemVisibility.sanitize(newItems)
|
||||
if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) {
|
||||
syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The selected default spend rail across both NWC wallets and CLINK debits. */
|
||||
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -83,6 +87,7 @@ class AccountSyncedSettings(
|
||||
val navigation =
|
||||
AccountNavigationPreferences(
|
||||
MutableStateFlow(internalSettings.navigation.bottomBarItems),
|
||||
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
@@ -124,7 +129,11 @@ class AccountSyncedSettings(
|
||||
.map { it.id }
|
||||
.sorted(),
|
||||
),
|
||||
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
|
||||
navigation =
|
||||
AccountNavigationPreferencesInternal(
|
||||
navigation.bottomBarItems.value,
|
||||
navigation.hiddenDrawerItems.value.toNames(),
|
||||
),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
@@ -221,6 +230,11 @@ class AccountSyncedSettings(
|
||||
if (navigation.bottomBarItems.value != newBottomBarItems) {
|
||||
navigation.bottomBarItems.tryEmit(newBottomBarItems)
|
||||
}
|
||||
|
||||
val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems))
|
||||
if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) {
|
||||
navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -322,6 +336,8 @@ class AccountMediaPreferences(
|
||||
@Stable
|
||||
class AccountNavigationPreferences(
|
||||
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
|
||||
/** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */
|
||||
val hiddenDrawerItems: MutableStateFlow<Set<NavBarItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
||||
+9
@@ -170,6 +170,15 @@ class AccountNavigationPreferencesInternal(
|
||||
// favorite apps, and individual joined chats/groups). Defaulted so blobs
|
||||
// written before this field existed decode to the app's current defaults.
|
||||
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
|
||||
// The drawer (side menu) rows the user switched off, as NavBarItem *names*.
|
||||
// Empty by default, which is what makes a newly shipped destination visible
|
||||
// to everyone without a migration — see DrawerItemVisibility.
|
||||
//
|
||||
// Stored as strings rather than the enum on purpose: an id written by a
|
||||
// newer client would fail the enum decoder and take the whole synced-settings
|
||||
// blob down with it, so unknown names are dropped on read instead (the same
|
||||
// approach AccountPoWPreferencesInternal.enabledCategories takes).
|
||||
var hiddenDrawerItems: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
* needing the full [LocalCache] API.
|
||||
*/
|
||||
interface Dao {
|
||||
fun getOrCreateUser(hex: HexKey): User
|
||||
fun getOrCreateUser(pubkey: HexKey): User
|
||||
|
||||
fun getOrCreateNote(hex: HexKey): Note
|
||||
|
||||
|
||||
@@ -685,12 +685,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
|
||||
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
|
||||
|
||||
override fun getOrCreateUser(hex: HexKey): User {
|
||||
require(isValidHex(key = hex)) { "$hex is not a valid hex" }
|
||||
override fun getOrCreateUser(pubkey: HexKey): User {
|
||||
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
|
||||
// Pass `this` as the UserContext — User now resolves each pinned
|
||||
// addressable note (kind:10002 / 10050 / 10019) lazily on first
|
||||
// read, instead of all-or-nothing at construction time.
|
||||
return users.getOrCreate(hex) { User(it, userContext) }
|
||||
return users.getOrCreate(pubkey) { User(it, userContext) }
|
||||
}
|
||||
|
||||
/** [UserContext] bridge to this cache's addressable lookup. */
|
||||
|
||||
+4
-2
@@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder(
|
||||
normalizedUrl: String,
|
||||
final: Boolean,
|
||||
): Boolean =
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
|
||||
// Overlay-mesh hosts (0200::/7) are reachable only through the local mesh
|
||||
// interface — Tor cannot route the range, so proxying only breaks the fetch.
|
||||
false
|
||||
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
||||
true
|
||||
@@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder(
|
||||
isOnionRelaysActive: Boolean,
|
||||
final: Boolean,
|
||||
): Boolean =
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
|
||||
false
|
||||
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
||||
isOnionRelaysActive
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ class NappletLiveSubscriptions(
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+29
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -49,6 +50,14 @@ class RelayProxyClientConnector(
|
||||
val torStatus: StateFlow<TorServiceStatus>,
|
||||
val client: INostrClient,
|
||||
val scope: CoroutineScope,
|
||||
/**
|
||||
* Called with the cause every time this connector *decides* to reconnect, so the
|
||||
* usage ledger can attribute relay churn without this class knowing where the
|
||||
* counters go. Causes are the `UsageKeys.TRIGGER_*` constants — see
|
||||
* [UsageKeys.relayTrigger] for why these are an upper bound rather than a count
|
||||
* of reconnects actually performed.
|
||||
*/
|
||||
val onTrigger: (String) -> Unit = {},
|
||||
) {
|
||||
data class RelayServiceInfra(
|
||||
val evaluator: TorRelayEvaluation,
|
||||
@@ -138,6 +147,9 @@ class RelayProxyClientConnector(
|
||||
infra.connectivity is ConnectivityStatus.Off -> {
|
||||
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" }
|
||||
if (client.isActive()) {
|
||||
// Counted inside the guard: the upstream combine() re-emits Off
|
||||
// repeatedly and only this branch does any work.
|
||||
onTrigger(UsageKeys.TRIGGER_OFF)
|
||||
client.disconnect()
|
||||
}
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
@@ -156,6 +168,7 @@ class RelayProxyClientConnector(
|
||||
}
|
||||
|
||||
// only calls this if the client is not active. Otherwise goes to the else below
|
||||
onTrigger(UsageKeys.TRIGGER_COLD_START)
|
||||
client.connect()
|
||||
lastNetworkId = networkId
|
||||
lastTorSettings = torSettings
|
||||
@@ -211,10 +224,26 @@ class RelayProxyClientConnector(
|
||||
Log.d("ManageRelayServices") {
|
||||
"Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection"
|
||||
}
|
||||
// The expensive branch, and the one the churn investigation is
|
||||
// aimed at: a full teardown re-dials the whole pool and replays
|
||||
// every REQ.
|
||||
//
|
||||
// Only this cause is counted here, so a wifi<->cellular handoff —
|
||||
// which mints a new network handle AND rebuilds the OkHttp clients
|
||||
// off the metered bit — is booked as netid alone. relay.trigger.transport
|
||||
// therefore undercounts exactly the case one would most want it for;
|
||||
// read it as "transport changed WITHOUT the handle changing".
|
||||
onTrigger(UsageKeys.TRIGGER_NETID)
|
||||
// Full teardown: disconnect() drops the dead sockets AND clears each
|
||||
// relay's backoff, so the new network starts from a clean slate.
|
||||
client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
} else {
|
||||
// Non-exclusive: count each independently so the report shows
|
||||
// which combination fired.
|
||||
if (transportChanged) onTrigger(UsageKeys.TRIGGER_TRANSPORT)
|
||||
if (torPolicyChanged) onTrigger(UsageKeys.TRIGGER_TOR_POLICY)
|
||||
if (classificationChanged) onTrigger(UsageKeys.TRIGGER_CLASSIFICATION)
|
||||
|
||||
val freshStart = transportChanged || torPolicyChanged
|
||||
if (freshStart) {
|
||||
// The failures behind the current backoffs were measured against a
|
||||
|
||||
+1
-1
@@ -158,7 +158,7 @@ class BootRelayDiagnostics(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger(
|
||||
Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" }
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ abstract class PerUserEoseManager<T>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class NotifyCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
newEose(TimeUtils.now(), relay, forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.notificationHistory
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ class NwcNotificationsEoseManager(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.chatroomList.giftWrapHistory
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ class UserWatcherSubAssembler(
|
||||
newEose(relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class RelaySpeedLogger(
|
||||
|
||||
private val clientListener =
|
||||
object : RelayConnectionListener {
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+294
-5
@@ -20,10 +20,22 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposeOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Counts relay websocket traffic into the usage ledger. Frame sizes are
|
||||
@@ -31,29 +43,210 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
* (relay JSON is ASCII-dominant), consistent with how RelayStats counts.
|
||||
* Excludes WS framing/compression; good enough for "which subsystem is
|
||||
* eating my data plan" comparisons.
|
||||
*
|
||||
* Beyond the byte totals this also carries the relay-churn diagnostics: a
|
||||
* per-verb split of those same bytes, a connection-lifetime histogram, and
|
||||
* per-relay failure/short-session counts. See
|
||||
* plans/2026-07-29-relay-churn-diagnostics.md for what each answers and how to
|
||||
* read them together.
|
||||
*
|
||||
* The verb split takes its name straight from the wire label the command already
|
||||
* knows (`Command.label()` / `Message.label()`), so `Σ verb == Σ msg` holds by
|
||||
* construction and a new subtype needs no change here.
|
||||
*/
|
||||
class RelayUsageListener(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val isMobile: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : RelayConnectionListener {
|
||||
/**
|
||||
* Relay -> when its current session became ready. Touched from the per-relay
|
||||
* OkHttp dispatcher threads, hence concurrent. Keyed by [NormalizedRelayUrl] to
|
||||
* match the other per-relay caches (`RelayStats`, `RelayLimitsTracker`).
|
||||
*
|
||||
* Entries are consumed on disconnect. Three ways a session escapes the map
|
||||
* unrecorded, all counted rather than prevented — see [UsageKeys.RELAY_LIFE_OVERWRITE],
|
||||
* [UsageKeys.RELAY_LIFE_ORPHAN], and [UsageKeys.relayConnects] for process death.
|
||||
*/
|
||||
private val connectedSince = ConcurrentHashMap<NormalizedRelayUrl, Long>()
|
||||
|
||||
/**
|
||||
* Relay -> subscription ids currently open on this connection, so a REQ that
|
||||
* replaces an in-flight subscription can be told apart from one that opens a new
|
||||
* one. Cleared on disconnect, because the relay forgets them too — every REQ
|
||||
* after a reconnect is legitimately new.
|
||||
*
|
||||
* Bounded by the live subscription count per relay (tens), not by session length.
|
||||
*/
|
||||
private val openSubs = ConcurrentHashMap<NormalizedRelayUrl, MutableSet<String>>()
|
||||
|
||||
/**
|
||||
* Subscription id -> the purpose that opened it, for attributing inbound frames:
|
||||
* an EVENT names only its subscription, never why the client asked for it.
|
||||
*
|
||||
* Deliberately **not** [openSubs]. Sharing one map conflated two different
|
||||
* lifetimes and lost 41 % of the download to `unattributed` in the 2026-08-02
|
||||
* reading: a CLOSE removed the id, and a disconnect dropped the whole relay's
|
||||
* map, while frames already in flight were still arriving. "Is this subscription
|
||||
* open" and "what did this subscription belong to" answer different questions and
|
||||
* expire at different times — the second stays true after the first turns false.
|
||||
*
|
||||
* Keyed by subscription id alone, without the relay. The same id is used across
|
||||
* relays for one logical subscription, so the purpose is a property of the id;
|
||||
* this also means a frame arriving after a reconnect still attributes.
|
||||
*
|
||||
* Bounded by [MAX_TRACKED_SUBS] with wholesale eviction rather than an LRU: this
|
||||
* is a diagnostic on a hot path, ids are recycled steadily, and a rare reset that
|
||||
* sends a few frames to `unattributed` is cheaper than per-frame bookkeeping.
|
||||
* `unattributed` staying small is what says the bound is generous enough.
|
||||
*/
|
||||
private val subPurpose = ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Event ids delivered recently, as the first 64 bits of the id.
|
||||
*
|
||||
* Held as a Long rather than the 64-char hex, which saves the id strings
|
||||
* themselves — but a boxed `Long` plus its map node still costs ~56 bytes an
|
||||
* entry, so a full window is ~3 MB, not the few hundred KB the raw payload
|
||||
* suggests. Worth knowing before raising [MAX_TRACKED_EVENTS] on a 512 MB-class
|
||||
* device; an unboxed open-addressed `LongArray` would be ~400 KB if it ever needs
|
||||
* to grow. 64 bits makes a collision between distinct ids negligible where a
|
||||
* 32-bit hash would not be.
|
||||
*
|
||||
* The window only needs to span the fan-out, not the session: the same event
|
||||
* arrives from every relay carrying it within seconds, so near-term memory
|
||||
* catches the duplication this measures. Cleared wholesale at [MAX_TRACKED_EVENTS]
|
||||
* for the same reason [subPurpose] is — the alternative is per-frame LRU
|
||||
* bookkeeping on the hottest path in the app. A clear undercounts duplicates that
|
||||
* straddle it, so the ratio is a floor.
|
||||
*/
|
||||
private val recentEventIds = ConcurrentHashMap.newKeySet<Long>()
|
||||
|
||||
/**
|
||||
* Relay -> when this dial was decided, so the pre-request cost can be separated
|
||||
* from the handshake. Consumed by whichever of `onConnected`/`onCannotConnect`
|
||||
* ends the dial, so an entry never outlives the attempt that made it.
|
||||
*/
|
||||
private val dialStartedAt = ConcurrentHashMap<NormalizedRelayUrl, Long>()
|
||||
|
||||
/** Notice texts already logged, so one wording costs one line however often it arrives. */
|
||||
private val loggedNotices = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
if (success) {
|
||||
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong())
|
||||
if (!success) return
|
||||
|
||||
val bytes = cmdStr.length.toLong()
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
accountant.add(UsageKeys.relayMsg(mobile, fg, received = false), bytes)
|
||||
accountant.add(UsageKeys.relayVerb(cmd.label(), received = false, mobile, fg), bytes)
|
||||
|
||||
when (cmd) {
|
||||
is ReqCmd -> {
|
||||
accountant.add(UsageKeys.relaySubsSent(mobile, fg), 1)
|
||||
|
||||
val purpose = purposeOf(cmd)
|
||||
accountant.add(UsageKeys.relayPurposeSent(purpose), 1)
|
||||
accountant.add(UsageKeys.relayPurposeBytes(purpose), bytes)
|
||||
|
||||
if (subPurpose.size >= MAX_TRACKED_SUBS) subPurpose.clear()
|
||||
subPurpose[cmd.subId] = purpose
|
||||
|
||||
// Already open on this connection, so this REQ replaces a live
|
||||
// subscription rather than starting one.
|
||||
// computeIfAbsent, not getOrPut: the latter is get-then-put and two
|
||||
// threads racing the first REQ after a (re)connect would each build a
|
||||
// set, the losing put's subId vanishing with its orphaned set.
|
||||
// `sendIfConnected` deliberately calls listeners outside PoolRequests'
|
||||
// stripe lock, so same-relay concurrency here is by design.
|
||||
val known = openSubs.computeIfAbsent(relay.url) { ConcurrentHashMap.newKeySet() }
|
||||
if (!known.add(cmd.subId)) {
|
||||
accountant.add(UsageKeys.relaySubsResent(mobile, fg), 1)
|
||||
}
|
||||
// Within the window after this relay's connect, so almost certainly
|
||||
// part of syncState's replay rather than a user action. A time
|
||||
// window because nothing on this side marks a frame as belonging to
|
||||
// it; see UsageKeys.relaySubsReplay.
|
||||
val since = connectedSince[relay.url]
|
||||
if (since != null && nowMs() - since <= UsageKeys.REPLAY_WINDOW_MS) {
|
||||
accountant.add(UsageKeys.relaySubsReplay(mobile, fg), 1)
|
||||
}
|
||||
}
|
||||
is CloseCmd -> {
|
||||
accountant.add(UsageKeys.relaySubsClosed(mobile, fg), 1)
|
||||
openSubs[relay.url]?.remove(cmd.subId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong())
|
||||
val bytes = msgStr.length.toLong()
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
accountant.add(UsageKeys.relayMsg(mobile, fg, received = true), bytes)
|
||||
accountant.add(UsageKeys.relayVerb(msg.label(), received = true, mobile, fg), bytes)
|
||||
|
||||
// Attribute the inbound side to whoever asked for it. Only frames that name a
|
||||
// subscription can be attributed; NOTICE and OK are relay-wide and are left out
|
||||
// rather than guessed at, which is why this does not reconcile to msg.rx.
|
||||
// Resolved once: the duplicate check below needs the same answer, and an
|
||||
// EVENT names only its subscription, never why the client asked for it.
|
||||
val purpose = subIdOf(msg)?.let { subPurpose[it] ?: UsageKeys.PURPOSE_UNATTRIBUTED }
|
||||
if (purpose != null) {
|
||||
accountant.add(UsageKeys.relayPurposeDown(purpose), bytes)
|
||||
accountant.add(UsageKeys.relayPurposeDownCount(purpose), 1)
|
||||
}
|
||||
|
||||
if (msg is EventMessage) {
|
||||
accountant.add(UsageKeys.relayEventsSeen(mobile, fg), 1)
|
||||
idPrefix(msg.event.id)?.let { key ->
|
||||
if (recentEventIds.size >= MAX_TRACKED_EVENTS) recentEventIds.clear()
|
||||
if (!recentEventIds.add(key)) {
|
||||
accountant.add(UsageKeys.relayEventsDup(mobile, fg), 1)
|
||||
accountant.add(UsageKeys.relayEventsDupBytes(mobile, fg), bytes)
|
||||
if (purpose != null) accountant.add(UsageKeys.relayPurposeDupBytes(purpose), bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A refused subscription arrives here and nowhere else: the NOTICE carries no
|
||||
// subscription id, so RelayReqRefusals (wired to CLOSED) never sees it.
|
||||
if (msg is NoticeMessage) {
|
||||
val reason = UsageKeys.noticeReason(msg.message)
|
||||
accountant.add(UsageKeys.relayNotice(reason), 1)
|
||||
if (reason == UsageKeys.NOTICE_UNCLASSIFIED) {
|
||||
// The counter alone cannot say whether an absent `toomanysubs` means no
|
||||
// refusals or an allowlist that misses how this relay words them.
|
||||
//
|
||||
// INFO, not DEBUG: a debug build defaults to LogLevel.INFO
|
||||
// (Amethyst.DEFAULT_LOG_LEVEL, with VERBOSE_LOGS off), so a DEBUG line
|
||||
// here is dropped before it reaches the sink and this said nothing at
|
||||
// all. Demote it once the allowlist stops needing evidence.
|
||||
//
|
||||
// One line per distinct wording rather than per frame: the ledger
|
||||
// already has the count, what is missing is the variety. Truncated and
|
||||
// capped because the text is server-controlled.
|
||||
if (loggedNotices.size < MAX_DISTINCT_NOTICES && loggedNotices.add(msg.message)) {
|
||||
Log.i(TAG) { "Unclassified NOTICE from ${relay.url.url}: ${msg.message.take(MAX_NOTICE_LOG)}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dial attempts. Unlike [onCannotConnect] this really is one per dial. */
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
accountant.add(UsageKeys.relayDials(isMobile(), isForeground()), 1)
|
||||
dialStartedAt[relay.url] = nowMs()
|
||||
}
|
||||
|
||||
// Every completed (re)connection paid a TCP+TLS handshake; high daily
|
||||
@@ -64,13 +257,109 @@ class RelayUsageListener(
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1)
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
// Doubles as the lifetime histogram's denominator — one session begins here.
|
||||
accountant.add(UsageKeys.relayConnects(mobile, fg), 1)
|
||||
// Consumed unconditionally: the gap needs a handshake to subtract, but the
|
||||
// stamp has to go either way or a dial that cannot be timed leaks its entry.
|
||||
val dialedAt = dialStartedAt.remove(relay.url)
|
||||
// pingMillis is the transport's own handshake timing; <= 0 means it could
|
||||
// not measure it, and a fabricated 0 would be worse than no record.
|
||||
if (pingMillis > 0) {
|
||||
accountant.add(UsageKeys.relayHandshake(pingMillis.toLong(), mobile, fg), 1)
|
||||
if (dialedAt != null) {
|
||||
val gap = nowMs() - dialedAt - pingMillis
|
||||
if (gap >= 0) accountant.add(UsageKeys.relayDialGap(gap, mobile, fg), 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedSince.put(relay.url, nowMs()) != null) {
|
||||
accountant.add(UsageKeys.RELAY_LIFE_OVERWRITE, 1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
accountant.add(UsageKeys.relayDisconnects(mobile, fg), 1)
|
||||
|
||||
openSubs.remove(relay.url)
|
||||
val startedAt = connectedSince.remove(relay.url)
|
||||
if (startedAt == null) {
|
||||
// A dial that never became ready, or a second disconnect for one session.
|
||||
accountant.add(UsageKeys.RELAY_LIFE_ORPHAN, 1)
|
||||
return
|
||||
}
|
||||
|
||||
val elapsed = (nowMs() - startedAt).coerceAtLeast(0)
|
||||
accountant.add(UsageKeys.relayLife(elapsed, mobile, fg), 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The [SubPurpose] behind a REQ, from the first filter that declares one.
|
||||
*
|
||||
* One subscription id carries one purpose in practice, so the first is the
|
||||
* subscription's. A REQ whose filters are plain [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]s
|
||||
* predates #3832's tagging and is counted separately rather than guessed at.
|
||||
*/
|
||||
private fun purposeOf(cmd: ReqCmd): String =
|
||||
cmd.filters
|
||||
.firstNotNullOfOrNull { it.purposeOrNull() }
|
||||
?.let { UsageKeys.purposeKeyPart(it) }
|
||||
?: UsageKeys.PURPOSE_UNEXPLAINED
|
||||
|
||||
/**
|
||||
* The first 64 bits of an event id, or null if it is not a well-formed id.
|
||||
*
|
||||
* Parsed in place rather than `substring(0, 16).toULongOrNull(16)`: this runs on
|
||||
* every inbound EVENT frame, and the substring would be a String plus its backing
|
||||
* array per event, thrown away immediately.
|
||||
*/
|
||||
private fun idPrefix(id: String): Long? {
|
||||
if (id.length < 16) return null
|
||||
var acc = 0L
|
||||
for (i in 0 until 16) {
|
||||
val digit = Character.digit(id[i], 16)
|
||||
if (digit < 0) return null
|
||||
acc = (acc shl 4) or digit.toLong()
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
/** The subscription a frame belongs to, when it names one. */
|
||||
private fun subIdOf(msg: Message): String? =
|
||||
when (msg) {
|
||||
is EventMessage -> msg.subId
|
||||
is EoseMessage -> msg.subId
|
||||
is ClosedMessage -> msg.subId
|
||||
is CountMessage -> msg.queryId
|
||||
else -> null
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1)
|
||||
// A dial that ends here never reaches onConnected, so nothing else would
|
||||
// ever consume its start stamp.
|
||||
dialStartedAt.remove(relay.url)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayUsage"
|
||||
|
||||
/** NOTICE text is server-controlled; cap what reaches the log. */
|
||||
private const val MAX_NOTICE_LOG = 200
|
||||
|
||||
/** Ceiling on distinct wordings held in memory; relay prose is unbounded. */
|
||||
private const val MAX_DISTINCT_NOTICES = 200
|
||||
|
||||
/** Ceiling on remembered subscription-id purposes. See [subPurpose]. */
|
||||
private const val MAX_TRACKED_SUBS = 4_000
|
||||
|
||||
/** Recent-event-id window. See [recentEventIds]. */
|
||||
private const val MAX_TRACKED_EVENTS = 50_000
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -38,7 +38,9 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
* hands off the accumulated value atomically, whereas remove+sum on a
|
||||
* LongAdder can strand a racing increment on an orphaned cell. Entries stay
|
||||
* in the map after a drain — the key space is small and fixed (dims x areas),
|
||||
* so this costs a few hundred boxed zeros at most.
|
||||
* so this costs a few hundred boxed zeros at most. The churn counters roughly
|
||||
* tripled it, but every one of them is still compile-time bounded: no counter
|
||||
* is keyed on a relay url, a host, or any other runtime string.
|
||||
*
|
||||
* Counters added from inside a pre-flush hook (the CPU sampler, the segment
|
||||
* integrators closing an open segment) never re-arm the debounce: they are
|
||||
@@ -74,8 +76,16 @@ class ResourceUsageAccountant(
|
||||
amount: Long,
|
||||
) {
|
||||
if (amount <= 0) return
|
||||
live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount)
|
||||
// Plain get first: computeIfAbsent locks the bin head when the key is present
|
||||
// but not the head node, and the churn counters roughly tripled the key count
|
||||
// (so collisions) on a path that runs per relay frame.
|
||||
(live[key] ?: live.computeIfAbsent(key) { AtomicLong() }).addAndGet(amount)
|
||||
if (inHookRun.get() == true) return
|
||||
// Plain read before the CAS: in steady state a flush is always already armed,
|
||||
// and the churn counters made this 5-8 calls per relay frame — every one of
|
||||
// which would otherwise be a read-modify-write on the same shared cache line
|
||||
// from every relay's socket thread, only to fail.
|
||||
if (flushScheduled.get()) return
|
||||
if (flushScheduled.compareAndSet(false, true)) {
|
||||
scope.launch {
|
||||
delay(flushDebounceMs)
|
||||
|
||||
+53
-1
@@ -75,16 +75,49 @@ class ResourceUsageReportAssembler {
|
||||
|
||||
sb.append("\nTechnical details (per epoch-day):\n")
|
||||
sb.append("```\n")
|
||||
days.toSortedMap().forEach { (day, counters) ->
|
||||
val sorted = days.toSortedMap()
|
||||
val included = newestDaysWithin(sorted, MAX_DUMP_CHARS)
|
||||
sorted.forEach { (day, counters) ->
|
||||
if (day !in included) return@forEach
|
||||
sb.append("day $day (today=$today)\n")
|
||||
counters.toSortedMap().forEach { (key, value) ->
|
||||
sb.append(" $key = $value\n")
|
||||
}
|
||||
}
|
||||
val omitted = sorted.size - included.size
|
||||
if (omitted > 0) {
|
||||
sb.append("($omitted earlier day(s) omitted to keep this report sendable; ")
|
||||
sb.append("the summary tables above still cover them)\n")
|
||||
}
|
||||
sb.append("```\n")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent days whose dumps fit in [budget], newest first, always
|
||||
* including at least the newest even if it alone exceeds it.
|
||||
*
|
||||
* Bounded by size rather than by a day count because the per-day size is not a
|
||||
* constant: it tracks how many distinct counters the build emits, and that has
|
||||
* grown by more than an order of magnitude. A fixed day count would have to be
|
||||
* re-tuned every time a counter family is added, and would be wrong in the
|
||||
* meantime.
|
||||
*/
|
||||
private fun newestDaysWithin(
|
||||
days: Map<Long, Map<String, Long>>,
|
||||
budget: Int,
|
||||
): Set<Long> {
|
||||
val included = mutableSetOf<Long>()
|
||||
var left = budget
|
||||
for (day in days.keys.sortedDescending()) {
|
||||
val size = days.getValue(day).entries.sumOf { it.key.length + DUMP_LINE_OVERHEAD }
|
||||
if (included.isNotEmpty() && size > left) break
|
||||
included.add(day)
|
||||
left -= size
|
||||
}
|
||||
return included
|
||||
}
|
||||
|
||||
private fun summaryTable(s: UsageSummary): String =
|
||||
buildString {
|
||||
append("| Metric | Value |\n")
|
||||
@@ -132,6 +165,25 @@ class ResourceUsageReportAssembler {
|
||||
/** Markdown table header/body separator row. */
|
||||
private const val TABLE_SEPARATOR = "| --- | --- |\n"
|
||||
|
||||
/**
|
||||
* Character budget for the raw per-day dump.
|
||||
*
|
||||
* This report exists to be sent to the developers as a NIP-17 DM, and relays
|
||||
* commonly cap events between 64 and 256 KB — so an unbounded dump does not
|
||||
* merely inconvenience, it makes the report unsendable by exactly the users
|
||||
* whose ledgers are most worth seeing. It also travels through a ~1 MB Binder
|
||||
* transaction when shared.
|
||||
*
|
||||
* The ledger keeps 30 days and a busy day now emits ~1,200 counters, which is
|
||||
* ~52 KB of dump per day — so "every retained day" would be ~1.5 MB. This
|
||||
* keeps the newest days and says how many it dropped; the summary tables
|
||||
* above are unaffected and still cover the whole window.
|
||||
*/
|
||||
private const val MAX_DUMP_CHARS = 64 * 1024
|
||||
|
||||
/** ` ` + ` = ` + the value, per dumped line. */
|
||||
private const val DUMP_LINE_OVERHEAD = 24
|
||||
|
||||
fun formatBytes(bytes: Long): String =
|
||||
when {
|
||||
bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
|
||||
|
||||
+20
-2
@@ -34,13 +34,31 @@ import java.io.File
|
||||
* Jackson + Mutex + write-to-tmp-then-rename + version envelope.
|
||||
*
|
||||
* Day keys are UTC epoch-days (stringified for JSON). Buckets older than
|
||||
* [keepDays] are pruned on every merge, so the file stays small (a few KB).
|
||||
* [keepDays] are pruned on every merge, so the file stays small (a few KB, on a
|
||||
* key space the churn counters tripled but left compile-time bounded). The whole
|
||||
* file is re-serialized on every flush (debounced to ~30s while traffic flows),
|
||||
* so that size is paid on each write, not just at rest.
|
||||
* Also carries the high-consumption alert state (last prompt time, opt-out)
|
||||
* so the whole feature has exactly one file.
|
||||
*/
|
||||
class ResourceUsageStore(
|
||||
private val storageFile: File,
|
||||
private val keepDays: Long = 30,
|
||||
/**
|
||||
* Retention, in days.
|
||||
*
|
||||
* Seven because that is the widest window anything reads: the summary tables and
|
||||
* the trend chart both span `today - 6 .. today`, the alert evaluator looks at
|
||||
* two days, and the report's raw dump is byte-bounded well below a week. At 30 —
|
||||
* the previous value — twenty-three days were rewritten on every flush and read
|
||||
* by nothing.
|
||||
*
|
||||
* That is not free: [persist] rewrites the whole file on every merge, and the
|
||||
* relay-churn counters took a day's bucket from ~57 keys to ~241. Retention is
|
||||
* therefore a write-amplification setting as much as a history setting — cutting
|
||||
* it to a week is what keeps those counters at ~18% over the previous file size
|
||||
* rather than ~5x.
|
||||
*/
|
||||
private val keepDays: Long = 7,
|
||||
) {
|
||||
data class UsageFile(
|
||||
val version: Int = 1,
|
||||
|
||||
+629
-16
@@ -20,6 +20,18 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.AUTH_REQUIRED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.BLOCKED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.ERROR
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.INVALID
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RATE_LIMITED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RESTRICTED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.UNSUPPORTED
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Counter-key grammar for the resource-usage ledger. Keys are flat strings so
|
||||
* the on-disk store is schema-free — adding a counter never needs a migration.
|
||||
@@ -29,8 +41,28 @@ package com.vitorpamplona.amethyst.service.resourceusage
|
||||
* - visibility: `fg` (an activity is started) vs `bg`
|
||||
* - direction: `rx` (downloaded) vs `tx` (uploaded)
|
||||
*
|
||||
* Counters are sizes, durations, and counts only — never URLs, relay names, or
|
||||
* content. See plans/2026-07-12-resource-usage-ledger.md.
|
||||
* Counters are sizes, durations, and counts only — never content, and never a
|
||||
* full URL, and never a relay name.
|
||||
* See plans/2026-07-12-resource-usage-ledger.md.
|
||||
*
|
||||
* ## Reserved segments — read before adding a counter
|
||||
*
|
||||
* [sumMatching] matches by dot-segment *membership*, not by prefix, and every
|
||||
* headline figure in [UsageSummary] is built from it. A new key that happens to
|
||||
* contain one of these segments silently joins that sum:
|
||||
*
|
||||
* rx tx msg connms connects connfails reqs bursts activems
|
||||
* worker runs + every value in [HTTP_ROLES]
|
||||
*
|
||||
* Concretely: a key named `relay.rx.event.mobile.bg` would be counted by
|
||||
* `traffic(MOBILE, BG)` *in addition to* `relay.msg.mobile.bg.rx`, doubling the
|
||||
* reported data usage and halving the effective threshold of the
|
||||
* background-mobile-data alert. That is why the relay verb split below uses
|
||||
* `up`/`down` rather than `tx`/`rx`.
|
||||
*
|
||||
* `ResourceUsageLedgerTest.newKeysDoNotDisturbSummary` is the regression guard:
|
||||
* it asserts [UsageSummary.from] is value-identical with and without every key
|
||||
* this object can produce.
|
||||
*/
|
||||
object UsageKeys {
|
||||
const val MOBILE = "mobile"
|
||||
@@ -54,6 +86,42 @@ object UsageKeys {
|
||||
|
||||
val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER)
|
||||
|
||||
/**
|
||||
* `mobile.bg` — the network x visibility pair every counter is split by.
|
||||
*
|
||||
* Table-backed rather than interpolated: this is evaluated on every relay frame
|
||||
* and every HTTP response, and there are only four possible answers. Declared
|
||||
* first because the key tables below are built from it at class-init.
|
||||
*/
|
||||
fun dim(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = DIMS[dimIndex(mobile, foreground)]
|
||||
|
||||
private val DIMS = arrayOf("$WIFI.$BG", "$WIFI.$FG", "$MOBILE.$BG", "$MOBILE.$FG")
|
||||
|
||||
private fun dimIndex(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): Int = (if (mobile) 2 else 0) or (if (foreground) 1 else 0)
|
||||
|
||||
/**
|
||||
* The four `<prefix>.<dim>[.<suffix>]` keys, indexed by [dimIndex].
|
||||
*
|
||||
* Used for every `relay.*` key, because all of them are built from a relay
|
||||
* callback — per frame for [relayMsg]/[relayVerb], per dial/connect/disconnect
|
||||
* for the rest. The `net.*` builders below still interpolate: their key space is
|
||||
* role x dim x metric and they are called once per HTTP response, so the table
|
||||
* would be larger and buy less. If you add a `relay.*` counter, table it.
|
||||
*/
|
||||
private fun dimKeys(
|
||||
prefix: String,
|
||||
suffix: String? = null,
|
||||
): Array<String> {
|
||||
val tail = if (suffix == null) "" else ".$suffix"
|
||||
return Array(DIMS.size) { "$prefix.${DIMS[it]}$tail" }
|
||||
}
|
||||
|
||||
/** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */
|
||||
fun net(
|
||||
role: String,
|
||||
@@ -87,25 +155,556 @@ object UsageKeys {
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
received: Boolean,
|
||||
): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}"
|
||||
): String = (if (received) RELAY_MSG_RX else RELAY_MSG_TX)[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_MSG_RX = dimKeys("relay.msg", RX)
|
||||
private val RELAY_MSG_TX = dimKeys("relay.msg", TX)
|
||||
|
||||
/** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */
|
||||
fun relayConnMs(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connms.${dim(mobile, foreground)}"
|
||||
): String = RELAY_CONNMS[dimIndex(mobile, foreground)]
|
||||
|
||||
/** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */
|
||||
private val RELAY_CONNMS = dimKeys("relay.connms")
|
||||
|
||||
/**
|
||||
* `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a
|
||||
* TCP+TLS handshake.
|
||||
*
|
||||
* Also the [relayLife] histogram's denominator — one session begins per
|
||||
* `onConnected` — which is what makes the histogram's deficit measurable rather
|
||||
* than assumed:
|
||||
*
|
||||
* connects − Σ life buckets − orphan = still open at report time + lost to process death
|
||||
*
|
||||
* Without that subtraction a leak, a still-open session and a session lost to a
|
||||
* background kill are indistinguishable.
|
||||
*/
|
||||
fun relayConnects(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connects.${dim(mobile, foreground)}"
|
||||
): String = RELAY_CONNECTS[dimIndex(mobile, foreground)]
|
||||
|
||||
/** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */
|
||||
private val RELAY_CONNECTS = dimKeys("relay.connects")
|
||||
|
||||
/**
|
||||
* `relay.connfails.mobile.bg` — every `onCannotConnect`.
|
||||
*
|
||||
* NOT a failed-dial count, despite the name. `BasicRelayClient.onFailure`
|
||||
* raises `onCannotConnect` with no `isReady` test, so a connection that lived
|
||||
* for ten minutes and then dropped increments both this and [relayConnects]
|
||||
* from a single dial. Use [relayDials] for the actual number of dials.
|
||||
*/
|
||||
fun relayConnectFails(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connfails.${dim(mobile, foreground)}"
|
||||
): String = RELAY_CONNFAILS[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_CONNFAILS = dimKeys("relay.connfails")
|
||||
|
||||
/**
|
||||
* `relay.dials.mobile.bg` — dial attempts, from `onConnecting`.
|
||||
*
|
||||
* Fires exactly once per dial, after the transport gate and the connect mutex
|
||||
* and before the socket is built, so this is the honest denominator that
|
||||
* [relayConnectFails] is not.
|
||||
*/
|
||||
fun relayDials(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_DIALS[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_DIALS = dimKeys("relay.dials")
|
||||
|
||||
/** `relay.disc.mobile.bg` — every `onDisconnected`, whatever the cause. */
|
||||
fun relayDisconnects(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_DISC[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_DISC = dimKeys("relay.disc")
|
||||
|
||||
/**
|
||||
* `relay.subs.sent.mobile.bg` — REQ commands sent.
|
||||
*
|
||||
* A count to sit beside the `relay.verb.up.req` byte total: PR #3832 raised the
|
||||
* number of live subscriptions per relay (background accounts now subscribe too)
|
||||
* and measured refusals against nos.lol's cap of 20. Divided by [relayConnects]
|
||||
* this is REQs per connection, which is what separates "we reconnect too often"
|
||||
* from "each reconnect asks for too much" — different fixes.
|
||||
*/
|
||||
fun relaySubsSent(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_SENT[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_SENT = dimKeys("relay.subs.sent")
|
||||
|
||||
/** `relay.subs.closed.mobile.bg` — CLOSE commands sent; sent minus closed is net subscription growth. */
|
||||
fun relaySubsClosed(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_CLOSED[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_CLOSED = dimKeys("relay.subs.closed")
|
||||
|
||||
/**
|
||||
* `relay.subs.replay.mobile.bg` — REQs sent within [REPLAY_WINDOW_MS] of that
|
||||
* relay's connect, i.e. the post-connect resubscribe burst.
|
||||
*
|
||||
* An estimate, not an exact split. `PoolRequests.syncState` replays every desired
|
||||
* filter from a coroutine launched at `onConnected`, but nothing on the listener
|
||||
* side marks a frame as belonging to it, so this is a time window. It is the
|
||||
* measurement behind the source report's inference that ~24 KB per connection
|
||||
* "is exactly the size of a full REQ subscription replay" — which was arithmetic
|
||||
* on a daily total, not an observation.
|
||||
*/
|
||||
fun relaySubsReplay(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_REPLAY[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_REPLAY = dimKeys("relay.subs.replay")
|
||||
|
||||
/** How long after a connect a REQ still counts as part of the resubscribe burst. */
|
||||
const val REPLAY_WINDOW_MS = 2_000L
|
||||
|
||||
/**
|
||||
* `relay.notice.toomanysubs` — NOTICE frames by reason.
|
||||
*
|
||||
* Exists because a refused subscription is otherwise invisible. Per PR #3832's
|
||||
* own known issue, `ERROR: too many concurrent REQs` arrives as a NOTICE, which
|
||||
* carries no subscription id and so never reaches `RelayReqRefusals.onRefused`
|
||||
* (wired to CLOSED only). The relay drops the REQ while the client still believes
|
||||
* it is live — it never EOSEs, its `since` never advances, and `syncState` then
|
||||
* re-requests its full backlog on every reconnect, forever. A non-trivial count
|
||||
* here means subscription pressure is a *cause* of the download volume rather
|
||||
* than a symptom of the reconnect count.
|
||||
*
|
||||
* The reason comes from a fixed allowlist, never from the relay's text. Relay
|
||||
* prose is server-controlled and this key is persisted for 30 days; `RelayObserver`
|
||||
* had to fix exactly this bug, where free-form CLOSED prose became its own tally
|
||||
* key and cardinality grew with the number of distinct sentences relays wrote.
|
||||
*/
|
||||
fun relayNotice(reason: String): String = RELAY_NOTICE[reason] ?: RELAY_NOTICE.getValue(NOTICE_UNCLASSIFIED)
|
||||
|
||||
const val NOTICE_TOO_MANY_SUBS = "toomanysubs"
|
||||
const val NOTICE_RATE_LIMITED = "ratelimited"
|
||||
const val NOTICE_AUTH_REQUIRED = "authrequired"
|
||||
const val NOTICE_RESTRICTED = "restricted"
|
||||
const val NOTICE_INVALID = "invalid"
|
||||
const val NOTICE_BLOCKED = "blocked"
|
||||
const val NOTICE_ERROR = "error"
|
||||
const val NOTICE_UNSUPPORTED = "unsupported"
|
||||
|
||||
/** The query itself was too expensive — too many kinds/steps/filters. Observed on nostr.land, relay.layer.systems. */
|
||||
const val NOTICE_QUERY_COST = "querycost"
|
||||
|
||||
/** The relay refuses REQs outright. Observed on sendit.nosflare.com. */
|
||||
const val NOTICE_REQ_REFUSED = "reqrefused"
|
||||
|
||||
/** Relay chatter that costs bytes but means nothing — keepalives, per-query PERF telemetry. */
|
||||
const val NOTICE_BENIGN = "benign"
|
||||
|
||||
/** Deliberately not `other`: that is an [HTTP_ROLES] value and a reserved segment. */
|
||||
const val NOTICE_UNCLASSIFIED = "unclassified"
|
||||
|
||||
val NOTICE_REASONS =
|
||||
listOf(
|
||||
NOTICE_TOO_MANY_SUBS,
|
||||
NOTICE_RATE_LIMITED,
|
||||
NOTICE_AUTH_REQUIRED,
|
||||
NOTICE_RESTRICTED,
|
||||
NOTICE_INVALID,
|
||||
NOTICE_BLOCKED,
|
||||
NOTICE_ERROR,
|
||||
NOTICE_UNSUPPORTED,
|
||||
NOTICE_QUERY_COST,
|
||||
NOTICE_REQ_REFUSED,
|
||||
NOTICE_BENIGN,
|
||||
NOTICE_UNCLASSIFIED,
|
||||
)
|
||||
|
||||
private val RELAY_NOTICE = NOTICE_REASONS.associateWith { "relay.notice.$it" }
|
||||
|
||||
/**
|
||||
* Classifies a NOTICE into one of [NOTICE_REASONS].
|
||||
*
|
||||
* Matches on content markers rather than the NIP-01 machine-readable prefix
|
||||
* alone, because the case this exists for does not have a useful one: strfry
|
||||
* sends `ERROR: too many concurrent REQs`, whose prefix is just `error`.
|
||||
* [RelayObserver.prefixOf] is consulted for the standard prefixes it does
|
||||
* handle correctly.
|
||||
*/
|
||||
fun noticeReason(message: String): String {
|
||||
val text = message.lowercase()
|
||||
// Several relays prefix a NOTICE with the subscription id it concerns
|
||||
// ("Kgo0HH: closed: too many steps"), which makes the *subscription id* the
|
||||
// machine-readable prefix and hides the real one. Try the remainder too.
|
||||
val prefix = RelayObserver.prefixOf(message)
|
||||
val inner = RelayObserver.prefixOf(message.substringAfter(':', ""))
|
||||
val prefixes = setOf(prefix, inner)
|
||||
return when {
|
||||
"too many" in text && ("req" in text || "subscription" in text || "concurrent" in text) -> NOTICE_TOO_MANY_SUBS
|
||||
// A cost refusal is still a refusal: the REQ is dropped, so it never
|
||||
// EOSEs and its `since` never advances.
|
||||
"too many" in text || "too costly" in text || "too expensive" in text -> NOTICE_QUERY_COST
|
||||
"does not accept" in text || "denied" in text || "not accepting" in text -> NOTICE_REQ_REFUSED
|
||||
"keepalive" in text || "perf:" in text -> NOTICE_BENIGN
|
||||
// Wire codes come from the enum rather than being hand-written a third
|
||||
// time (after the enum itself and the NOTICE_* key segments).
|
||||
RATE_LIMITED.code in prefixes || ("rate" in text && "limit" in text) -> NOTICE_RATE_LIMITED
|
||||
AUTH_REQUIRED.code in prefixes || ("auth" in text && "required" in text) -> NOTICE_AUTH_REQUIRED
|
||||
RESTRICTED.code in prefixes -> NOTICE_RESTRICTED
|
||||
INVALID.code in prefixes -> NOTICE_INVALID
|
||||
BLOCKED.code in prefixes -> NOTICE_BLOCKED
|
||||
UNSUPPORTED.code in prefixes -> NOTICE_UNSUPPORTED
|
||||
ERROR.code in prefixes -> NOTICE_ERROR
|
||||
else -> NOTICE_UNCLASSIFIED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar that decides reconnect behaviour, and so also what counts as a short
|
||||
* session: below it a disconnect keeps the growing backoff,
|
||||
* at or above it the backoff resets to 1s. (`NostrClient.KEEP_ALIVE_INTERVAL_MS`
|
||||
* is the same 60s, but it is private, so this reads the one that is public.)
|
||||
*
|
||||
* Derived rather than copied: the plan retunes `STABLE_CONNECTION_IN_SECS` once
|
||||
* the histogram is read, and a hand-written 60_000 here would silently stop
|
||||
* meaning "session that kept the backoff growing" at that point.
|
||||
* `UsageKeyHelpersTest.lifeBucketsAreHalfOpen` asserts the resulting bucket
|
||||
* labels, so a retune surfaces as a test failure rather than as a histogram that
|
||||
* quietly answers the wrong question.
|
||||
*/
|
||||
const val SHORT_SESSION_MS = BasicRelayClient.STABLE_CONNECTION_IN_SECS * 1_000L
|
||||
|
||||
/**
|
||||
* A half-open millisecond histogram: ascending upper [bounds], the derived
|
||||
* bucket labels, and the `<prefix>.<bucket>.<dim>` key table they index.
|
||||
*
|
||||
* Shared by all three histograms below (`relay.life`, `relay.hs`, `relay.gap`),
|
||||
* which differ only in their bounds and in whether the label reads in seconds or
|
||||
* milliseconds. Deriving the names from the bounds is what keeps a bound change
|
||||
* from leaving a label lying about it.
|
||||
*/
|
||||
private class MsHistogram(
|
||||
prefix: String,
|
||||
private val bounds: LongArray,
|
||||
label: (Long) -> String,
|
||||
) {
|
||||
init {
|
||||
// [indexOf] linear-scans for the first bound greater than the elapsed
|
||||
// time, so the bounds must ascend. One of relay.life's is derived from
|
||||
// BasicRelayClient.STABLE_CONNECTION_IN_SECS, and raising that to five
|
||||
// minutes — exactly the retune commit 2 of the churn plan contemplates —
|
||||
// would push it past the two bounds after it. The buckets between would
|
||||
// become unreachable and the labels would start lying. Fail at class-init
|
||||
// with the reason rather than as a puzzling boundary-test failure.
|
||||
require(bounds.asList() == bounds.sorted()) {
|
||||
"$prefix bounds must ascend, got ${bounds.toList()}. " +
|
||||
"SHORT_SESSION_MS is ${SHORT_SESSION_MS}ms — reorder the bounds to match."
|
||||
}
|
||||
}
|
||||
|
||||
val names = Array(bounds.size + 1) { i -> if (i < bounds.size) "lt${label(bounds[i])}" else "gte${label(bounds.last())}" }
|
||||
|
||||
private val keys = Array(names.size) { dimKeys("$prefix.${names[it]}") }
|
||||
|
||||
fun indexOf(ms: Long): Int {
|
||||
for (i in bounds.indices) {
|
||||
if (ms < bounds[i]) return i
|
||||
}
|
||||
return bounds.size
|
||||
}
|
||||
|
||||
fun nameOf(ms: Long): String = names[indexOf(ms)]
|
||||
|
||||
fun key(
|
||||
ms: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = keys[indexOf(ms)][dimIndex(mobile, foreground)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds for the [relayLife] histogram deliberately straddle [SHORT_SESSION_MS]:
|
||||
* a mean cannot tell a tight cluster sitting on that bar from a bimodal mix;
|
||||
* this can.
|
||||
*/
|
||||
private val LIFE = MsHistogram("relay.life", longArrayOf(5_000, 30_000, SHORT_SESSION_MS, 120_000, 300_000)) { "${it / 1000}s" }
|
||||
|
||||
fun lifeBucket(elapsedMs: Long): String = LIFE.nameOf(elapsedMs)
|
||||
|
||||
/**
|
||||
* `relay.life.lt60s.mobile.bg` — connections that closed after living this long.
|
||||
* [relayConnects] is the denominator; see its doc for the deficit equation.
|
||||
*/
|
||||
fun relayLife(
|
||||
elapsedMs: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = LIFE.key(elapsedMs, mobile, foreground)
|
||||
|
||||
/**
|
||||
* `relay.life.overwrite` — a connect arrived for a relay that already had an
|
||||
* unconsumed start stamp.
|
||||
*
|
||||
* Expected during a pool teardown: `NostrClient` runs `disconnect()` then
|
||||
* `connect()` synchronously, so a stale failure callback for the old socket
|
||||
* can land after the new socket is already open. The new stamp overwrites the
|
||||
* old, and the stale disconnect then consumes the new one — booking a
|
||||
* near-zero lifetime for a session that never ended. Bias runs toward `lt5s`,
|
||||
* so read this before reading the histogram's short buckets.
|
||||
*/
|
||||
const val RELAY_LIFE_OVERWRITE = "relay.life.overwrite"
|
||||
|
||||
/** `relay.life.orphan` — a disconnect with no matching start stamp. */
|
||||
const val RELAY_LIFE_ORPHAN = "relay.life.orphan"
|
||||
|
||||
/**
|
||||
* `relay.verb.up.req.mobile.bg` / `relay.verb.down.eose.mobile.bg` — the
|
||||
* [relayMsg] bytes, split by wire verb. Parameterised on direction for the same
|
||||
* reason [relayMsg] is: one memo strategy, not two.
|
||||
*
|
||||
* `verb` is the command's own `label()` (`REQ`, `EVENT`, ...) lowercased, so a
|
||||
* new `Command`/`Message` subtype maps itself and nothing can land in a
|
||||
* catch-all bucket unnoticed. Deliberately `up`/`down` rather than `tx`/`rx` —
|
||||
* see the reserved-segment note above.
|
||||
*
|
||||
* Keys are memoized because this is on the per-frame path: the verb x dim space
|
||||
* is a handful of entries, so steady state is a map lookup and an array index
|
||||
* with no string building at all.
|
||||
*/
|
||||
fun relayVerb(
|
||||
verb: String,
|
||||
received: Boolean,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String =
|
||||
(if (received) VERB_DOWN_KEYS else VERB_UP_KEYS)
|
||||
.getOrPut(verb) { dimKeys("relay.verb.${if (received) DOWN else UP}.${verb.lowercase()}") }[dimIndex(mobile, foreground)]
|
||||
|
||||
private const val UP = "up"
|
||||
private const val DOWN = "down"
|
||||
|
||||
private val VERB_UP_KEYS = ConcurrentHashMap<String, Array<String>>()
|
||||
private val VERB_DOWN_KEYS = ConcurrentHashMap<String, Array<String>>()
|
||||
|
||||
/**
|
||||
* `relay.purpose.home_feed.sent` / `.bytes` — REQ frames and REQ bytes by the
|
||||
* [SubPurpose] that asked for them.
|
||||
*
|
||||
* The counter that turns "REQ traffic is 64 % of upload" into an actionable
|
||||
* name. Purpose travels on the filter itself (PR #3832's `ExplainedFilter`,
|
||||
* which survives the `copy(since = …)` assemblers do after every EOSE), so this
|
||||
* is a read, not a new registry.
|
||||
*
|
||||
* Cardinality is the enum, so it is bounded and stable. [PURPOSE_UNEXPLAINED] is
|
||||
* its own bucket rather than folded into the enum's OTHER: a filter carrying no
|
||||
* purpose at all means an assembler #3832 did not reach, which is a different
|
||||
* fact from one that declared itself uncategorised — and if that bucket is large,
|
||||
* the attribution below cannot be trusted.
|
||||
*
|
||||
* Memoized for the same reason [relayVerb] is, and more urgently: [relayPurposeDown]
|
||||
* and [relayPurposeDownCount] both run on every inbound frame that names a
|
||||
* subscription, so interpolating would build two strings per frame on the hottest
|
||||
* path in the app. The purpose space is the enum plus the three fallbacks below.
|
||||
*/
|
||||
fun relayPurposeSent(purpose: String): String = purposeKeys(purpose)[P_SENT]
|
||||
|
||||
fun relayPurposeBytes(purpose: String): String = purposeKeys(purpose)[P_BYTES]
|
||||
|
||||
/**
|
||||
* `relay.purpose.moderation.down` — bytes received on subscriptions opened for
|
||||
* that purpose, resolved through the subscription id the frame carries.
|
||||
*
|
||||
* The upload counters answer "who is asking"; this answers "who is being
|
||||
* answered", which is the larger number: inbound EVENT payload is ~74 % of relay
|
||||
* traffic against ~26 % outbound. Without it, a fix to the REQ churn can only be
|
||||
* credited with the upload it removes, when the interesting question is how much
|
||||
* of the download it was causing — every re-subscription can make the relay
|
||||
* re-send everything that matches.
|
||||
*/
|
||||
fun relayPurposeDown(purpose: String): String = purposeKeys(purpose)[P_DOWN]
|
||||
|
||||
/**
|
||||
* `relay.purpose.home_feed.downn` — inbound frames, alongside the bytes.
|
||||
*
|
||||
* Bytes alone cannot separate "many small events delivered repeatedly" from "few
|
||||
* large ones", and those want opposite fixes. With a count, `down / downn` is the
|
||||
* average frame size per purpose, and the total frame count set against
|
||||
* `crypto.verify.count` — which the cache pays once per event it accepts — bounds
|
||||
* how much of the download is the same events arriving from different relays
|
||||
* under the outbox fan-out.
|
||||
*/
|
||||
fun relayPurposeDownCount(purpose: String): String = purposeKeys(purpose)[P_DOWNN]
|
||||
|
||||
/**
|
||||
* `relay.purpose.user_profile.dupbytes` — of that purpose's inbound bytes, how
|
||||
* many carried an event already delivered.
|
||||
*
|
||||
* [relayEventsDupBytes] measures duplication across the whole client, which says
|
||||
* how much is wasted but not where. The seventh reading needs exactly this split:
|
||||
* `user_profile` was 57 % of download at ~17 KB per event, and whether that is
|
||||
* mostly the same events arriving from many relays or mostly distinct large ones
|
||||
* points at completely different fixes — suppress redundant delivery, or stop
|
||||
* fetching the large thing per relay.
|
||||
*/
|
||||
fun relayPurposeDupBytes(purpose: String): String = purposeKeys(purpose)[P_DUPBYTES]
|
||||
|
||||
private const val P_SENT = 0
|
||||
private const val P_BYTES = 1
|
||||
private const val P_DOWN = 2
|
||||
private const val P_DOWNN = 3
|
||||
private const val P_DUPBYTES = 4
|
||||
|
||||
private val PURPOSE_SUFFIXES = arrayOf("sent", "bytes", "down", "downn", "dupbytes")
|
||||
|
||||
private val PURPOSE_KEYS = ConcurrentHashMap<String, Array<String>>()
|
||||
|
||||
/** The five `relay.purpose.<purpose>.*` keys, indexed by the `P_` constants above. */
|
||||
private fun purposeKeys(purpose: String): Array<String> = PURPOSE_KEYS.getOrPut(purpose) { Array(PURPOSE_SUFFIXES.size) { "relay.purpose.$purpose.${PURPOSE_SUFFIXES[it]}" } }
|
||||
|
||||
/** A frame whose subscription id we never saw opened — counters wiped mid-session, or a sub from before this connection. */
|
||||
const val PURPOSE_UNATTRIBUTED = "unattributed"
|
||||
|
||||
/** A REQ whose filters carry no [ExplainedFilter] purpose. */
|
||||
const val PURPOSE_UNEXPLAINED = "unexplained"
|
||||
|
||||
/** The enum's own OTHER, renamed: bare `other` is an [HTTP_ROLES] value and a reserved segment. */
|
||||
const val PURPOSE_OTHER = "otherpurpose"
|
||||
|
||||
/**
|
||||
* The key segment for a [SubPurpose]. The one place the enum is turned into a
|
||||
* key, so the reserved-segment rename cannot drift between the producer and the
|
||||
* test that guards it — which is exactly how it drifted the first time.
|
||||
*
|
||||
* Tabled by ordinal: this runs per REQ, and `name.lowercase()` would allocate a
|
||||
* fresh String each time for one of a dozen fixed answers.
|
||||
*/
|
||||
fun purposeKeyPart(purpose: SubPurpose): String = PURPOSE_PARTS[purpose.ordinal]
|
||||
|
||||
private val PURPOSE_PARTS =
|
||||
Array(SubPurpose.entries.size) {
|
||||
val purpose = SubPurpose.entries[it]
|
||||
if (purpose == SubPurpose.OTHER) PURPOSE_OTHER else purpose.name.lowercase()
|
||||
}
|
||||
|
||||
/**
|
||||
* `relay.subs.resent.mobile.bg` — a REQ for a subscription id this relay already
|
||||
* has open on the current connection.
|
||||
*
|
||||
* The distinction the churn question turns on. A REQ that opens a new
|
||||
* subscription is work; a REQ that replaces one already in flight is the client
|
||||
* changing its mind, and at ~1 KB each that is pure cost. Measured against
|
||||
* [relaySubsSent] it says what fraction of the upload is re-subscription rather
|
||||
* than subscription.
|
||||
*/
|
||||
fun relaySubsResent(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_RESENT[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_RESENT = dimKeys("relay.subs.resent")
|
||||
|
||||
/** Half-open bounds, in ms, shared by the two connect-timing histograms below. */
|
||||
private val CONNECT_BOUNDS_MS = longArrayOf(100, 500, 2_000, 10_000, 30_000)
|
||||
|
||||
/**
|
||||
* `relay.hs.lt500ms.wifi.fg` — the websocket upgrade round-trip, as the
|
||||
* transport measured it.
|
||||
*
|
||||
* This is `onConnected`'s `pingMillis`, which `BasicOkHttpWebSocket` computes as
|
||||
* `receivedResponseAtMillis - sentRequestAtMillis` and which this listener
|
||||
* previously discarded. PR #3843 is the cautionary tale: `RelayObserver` derived
|
||||
* the same quantity from `onConnecting -> onConnected` instead, and on a large
|
||||
* fan-out published a 33.5 s median that was the client's own backlog rather than
|
||||
* relay latency. Those timestamps bracket the request itself, so everything
|
||||
* before it — queueing, DNS, TCP, TLS — is excluded. Zero or negative means the
|
||||
* transport could not time it, and nothing is recorded rather than a fabricated 0.
|
||||
*/
|
||||
fun relayHandshake(
|
||||
ms: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = HANDSHAKE.key(ms, mobile, foreground)
|
||||
|
||||
private val HANDSHAKE = MsHistogram("relay.hs", CONNECT_BOUNDS_MS) { "${it}ms" }
|
||||
|
||||
/**
|
||||
* `relay.gap.lt2000ms.wifi.fg` — everything between deciding to dial and the
|
||||
* upgrade request going out: dispatcher queueing, DNS, TCP, TLS.
|
||||
*
|
||||
* `(onConnected wall clock - onConnecting wall clock) - handshake`. This is the
|
||||
* share of connect latency the app is responsible for rather than the relay, and
|
||||
* it is what decides whether a high never-became-ready rate is relays being
|
||||
* unreachable or ~500 simultaneous dials saturating name resolution and sockets.
|
||||
* The dispatcher's own cap is not the constraint on a phone
|
||||
* (`maxRequests = 1024`, `maxRequestsPerHost = 10`), so a large value here points
|
||||
* at resolution and socket setup, not at a queue.
|
||||
*/
|
||||
fun relayDialGap(
|
||||
ms: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = DIAL_GAP.key(ms, mobile, foreground)
|
||||
|
||||
private val DIAL_GAP = MsHistogram("relay.gap", CONNECT_BOUNDS_MS) { "${it}ms" }
|
||||
|
||||
/**
|
||||
* `relay.events.seen.wifi.fg` — inbound EVENT frames, and of those, how many
|
||||
* carried an event id already delivered recently.
|
||||
*
|
||||
* The outbox model asks many relays for the same authors, so one event is
|
||||
* delivered once per relay that carries it. Relay download is ~65 % of all data
|
||||
* on the release build, so the duplication factor decides whether the largest
|
||||
* number in the ledger is content or repetition — a question no other counter
|
||||
* here can answer, and one [VERIFY_COUNT] only proxies (it counts what the cache
|
||||
* accepted, not what arrived, and only while dedup-before-verify holds).
|
||||
*
|
||||
* [relayEventsDupBytes] is the number that matters: bytes that arrived and were
|
||||
* already held.
|
||||
*/
|
||||
fun relayEventsSeen(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_EV_SEEN[dimIndex(mobile, foreground)]
|
||||
|
||||
fun relayEventsDup(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_EV_DUP[dimIndex(mobile, foreground)]
|
||||
|
||||
fun relayEventsDupBytes(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_EV_DUPB[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_EV_SEEN = dimKeys("relay.events.seen")
|
||||
private val RELAY_EV_DUP = dimKeys("relay.events.dup")
|
||||
private val RELAY_EV_DUPB = dimKeys("relay.events.dupbytes")
|
||||
|
||||
/**
|
||||
* `relay.trigger.netid` — reconnect *decisions*, by cause, not reconnects
|
||||
* performed.
|
||||
*
|
||||
* Two reasons this is an upper bound, both of which matter when reading it:
|
||||
* `NostrClient.reconnect` emits into a debounced flow that `subscribe` /
|
||||
* `count` / `publish` / `onDisconnected` also feed very frequently, so a
|
||||
* teardown can be coalesced away before it runs; and the flow's initial value
|
||||
* fires one teardown per client construction with no trigger attributed.
|
||||
*/
|
||||
fun relayTrigger(cause: String): String = "relay.trigger.$cause"
|
||||
|
||||
const val TRIGGER_NETID = "netid"
|
||||
const val TRIGGER_TRANSPORT = "transport"
|
||||
const val TRIGGER_TOR_POLICY = "torpolicy"
|
||||
const val TRIGGER_CLASSIFICATION = "class"
|
||||
const val TRIGGER_COLD_START = "coldstart"
|
||||
const val TRIGGER_OFF = "off"
|
||||
const val TRIGGER_BUZZ = "buzz"
|
||||
|
||||
/** `worker.scheduledPost.runs` */
|
||||
fun workerRuns(worker: String): String = "worker.$worker.runs"
|
||||
@@ -187,18 +786,32 @@ object UsageKeys {
|
||||
const val BATTERY_DRAIN_FG = "battery.drain.fg"
|
||||
const val BATTERY_DRAIN_BG = "battery.drain.bg"
|
||||
|
||||
fun dim(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}"
|
||||
|
||||
/** Sums every counter whose key matches all the given dot-delimited parts. */
|
||||
/**
|
||||
* Sums every counter whose key matches all the given dot-delimited parts.
|
||||
*
|
||||
* Scans segments in place rather than `key.split('.')`: [UsageSummary.from] makes
|
||||
* ~54 of these passes over the whole day bucket, the usage screen builds 16
|
||||
* summaries per entry on the main thread, and the churn counters roughly tripled
|
||||
* the key count — none of which can ever match (that is what
|
||||
* `noNewKeyContainsAReservedSegment` guarantees), so every split was pure waste.
|
||||
*/
|
||||
fun Map<String, Long>.sumMatching(vararg parts: String): Long {
|
||||
var total = 0L
|
||||
for ((key, value) in this) {
|
||||
val segments = key.split('.')
|
||||
if (parts.all { it in segments }) total += value
|
||||
if (parts.all { key.hasSegment(it) }) total += value
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** True when `segment` is one of this key's whole dot-delimited segments. */
|
||||
private fun String.hasSegment(segment: String): Boolean {
|
||||
var from = 0
|
||||
while (from <= length) {
|
||||
var end = indexOf('.', from)
|
||||
if (end < 0) end = length
|
||||
if (end - from == segment.length && regionMatches(from, segment, 0, segment.length)) return true
|
||||
from = end + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
/**
|
||||
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
|
||||
* an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia]
|
||||
* returns null for.
|
||||
*
|
||||
* It exists so those files have somewhere to land other than the video player: an unknown blob
|
||||
* used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a
|
||||
* zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the
|
||||
* card costs no network round-trip — unlike routing the URL through the OpenGraph previewer,
|
||||
* which would try to download the blob just to rediscover the type the event already declared.
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachmentCard(
|
||||
url: String,
|
||||
description: String?,
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val filename = remember(url) { extractFilename(url) }
|
||||
val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(url) },
|
||||
) {
|
||||
FileAttachmentRow(
|
||||
symbol = MaterialSymbols.AttachFile,
|
||||
// The alt/content text names the file for a human ("Webxdc app: Quake");
|
||||
// the hashed URL basename is the fallback when the event omits it.
|
||||
title = description?.ifBlank { null } ?: filename,
|
||||
subtitle = subtitle,
|
||||
titleMaxLines = 2,
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon + title + subtitle row shared by every card that stands in for a file it can't
|
||||
* render inline: this one and the PDF placeholder/skeleton in
|
||||
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
|
||||
*/
|
||||
@Composable
|
||||
internal fun FileAttachmentRow(
|
||||
symbol: MaterialSymbol,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
titleMaxLines: Int = 1,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "APK · 16 MB", dropping either half when the event doesn't declare it. */
|
||||
private fun fileSubtitle(
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
): String? =
|
||||
listOfNotNull(
|
||||
mimeType?.ifBlank { null }?.let(::prettyMime),
|
||||
sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes),
|
||||
).joinToString(" · ").ifEmpty { null }
|
||||
@@ -25,6 +25,8 @@ import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.FileProvider
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
|
||||
import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -37,6 +39,7 @@ object ShareHelper {
|
||||
private const val DEFAULT_IMAGE_EXTENSION = "jpg"
|
||||
private const val DEFAULT_VIDEO_EXTENSION = "mp4"
|
||||
private const val SHARED_FILE_PREFIX = "shared_media"
|
||||
private const val GENERIC_BINARY_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
data class SharableFile(
|
||||
val uri: Uri,
|
||||
@@ -65,6 +68,25 @@ object ShareHelper {
|
||||
private val MP4_BRAND_MP42 = "mp42".toByteArray()
|
||||
private val MOV_BRAND_QT = "qt ".toByteArray()
|
||||
|
||||
/**
|
||||
* Picks the MIME type to put on an `ACTION_SEND` intent.
|
||||
*
|
||||
* `Intent.type` has to be a real `type/subtype` — an `IntentFilter` matches the two halves
|
||||
* separately, so a slash-less value matches nothing and the chooser opens empty. The declared
|
||||
* type is author-supplied and may be unusable (see [normalizeMimeType]), so it is treated as a
|
||||
* hint; [fileExtension] is the safer signal because [getMediaExtension] sniffs it from the
|
||||
* file's magic numbers rather than trusting the event.
|
||||
*/
|
||||
internal fun resolveShareMimeType(
|
||||
declaredMimeType: String?,
|
||||
fileExtension: String,
|
||||
): String =
|
||||
normalizeMimeType(declaredMimeType)
|
||||
?: mimeTypeMap[fileExtension.lowercase()]
|
||||
// Unreachable today: getMediaExtension only ever returns keys of mimeTypeMap. Kept so
|
||||
// the return type stays a well-formed MIME if that ever stops holding.
|
||||
?: GENERIC_BINARY_MIME_TYPE
|
||||
|
||||
suspend fun getSharableUriFromUrl(
|
||||
context: Context,
|
||||
imageUrl: String,
|
||||
|
||||
+3
-3
@@ -1103,7 +1103,7 @@ private suspend fun shareImageFile(
|
||||
val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri)
|
||||
|
||||
// Determine mime type, use provided or derive from extension
|
||||
val determinedMimeType = mimeType ?: "image/$fileExtension"
|
||||
val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, fileExtension)
|
||||
|
||||
// Create share intent
|
||||
val shareIntent =
|
||||
@@ -1161,7 +1161,7 @@ private suspend fun shareVideoFile(
|
||||
sharedFile = sharableFile
|
||||
|
||||
// Determine mime type
|
||||
val determinedMimeType = mimeType ?: "video/$extension"
|
||||
val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension)
|
||||
|
||||
// Create share intent
|
||||
val shareIntent =
|
||||
@@ -1227,7 +1227,7 @@ private suspend fun shareLocalVideoFile(
|
||||
val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile)
|
||||
|
||||
// Determine mime type
|
||||
val determinedMimeType = mimeType ?: "video/$extension"
|
||||
val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension)
|
||||
|
||||
// Create share intent
|
||||
val shareIntent =
|
||||
|
||||
+6
-39
@@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) {
|
||||
private fun FilenameRow(
|
||||
filename: String,
|
||||
subtitle: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = filename,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) = FileAttachmentRow(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
title = filename,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
||||
private fun renderFirstPage(
|
||||
file: java.io.File,
|
||||
|
||||
@@ -263,6 +263,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
|
||||
@@ -578,6 +579,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
|
||||
-28
@@ -20,13 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@@ -58,29 +56,3 @@ fun keyboardAsState(): State<KeyboardState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BackHandler] that steps aside while the soft keyboard is on screen.
|
||||
*
|
||||
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
|
||||
* When that pop happens while the keyboard is still up, it races the predictive-back window
|
||||
* animation against the IME's close animation. On release builds — fast enough that the window
|
||||
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
|
||||
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
|
||||
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
|
||||
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
|
||||
* screen).
|
||||
*
|
||||
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
|
||||
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
|
||||
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
|
||||
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
|
||||
*/
|
||||
@Composable
|
||||
fun KeyboardAwareBackHandler(
|
||||
enabled: Boolean = true,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val keyboardState by keyboardAsState()
|
||||
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
|
||||
}
|
||||
|
||||
+22
-66
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
|
||||
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
|
||||
* Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a
|
||||
* subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections).
|
||||
* Order in this enum has no semantic meaning.
|
||||
*/
|
||||
@Serializable
|
||||
enum class NavBarItem {
|
||||
@@ -84,6 +84,18 @@ enum class NavBarItem {
|
||||
FAVORITE_ALGO_FEEDS,
|
||||
}
|
||||
|
||||
private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name }
|
||||
|
||||
/**
|
||||
* Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings
|
||||
* blob synced from a newer client can name a destination that doesn't exist here yet, and that must
|
||||
* degrade to "ignore this one row" rather than failing the decode of the whole blob.
|
||||
*/
|
||||
fun navBarItemsFromNames(names: Collection<String>): Set<NavBarItem> = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] }
|
||||
|
||||
/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */
|
||||
fun Set<NavBarItem>.toNames(): List<String> = map { it.name }.sorted()
|
||||
|
||||
data class NavBarItemDef(
|
||||
val id: NavBarItem,
|
||||
val labelRes: Int,
|
||||
@@ -443,34 +455,6 @@ val DefaultBottomBarItems: List<NavBarItem> =
|
||||
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
|
||||
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
|
||||
|
||||
// Ordered membership lists for each drawer section. The drawer renders these by looking up
|
||||
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
|
||||
// matching section list below — not two separate files.
|
||||
val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
/**
|
||||
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
|
||||
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
|
||||
@@ -479,6 +463,7 @@ val DrawerYouItems: List<NavBarItem> =
|
||||
*/
|
||||
data class NavBarCategory(
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
)
|
||||
|
||||
@@ -491,6 +476,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
listOf(
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_main,
|
||||
MaterialSymbols.Home,
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
@@ -501,6 +487,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_chats,
|
||||
MaterialSymbols.Group,
|
||||
listOf(
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
@@ -510,6 +497,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_you,
|
||||
MaterialSymbols.AccountCircle,
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
@@ -527,6 +515,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_feeds,
|
||||
MaterialSymbols.Subscriptions,
|
||||
listOf(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.LONGS,
|
||||
@@ -553,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_apps,
|
||||
MaterialSymbols.Apps,
|
||||
listOf(
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.FAVORITE_APPS,
|
||||
@@ -563,43 +553,9 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_other,
|
||||
MaterialSymbols.Settings,
|
||||
listOf(
|
||||
NavBarItem.SETTINGS,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
+59
-44
@@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
|
||||
@@ -584,42 +582,17 @@ fun ListContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Per-account, synced through the NIP-78 app-specific data event, and edited on the
|
||||
// Side Menu settings screen. Empty (the default) means the full stock drawer.
|
||||
val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier) {
|
||||
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_create) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_system) {
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
|
||||
NavBarCatalog[NavBarItem.SETTINGS]?.let {
|
||||
CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav)
|
||||
DrawerSections.forEach { section ->
|
||||
// Keyed by section: hiding the last row of a section removes it from the drawer
|
||||
// entirely, and without a key the sections below would slide up into its slots and
|
||||
// inherit its CollapsibleSection expanded/collapsed state.
|
||||
key(section.id) {
|
||||
CatalogSection(section, hidden, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,22 +607,64 @@ fun ListContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
|
||||
@Composable
|
||||
private fun CreateRows(nav: INav) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog].
|
||||
* Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
* Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't
|
||||
* switched off. Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
*
|
||||
* A section with nothing left to show renders nothing at all — an empty, permanently collapsed
|
||||
* heading is just noise. Two sections always have something: Create is entirely fixed rows, and
|
||||
* System carries the relay-status row (not a catalog destination — it shows a live counter).
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogSection(
|
||||
titleRes: Int,
|
||||
ids: List<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val onBackground = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
CollapsibleSection(title = titleRes) {
|
||||
ids.forEach { id ->
|
||||
val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) }
|
||||
if (visible.isEmpty() && !section.hasFixedRows) return
|
||||
|
||||
CollapsibleSection(title = section.titleRes) {
|
||||
when (section.id) {
|
||||
DrawerSectionId.CREATE -> CreateRows(nav)
|
||||
DrawerSectionId.SYSTEM ->
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
|
||||
visible.forEach { id ->
|
||||
NavBarCatalog[id]?.let { def ->
|
||||
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
|
||||
if (def.id == NavBarItem.SCHEDULED_POSTS) {
|
||||
|
||||
+104
@@ -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.navigation.drawer
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* Which drawer rows the user cannot hide.
|
||||
*
|
||||
* Settings is the only one, and it is mandatory for a specific reason: it is the route back to the
|
||||
* screen that hides rows in the first place. Hiding it would let a user lock themselves out of their
|
||||
* own configuration. Everything else the drawer always shows — the profile header, the relay-status
|
||||
* row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row,
|
||||
* so it is present by construction and never appears in the hidden set.
|
||||
*/
|
||||
val MandatoryDrawerItems: Set<NavBarItem> = setOf(NavBarItem.SETTINGS)
|
||||
|
||||
/**
|
||||
* Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are
|
||||
* exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI.
|
||||
*
|
||||
* The per-account preference stores the **hidden** items rather than the visible ones. That choice is
|
||||
* what makes a newly added destination appear for everyone automatically: a row nobody has ever
|
||||
* hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each
|
||||
* account's drawer at the moment they first touched the setting, and every later release would have
|
||||
* to migrate saved lists to introduce a screen.
|
||||
*/
|
||||
object DrawerItemVisibility {
|
||||
fun isVisible(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Boolean = item in MandatoryDrawerItems || item !in hidden
|
||||
|
||||
/** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */
|
||||
fun toggle(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Set<NavBarItem> =
|
||||
when {
|
||||
item in MandatoryDrawerItems -> hidden
|
||||
item in hidden -> hidden - item
|
||||
else -> hidden + item
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops mandatory rows from the set. The persistence layer is the single place this is enforced —
|
||||
* it runs on decode, on an external sync, and on every write — so a value synced from another
|
||||
* client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden.
|
||||
*
|
||||
* Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see
|
||||
* DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and
|
||||
* preserving it means editing the drawer on that device doesn't silently clear the choice the
|
||||
* user made on another one.
|
||||
*/
|
||||
fun sanitize(hidden: Set<NavBarItem>): Set<NavBarItem> = hidden - MandatoryDrawerItems
|
||||
|
||||
/** The rows of [section] to render, in the section's fixed order. */
|
||||
fun visibleItems(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): List<NavBarItem> = section.items.filter { isVisible(hidden, it) }
|
||||
|
||||
/** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */
|
||||
fun hiddenCount(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): Int = section.items.count { !isVisible(hidden, it) }
|
||||
|
||||
/** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */
|
||||
fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Hides every row of [section] that can be hidden, leaving the mandatory ones. */
|
||||
fun hideAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden + section.items.filter { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Shows every row of [section] again. */
|
||||
fun showAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden - section.items.toSet()
|
||||
|
||||
/** Total hidden rows across every section — the count the settings screen shows at the top. */
|
||||
fun totalHidden(hidden: Set<NavBarItem>): Int = DrawerSections.sumOf { hiddenCount(it, hidden) }
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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.navigation.drawer
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* The drawer's layout: which destinations it lists, under which heading, in which order.
|
||||
*
|
||||
* One list drives two screens — [ListContent] renders the visible rows of each section, and the Side
|
||||
* Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a
|
||||
* section's list therefore surfaces it in the drawer *and* in its configuration screen without
|
||||
* touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't
|
||||
* filed into exactly one section.
|
||||
*
|
||||
* Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a
|
||||
* menu whose headings move around is harder to learn, not easier. The only per-account choice is
|
||||
* which rows are visible — see [DrawerItemVisibility].
|
||||
*/
|
||||
@Immutable
|
||||
data class DrawerSection(
|
||||
val id: DrawerSectionId,
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
/**
|
||||
* True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]).
|
||||
* Such a section stays in the drawer even with every catalog row switched off, and — since a fixed
|
||||
* row is not a catalog destination — it never appears in the Side Menu settings screen's counts.
|
||||
*/
|
||||
val hasFixedRows: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Identifies a section for the handful of rendering rules that are specific to one. Matching on this
|
||||
* rather than on a section's object identity keeps those rules working if the list is ever mapped or
|
||||
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
|
||||
* compile error and nothing to fail a test.
|
||||
*/
|
||||
enum class DrawerSectionId {
|
||||
YOU,
|
||||
NAVIGATE,
|
||||
FEEDS,
|
||||
|
||||
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
|
||||
CREATE,
|
||||
|
||||
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
|
||||
SYSTEM,
|
||||
}
|
||||
|
||||
private val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
private val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.FAVORITE_ALGO_FEEDS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
private val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
val DrawerSections: List<DrawerSection> =
|
||||
listOf(
|
||||
DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems),
|
||||
DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems),
|
||||
DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems),
|
||||
DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true),
|
||||
DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true),
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite
|
||||
* Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost),
|
||||
* so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be
|
||||
* missing, and fails on anything else — that's what keeps a newly added destination from silently
|
||||
* skipping both the drawer and its settings screen.
|
||||
*/
|
||||
val SdkGatedDrawerItems: Set<NavBarItem> = setOf(NavBarItem.FAVORITE_APPS)
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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.navigation.navs
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** How long to wait for the IME inset to reach zero before navigating anyway. */
|
||||
const val IME_SETTLE_TIMEOUT_MS = 700L
|
||||
|
||||
/**
|
||||
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
|
||||
* in the app serializes the IME and window animations instead of overlapping them.
|
||||
*
|
||||
* Navigating while the keyboard is up races the window animation against the IME's close animation.
|
||||
* On release builds — fast enough that the window animation wins — the IME
|
||||
* [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before
|
||||
* its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it
|
||||
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
|
||||
* freezes at the keyboard height until some later inset pass happens to rebalance it.
|
||||
*
|
||||
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
|
||||
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
|
||||
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
|
||||
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
|
||||
* has done anything, and every way out of it is a navigation.
|
||||
*/
|
||||
fun interface ImeSettler {
|
||||
suspend fun settle()
|
||||
|
||||
companion object {
|
||||
/** For [EmptyNav] and previews, where there is no window to read insets from. */
|
||||
val None = ImeSettler { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
|
||||
* and the padding can never disagree about whether the keyboard is gone.
|
||||
*
|
||||
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
|
||||
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
|
||||
* guards against, navigation still proceeds rather than stranding the user on the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberImeSettler(): ImeSettler {
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
return remember(density, imeInsets, keyboard, focusManager) {
|
||||
ImeSettler {
|
||||
if (imeInsets.getBottom(density) > 0) {
|
||||
focusManager.clearFocus(true)
|
||||
keyboard?.hide()
|
||||
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
|
||||
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
|
||||
class Nav(
|
||||
val controller: NavHostController,
|
||||
override val navigationScope: CoroutineScope,
|
||||
/**
|
||||
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
|
||||
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
|
||||
* through this class, so this is the one place that has to get it right — no screen, top bar
|
||||
* or back handler needs to think about the keyboard on its way out.
|
||||
*/
|
||||
private val ime: ImeSettler = ImeSettler.None,
|
||||
) : INav {
|
||||
override val drawerState = DrawerState(DrawerValue.Closed)
|
||||
|
||||
@@ -63,6 +70,7 @@ class Nav(
|
||||
|
||||
override fun nav(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
if (getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
}
|
||||
@@ -71,6 +79,7 @@ class Nav(
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
val route = computeRoute()
|
||||
if (route != null && getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
@@ -80,6 +89,7 @@ class Nav(
|
||||
|
||||
override fun newStack(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(route) {
|
||||
inclusive = true
|
||||
@@ -91,6 +101,7 @@ class Nav(
|
||||
|
||||
override fun navBottomBar(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
// Clear sibling bottom-nav entries but keep Home (the start
|
||||
// destination) below, so back-swipe from any tab returns to
|
||||
@@ -149,6 +160,7 @@ class Nav(
|
||||
|
||||
override fun popBack() {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigateUp()
|
||||
}
|
||||
}
|
||||
@@ -159,6 +171,7 @@ class Nav(
|
||||
klass: KClass<T>,
|
||||
) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(klass) { inclusive = true }
|
||||
}
|
||||
|
||||
+3
-2
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
|
||||
fun rememberNav(): Nav {
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
val ime = rememberImeSettler()
|
||||
|
||||
return remember(navController, scope) {
|
||||
Nav(navController, scope)
|
||||
return remember(navController, scope, ime) {
|
||||
Nav(navController, scope, ime)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -457,6 +457,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object BottomBarSettings : Route()
|
||||
|
||||
@Serializable object DrawerSettings : Route()
|
||||
|
||||
@Serializable object HomeTabsSettings : Route()
|
||||
|
||||
@Serializable object ProfileUiSettings : Route()
|
||||
|
||||
@@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -43,50 +46,103 @@ fun FileHeaderDisplay(
|
||||
) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
val fullUrl = event.url() ?: return
|
||||
val mimeType = remember(note) { event.mimeType() }
|
||||
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
|
||||
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val blurHash = event.blurhash()
|
||||
val thumbHash = event.thumbhash()
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
|
||||
if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The sensitivity gate wraps both branches: a content warning is about the file, not about
|
||||
// which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate.
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
if (content == null) {
|
||||
FileHeaderAttachmentCard(event, fullUrl, mimeType)
|
||||
} else {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob.
|
||||
*
|
||||
* Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71
|
||||
* video event the kind itself asserts nothing about how to render the payload. A null here means
|
||||
* the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player.
|
||||
*/
|
||||
internal fun FileHeaderEvent.toMediaContent(
|
||||
note: Note,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): BaseMediaContent? {
|
||||
val blurHash = blurhash()
|
||||
val thumbHash = thumbhash()
|
||||
val hash = hash()
|
||||
val dimensions = dimensions()
|
||||
val description = fileDescription()
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
return when (RichTextParser.classifyMedia(url, mimeType)) {
|
||||
MediaContentKind.IMAGE ->
|
||||
MediaUrlImage(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.VIDEO ->
|
||||
MediaUrlVideo(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.PDF ->
|
||||
MediaUrlPdf(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */
|
||||
@Composable
|
||||
internal fun FileHeaderAttachmentCard(
|
||||
event: FileHeaderEvent,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val description = remember(event) { event.fileDescription() }
|
||||
val sizeInBytes = remember(event) { event.size()?.toLong() }
|
||||
|
||||
FileAttachmentCard(
|
||||
url = url,
|
||||
description = description,
|
||||
mimeType = mimeType,
|
||||
sizeInBytes = sizeInBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */
|
||||
private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt()
|
||||
|
||||
@@ -65,6 +65,7 @@ import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -766,27 +767,6 @@ fun RenderSoftwareAsset(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun prettyMime(mime: String): String =
|
||||
when (mime) {
|
||||
"application/vnd.android.package-archive" -> "APK"
|
||||
"application/vnd.apple.ipa" -> "IPA"
|
||||
"application/x-apple-diskimage" -> "DMG"
|
||||
"application/vnd.apple.installer+xml" -> "PKG"
|
||||
"application/x-msi" -> "MSI"
|
||||
"application/vnd.appimage" -> "AppImage"
|
||||
"application/vnd.flatpak" -> "Flatpak"
|
||||
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
|
||||
"application/x-executable" -> "ELF"
|
||||
"application/x-mach-binary" -> "Mach-O"
|
||||
"application/vnd.microsoft.portable-executable" -> "EXE"
|
||||
"application/vsix" -> "VSIX"
|
||||
"application/x-chrome-extension" -> "CRX"
|
||||
"application/x-xpinstall" -> "XPI"
|
||||
"application/wasm" -> "WASM"
|
||||
"application/webbundle" -> "Web Bundle"
|
||||
else -> mime
|
||||
}
|
||||
|
||||
internal fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024L) return "$bytes B"
|
||||
val kb = bytes / 1024.0
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -88,7 +89,9 @@ fun VideoDisplay(
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val description = videoEvent.content.ifBlank { null } ?: event.alt()
|
||||
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
if (isImage) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -55,7 +56,9 @@ fun JustVideoDisplay(
|
||||
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
|
||||
|
||||
val content by
|
||||
remember(note) {
|
||||
|
||||
+11
-1
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
@@ -1986,6 +1987,15 @@ class AccountViewModel(
|
||||
|
||||
fun bottomBarItemsFlow(): StateFlow<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
|
||||
|
||||
fun hiddenDrawerItemsFlow(): StateFlow<Set<NavBarItem>> = account.settings.syncedSettings.navigation.hiddenDrawerItems
|
||||
|
||||
/** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */
|
||||
fun changeHiddenDrawerItems(items: Set<NavBarItem>) {
|
||||
if (account.applyHiddenDrawerItems(items)) {
|
||||
launchSigner { account.sendNewAppSpecificData() }
|
||||
}
|
||||
}
|
||||
|
||||
fun changeBottomBarItems(items: List<BottomBarEntry>) {
|
||||
// Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay
|
||||
// ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would
|
||||
@@ -2096,7 +2106,7 @@ class AccountViewModel(
|
||||
|
||||
fun checkGetOrCreateUser(key: HexKey): User? = LocalCache.checkGetOrCreateUser(key)
|
||||
|
||||
override fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex)
|
||||
override fun getOrCreateUser(pubkey: HexKey): User = LocalCache.getOrCreateUser(pubkey)
|
||||
|
||||
fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex)
|
||||
|
||||
|
||||
+1
-3
@@ -109,9 +109,7 @@ class AgentConsoleViewModel : ViewModel() {
|
||||
relay?.let {
|
||||
val newlyJoined = BuzzWorkspaces.join(it)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
|
||||
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
|
||||
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
|
||||
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
if (newlyJoined) reconnectPoolAfterJoin(account.client)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
+1
-3
@@ -130,9 +130,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
|
||||
val newlyJoined = BuzzWorkspaces.join(relay)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
|
||||
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
|
||||
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
|
||||
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
if (newlyJoined) reconnectPoolAfterJoin(account.client)
|
||||
|
||||
// Paint from cache BEFORE any network work. [discoverMemberChannels] learns the channel ids
|
||||
// from a relay round-trip, so waiting on it left the Direct Messages section visibly empty
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
/**
|
||||
* Re-dials the whole relay pool after a Buzz workspace join.
|
||||
*
|
||||
* NIP-42 sends its AUTH challenge once, on connect. If the socket was already open
|
||||
* before the join (the common case — the relay is in the user's lists and connected
|
||||
* at startup), that challenge was spent while the relay was still NOT first-party,
|
||||
* so the connection is unauthenticated and every `#p=me`-gated read on it is
|
||||
* refused. Joining makes the relay first-party (see `AuthCoordinator.isFirstParty`);
|
||||
* this forces the relay to re-challenge so the connection authenticates.
|
||||
*
|
||||
* Shared by the three join sites that need the re-challenge, both to keep the
|
||||
* reconnect flags identical and to give the churn ledger one place to attribute from:
|
||||
* without the counter, `Σ(relay.trigger.*)` would only account for the
|
||||
* connectivity-driven teardowns `RelayProxyClientConnector` reports, and a full pool
|
||||
* teardown is the most expensive thing either can do.
|
||||
*
|
||||
* `BuzzInviteScreen` is a fourth join+pre-approve site that deliberately does NOT
|
||||
* reconnect — it hands off to the in-app browser rather than reading a `#p=me`-gated
|
||||
* subscription — so `relay.trigger.buzz` undercounts joins, not re-challenges.
|
||||
*/
|
||||
internal fun reconnectPoolAfterJoin(client: INostrClient) {
|
||||
// Guarded like every other ledger write that reaches the application singleton
|
||||
// (MediaPlayTimeTracker, the workers): a diagnostics counter must never break a
|
||||
// user-visible join, and `Amethyst.instance` is lateinit.
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.relayTrigger(UsageKeys.TRIGGER_BUZZ), 1) }
|
||||
client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
}
|
||||
+2
-8
@@ -107,14 +107,8 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
val newlyJoined = BuzzWorkspaces.join(normalized)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
|
||||
|
||||
// NIP-42 sends its AUTH challenge once, on connect. If the socket was already open before this
|
||||
// join (the common case — the relay is in the user's lists and connected at startup), that
|
||||
// challenge was spent while the relay was still NOT first-party, so the connection is
|
||||
// unauthenticated and the persistent group-roster (39002) subscription is refused. Joining
|
||||
// makes the relay first-party (see AuthCoordinator.isFirstParty); force a reconnect so the
|
||||
// relay re-challenges and the connection authenticates — unlocking the roster (Join gate) and
|
||||
// every other `#p=me`-gated read on the shared socket.
|
||||
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
// Unlocks the persistent group-roster (39002) subscription — see [reconnectPoolAfterJoin].
|
||||
if (newlyJoined) reconnectPoolAfterJoin(account.client)
|
||||
|
||||
// Track "already added" against the live kind-10009 list, scoped to this relay, so the rows
|
||||
// follow every add/remove — from here, from the channel's top bar, or from another device.
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler(
|
||||
// so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-2
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
|
||||
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
|
||||
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
|
||||
onSendNewMessage: () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
if (channelScreenModel.message.text.isNotBlank()) {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
|
||||
+39
-18
@@ -167,11 +167,19 @@ fun ConcordChannelListScreen(
|
||||
|
||||
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
|
||||
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
|
||||
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
|
||||
// control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3),
|
||||
// so the affordance waits for the key too.
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned
|
||||
// moderator kept seeing every control here. The editions they authored were dropped by everyone's
|
||||
// fold, which made these buttons silently no-op — worse than absent, and the same trap this file
|
||||
// already avoids for the Roles… menu.
|
||||
val canManageChannels =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
|
||||
} == true
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
// channelIdHex == null → create; else → rename that channel.
|
||||
var channelEditor by remember { mutableStateOf<ConcordChannelEditor?>(null) }
|
||||
@@ -234,10 +242,21 @@ fun ConcordChannelListScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Rank + the Control write key (CORD-02 §2), like [canManageChannels] above.
|
||||
val canEdit =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
// Minting an invite hands out a working key to the community, so it takes
|
||||
// CREATE_INVITE like any other privileged action. This button used to be the one
|
||||
// control on the screen with no gate at all.
|
||||
val canInvite =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE)
|
||||
} == true
|
||||
|
||||
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
|
||||
@@ -248,22 +267,24 @@ fun ConcordChannelListScreen(
|
||||
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
if (canInvite) {
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
|
||||
|
||||
+4
-1
@@ -133,7 +133,10 @@ fun ConcordMembersScreen(
|
||||
}
|
||||
|
||||
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
|
||||
// hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban /
|
||||
// Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn,
|
||||
// which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md.
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true
|
||||
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
|
||||
|
||||
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ class ConcordChannelHistorySubAssembler(
|
||||
// cursors so a late callback can't move another channel's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler(
|
||||
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler(
|
||||
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -78,7 +78,7 @@ fun EditFieldRow(
|
||||
onSendNewMessage: suspend () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
channelScreenModel.cancel()
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ class ChatroomListNip04HistorySubAssembler(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.chatroomList.nip04History
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ class ChessFeedFilterSubAssembler(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+210
-9
@@ -20,11 +20,36 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.search.GitRepositorySearchMatcher
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
@@ -34,8 +59,17 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
@Composable
|
||||
fun GitRepositoriesScreen(
|
||||
@@ -59,10 +93,31 @@ fun GitRepositoriesScreen(
|
||||
WatchAccountForGitRepositoriesScreen(gitRepositoriesFeedContentState = gitRepositoriesFeedContentState, accountViewModel = accountViewModel)
|
||||
GitRepositoriesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
// Search UI state is remembered across configuration changes so the
|
||||
// user doesn't lose their query when rotating; scoped to this screen,
|
||||
// not persisted to disk (unlike the follow-list filter above).
|
||||
var isSearchOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
GitRepositoriesTopBar(accountViewModel, nav)
|
||||
GitRepositoriesTopBar(
|
||||
isSearchOpen = isSearchOpen,
|
||||
onToggleSearch = {
|
||||
// Closing collapses the field AND clears the query so
|
||||
// the feed is fully restored — the icon acts as a
|
||||
// one-tap "reset" once the user has narrowed the view.
|
||||
if (isSearchOpen) {
|
||||
searchQuery = ""
|
||||
isSearchOpen = false
|
||||
} else {
|
||||
isSearchOpen = true
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.GitRepositories, nav, accountViewModel) { route ->
|
||||
@@ -75,20 +130,166 @@ fun GitRepositoriesScreen(
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableBox(gitRepositoriesFeedContentState, true) {
|
||||
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (isSearchOpen) {
|
||||
GitRepositorySearchField(
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
onClearQuery = { searchQuery = "" },
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
RefresheableBox(gitRepositoriesFeedContentState, true) {
|
||||
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
|
||||
val query = searchQuery
|
||||
if (query.isBlank()) {
|
||||
RenderFeedContentState(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
)
|
||||
} else {
|
||||
// When the filter is active we can't reuse the shared
|
||||
// scroll state because the filtered list has a different
|
||||
// set of item keys — using the same LazyListState would
|
||||
// make Compose try to restore an index that no longer
|
||||
// exists and jump the user to an unrelated repo. We
|
||||
// scope a fresh, per-query LazyListState so scrolling
|
||||
// stays inside the filtered view.
|
||||
RenderFilteredFeed(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
query = query,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline text field that drives the client-side ngit-repository search. Sits
|
||||
* directly under the top bar and above the feed so the user can see the
|
||||
* result of every keystroke narrow the list beneath it.
|
||||
*/
|
||||
@Composable
|
||||
private fun GitRepositorySearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
// Focus on first appearance so the keyboard opens without a second
|
||||
// tap. Subsequent recompositions inside the same session don't re-
|
||||
// request focus, which would fight with the user pressing "back to
|
||||
// the feed" via the field's clear-text icon.
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repositories_search_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = onClearQuery) {
|
||||
ClearTextIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the ngit repositories the user is already subscribed to, filtered
|
||||
* by [query]. Loading and error states are delegated to the shared
|
||||
* [RenderFeedContentState] via the appropriate branches; the loaded branch
|
||||
* is intercepted so we can filter the notes without touching the shared
|
||||
* feed model (which other screens also observe).
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RenderFilteredFeed(
|
||||
feedContentState: FeedContentState,
|
||||
query: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val filteredListState = rememberLazyListState()
|
||||
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = filteredListState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
onLoaded = { loaded ->
|
||||
val loadedItems by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
val filtered =
|
||||
remember(loadedItems, query) {
|
||||
loadedItems.list.filter { note ->
|
||||
val event = note.event as? GitRepositoryEvent ?: return@filter false
|
||||
GitRepositorySearchMatcher.matches(event, query)
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repositories_search_no_results),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = filteredListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
filtered,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||
NoteCompose(
|
||||
item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = loadedItems.showHidden,
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForGitRepositoriesScreen(
|
||||
gitRepositoriesFeedContentState: FeedContentState,
|
||||
|
||||
+81
-11
@@ -20,35 +20,105 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
/**
|
||||
* Top bar for the ngit repositories discovery screen.
|
||||
*
|
||||
* Two search affordances live side-by-side in the actions row:
|
||||
*
|
||||
* 1. A **repository filter** (magnifier-with-a-plus icon) that toggles
|
||||
* an inline text field over the loaded feed. This is the ngit-specific
|
||||
* search — it matches the fields NIP-34 announcements carry: name,
|
||||
* identifier, description, hashtags, clone/web/relay URLs, and
|
||||
* maintainer pubkeys. It filters what the user is already looking
|
||||
* at without touching relays.
|
||||
*
|
||||
* 2. The **generic Nostr search** (plain magnifier) that navigates to
|
||||
* the global [Route.Search] screen, matching the affordance on
|
||||
* every other top-level screen.
|
||||
*
|
||||
* Splitting them this way makes it obvious which magnifier does what: the
|
||||
* inline one narrows the current list, the outbound one opens the fleet-
|
||||
* wide search that also queries people, notes, hashtags, etc.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitRepositoriesTopBar(
|
||||
isSearchOpen: Boolean,
|
||||
onToggleSearch: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
GitRepositoriesTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
|
||||
)
|
||||
}
|
||||
GitRepositoriesTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
|
||||
actions = {
|
||||
IconButton(onClick = onToggleSearch) {
|
||||
Icon(
|
||||
symbol =
|
||||
if (isSearchOpen) {
|
||||
MaterialSymbols.Close
|
||||
} else {
|
||||
MaterialSymbols.FilterAlt
|
||||
},
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (isSearchOpen) {
|
||||
R.string.git_repositories_search_close
|
||||
} else {
|
||||
R.string.git_repositories_search_open
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { nav.nav(Route.Search) }) {
|
||||
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+22
@@ -170,6 +170,7 @@ fun RelayUrlEditField(
|
||||
nav: INav,
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
var isInvalid by remember { mutableStateOf(false) }
|
||||
|
||||
fun submitRelay() {
|
||||
if (url.isNotBlank()) {
|
||||
@@ -177,7 +178,13 @@ fun RelayUrlEditField(
|
||||
if (relay != null) {
|
||||
onNewRelay(relay)
|
||||
url = ""
|
||||
isInvalid = false
|
||||
relaySuggestions.reset()
|
||||
} else {
|
||||
// Without this the Add button is a silent no-op, which reads as a broken button.
|
||||
// Bare IPv6 literals are the common way to land here: an overlay-mesh address
|
||||
// pasted straight out of `yggdrasilctl getSelf` needs brackets to carry a port.
|
||||
isInvalid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,8 +196,23 @@ fun RelayUrlEditField(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
url = it
|
||||
isInvalid = false
|
||||
relaySuggestions.processInput(it)
|
||||
},
|
||||
isError = isInvalid,
|
||||
// Null, not an empty lambda: a non-null slot reserves its line height even when it
|
||||
// draws nothing, which would pad the field permanently for every user.
|
||||
supportingText =
|
||||
if (isInvalid) {
|
||||
{
|
||||
Text(
|
||||
text = stringRes(R.string.relay_url_not_valid),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "server.com",
|
||||
|
||||
+1
-1
@@ -456,7 +456,7 @@ class EventSync(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+89
-260
@@ -22,11 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
@@ -50,7 +45,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -60,7 +54,6 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -97,7 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
@@ -116,11 +109,6 @@ private val ExpandableItems =
|
||||
/** Soft guidance, not a hard cap: a Material bottom bar reads best at ~5 tabs. */
|
||||
private const val RECOMMENDED_SLOTS = 5
|
||||
|
||||
// Reveal expandable sections by unrolling straight down from the top edge (the default AnimatedVisibility
|
||||
// enter also expands horizontally from the bottom-end, which reads as a diagonal slide from the top-left).
|
||||
private val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
|
||||
private val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
|
||||
fun BottomBarSettingsScreenPreview() {
|
||||
@@ -157,14 +145,21 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
|
||||
// All pin/unpin/reorder logic lives in the holder (unit-tested); the composable only renders and
|
||||
// forwards events. Each persist republishes the account's NIP-78 settings event. syncFrom re-seeds
|
||||
// when the saved list changes elsewhere without clobbering a drag.
|
||||
//
|
||||
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
|
||||
// holder that outlived an account switch would write account A's edits to account B. It cannot:
|
||||
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
|
||||
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
|
||||
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
|
||||
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
|
||||
val state = remember { BottomBarSettingsState(savedItems) { accountViewModel.changeBottomBarItems(it) } }
|
||||
LaunchedEffect(savedItems) { state.syncFrom(savedItems) }
|
||||
|
||||
val pinned = state.pinned
|
||||
val pinnedKeys = remember(pinned) { state.pinnedKeys() }
|
||||
|
||||
val expandedCategories = remember { mutableStateMapOf<Int, Boolean>() }
|
||||
val expandedItems = remember { mutableStateMapOf<NavBarItem, Boolean>() }
|
||||
val expandedCategories = rememberExpandedKeys<Int>()
|
||||
val expandedItems = rememberExpandedKeys<NavBarItem>()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
@@ -177,26 +172,19 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
|
||||
// --- The editable bar: a real preview you drag to reorder and tap ✕ to remove from. ---
|
||||
EditableBarCard(state, pinned, accountViewModel)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = { state.restoreDefault() }) {
|
||||
Text(stringRes(R.string.bottom_bar_settings_restore_default))
|
||||
}
|
||||
}
|
||||
RestoreDefaultRow(onClick = { state.restoreDefault() })
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// --- Available catalogue, grouped into collapsible category cards. ---
|
||||
SectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
|
||||
PickerSectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
|
||||
|
||||
BottomBarCategories.forEach { category ->
|
||||
CategoryCard(
|
||||
category = category,
|
||||
pinnedKeys = pinnedKeys,
|
||||
expanded = expandedCategories[category.titleRes] ?: false,
|
||||
onToggleExpand = { expandedCategories[category.titleRes] = !(expandedCategories[category.titleRes] ?: false) },
|
||||
expanded = expandedCategories.isExpanded(category.titleRes),
|
||||
onToggleExpand = { expandedCategories.toggle(category.titleRes) },
|
||||
expandedItems = expandedItems,
|
||||
accountViewModel = accountViewModel,
|
||||
onTogglePin = state::togglePin,
|
||||
@@ -217,60 +205,43 @@ private fun EditableBarCard(
|
||||
pinned: List<BottomBarEntry>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = accent.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.bottom_bar_settings_pinned),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "${pinned.size} / $RECOMMENDED_SLOTS",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (pinned.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringRes(R.string.bottom_bar_settings_pinned_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
EditableBar(state, pinned, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
PickerHeroCard(
|
||||
title = stringRes(R.string.bottom_bar_settings_pinned),
|
||||
trailing = {
|
||||
Text(
|
||||
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
text = "${pinned.size} / $RECOMMENDED_SLOTS",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (pinned.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringRes(R.string.bottom_bar_settings_pinned_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
EditableBar(state, pinned, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,76 +437,37 @@ private fun CategoryCard(
|
||||
pinnedKeys: Set<String>,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
expandedItems: SnapshotStateMap<NavBarItem, Boolean>,
|
||||
expandedItems: ExpandedKeys<NavBarItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onTogglePin: (BottomBarEntry) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
|
||||
CatalogCard(
|
||||
icon = category.icon,
|
||||
title = stringRes(category.titleRes),
|
||||
expanded = expanded,
|
||||
onToggleExpand = onToggleExpand,
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(34.dp)
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
category.items.forEach { item ->
|
||||
val def = NavBarCatalog[item] ?: return@forEach
|
||||
val entry = BottomBarEntry.BuiltIn(item)
|
||||
if (item in ExpandableItems) {
|
||||
ExpandableAvailableRow(
|
||||
icon = def.icon,
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
expanded = expandedItems.isExpanded(item),
|
||||
onTogglePin = { onTogglePin(entry) },
|
||||
onToggleExpand = { expandedItems.toggle(item) },
|
||||
) {
|
||||
Icon(
|
||||
symbol = categoryIcon(category.titleRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(category.titleRes),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
} else {
|
||||
AvailableRow(
|
||||
leading = { LeadingGlyph(def.icon) },
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
onToggle = { onTogglePin(entry) },
|
||||
)
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
|
||||
Column(Modifier.padding(bottom = 6.dp)) {
|
||||
category.items.forEach { item ->
|
||||
val def = NavBarCatalog[item] ?: return@forEach
|
||||
val entry = BottomBarEntry.BuiltIn(item)
|
||||
if (item in ExpandableItems) {
|
||||
ExpandableAvailableRow(
|
||||
icon = def.icon,
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
expanded = expandedItems[item] ?: false,
|
||||
onTogglePin = { onTogglePin(entry) },
|
||||
onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) },
|
||||
) {
|
||||
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
|
||||
}
|
||||
} else {
|
||||
AvailableRow(
|
||||
leading = { LeadingGlyph(def.icon) },
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
onToggle = { onTogglePin(entry) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -757,18 +689,6 @@ private fun ConcordServerPickerGroup(
|
||||
// Rows & shared bits
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
|
||||
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
|
||||
* NIP-29 group under its relay, or a Concord channel under its community).
|
||||
*/
|
||||
private fun indentPadding(level: Int) =
|
||||
when (level) {
|
||||
0 -> 13.dp
|
||||
1 -> 24.dp
|
||||
else -> 40.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvailableRow(
|
||||
leading: @Composable () -> Unit,
|
||||
@@ -777,23 +697,12 @@ private fun AvailableRow(
|
||||
onToggle: () -> Unit,
|
||||
indentLevel: Int = 0,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(start = indentPadding(indentLevel), end = 13.dp, top = 7.dp, bottom = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
CatalogRow(
|
||||
leading = leading,
|
||||
label = label,
|
||||
onToggle = onToggle,
|
||||
indentLevel = indentLevel,
|
||||
) {
|
||||
leading()
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AddPill(added = pinned, onClick = onToggle)
|
||||
}
|
||||
}
|
||||
@@ -808,27 +717,15 @@ private fun ExpandableAvailableRow(
|
||||
onToggleExpand: () -> Unit,
|
||||
children: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggleExpand)
|
||||
.padding(start = 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
CatalogRow(
|
||||
leading = { LeadingGlyph(icon) },
|
||||
label = label,
|
||||
onToggle = onToggleExpand,
|
||||
) {
|
||||
LeadingGlyph(icon)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = stringRes(R.string.bottom_bar_settings_expand),
|
||||
modifier = Modifier.size(22.dp),
|
||||
modifier = Size22Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
AddPill(added = pinned, onClick = onTogglePin)
|
||||
@@ -838,52 +735,18 @@ private fun ExpandableAvailableRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outlined "Add" that fills to "Added" once pinned — states the action and its result. Both states
|
||||
* share one Row body (only color/border/tint differ) so the pill keeps a constant height and the rows
|
||||
* stay aligned whether an item is added or not.
|
||||
*/
|
||||
/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */
|
||||
@Composable
|
||||
private fun AddPill(
|
||||
added: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val content = if (added) MaterialTheme.colorScheme.onPrimary else accent
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (added) accent else Color.Transparent,
|
||||
border = if (added) null else BorderStroke(1.dp, accent),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = content,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A category/destination glyph in a soft accent-tinted circle. */
|
||||
@Composable
|
||||
private fun LeadingGlyph(icon: MaterialSymbol) {
|
||||
Box(
|
||||
modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(symbol = icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
TogglePill(
|
||||
on = added,
|
||||
label = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
|
||||
icon = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** A favorite web-app / nsite / napplet's real favicon in a tinted circle (glyph fallback). */
|
||||
@@ -902,40 +765,6 @@ private fun FavoriteLeading(app: FavoriteApp) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyChildHint(
|
||||
textRes: Int,
|
||||
indentLevel: Int = 1,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(textRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = indentPadding(indentLevel), end = 13.dp, top = 6.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun categoryIcon(titleRes: Int): MaterialSymbol =
|
||||
when (titleRes) {
|
||||
R.string.bottom_bar_category_main -> MaterialSymbols.Home
|
||||
R.string.bottom_bar_category_chats -> MaterialSymbols.Group
|
||||
R.string.bottom_bar_category_you -> MaterialSymbols.AccountCircle
|
||||
R.string.bottom_bar_category_feeds -> MaterialSymbols.Subscriptions
|
||||
R.string.bottom_bar_category_apps -> MaterialSymbols.Apps
|
||||
else -> MaterialSymbols.Settings
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Leading/label resolution for a pinned entry (built-in glyph, favorite icon, or group avatar).
|
||||
// Computed once so a group's channel is subscribed at most once per row.
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
|
||||
fun DrawerSettingsScreenPreview() {
|
||||
ThemeComparisonRow {
|
||||
DrawerSettingsScreen(
|
||||
mockAccountViewModel(),
|
||||
EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerSettingsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(stringRes(id = R.string.drawer_settings), nav)
|
||||
},
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding)) {
|
||||
DrawerSettingsContent(accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide editor for the side menu's rows. It renders [DrawerSections] directly — the very list the
|
||||
* drawer renders — so a destination added to a section shows up here with no work, and a row that
|
||||
* exists here always exists there.
|
||||
*/
|
||||
@Composable
|
||||
fun DrawerSettingsContent(accountViewModel: AccountViewModel) {
|
||||
// Per-account, synced through the NIP-78 app-specific data event.
|
||||
val savedHidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
// All show/hide logic lives in the holder (unit-tested); the composable only renders and forwards
|
||||
// events. Each edit republishes the account's NIP-78 settings event. syncFrom re-seeds when the
|
||||
// saved set changes elsewhere.
|
||||
//
|
||||
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
|
||||
// holder that outlived an account switch would write account A's edits to account B. It cannot:
|
||||
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
|
||||
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
|
||||
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
|
||||
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
|
||||
val state = remember { DrawerSettingsState(savedHidden) { accountViewModel.changeHiddenDrawerItems(it) } }
|
||||
LaunchedEffect(savedHidden) { state.syncFrom(savedHidden) }
|
||||
|
||||
// Sections start collapsed: expanded, they are ~50 rows of scrolling. The header's hidden
|
||||
// counter is what tells the user which one to open.
|
||||
val expandedSections = rememberExpandedKeys<DrawerSectionId>()
|
||||
|
||||
val totalHidden = state.totalHidden()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
SummaryCard(totalHidden)
|
||||
|
||||
RestoreDefaultRow(onClick = { state.restoreDefault() })
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
PickerSectionHeader(title = stringRes(R.string.drawer_settings_sections))
|
||||
|
||||
// A section with no catalog rows has nothing to configure (Create is composer entry points),
|
||||
// so it isn't listed here even though the drawer renders it.
|
||||
DrawerSections.forEach { section ->
|
||||
if (section.items.isEmpty()) return@forEach
|
||||
SectionCard(
|
||||
section = section,
|
||||
state = state,
|
||||
expanded = expandedSections.isExpanded(section.id),
|
||||
onToggleExpand = { expandedSections.toggle(section.id) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** What the setting does and how far from stock the menu currently is. */
|
||||
@Composable
|
||||
private fun SummaryCard(totalHidden: Int) {
|
||||
PickerHeroCard(
|
||||
title = stringRes(R.string.drawer_settings_title),
|
||||
trailing = {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.drawer_settings_hidden_count, totalHidden, totalHidden),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.drawer_settings_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
section: DrawerSection,
|
||||
state: DrawerSettingsState,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
) {
|
||||
// Each card reads the same coarse `hidden` state, so without derivedStateOf a toggle in one
|
||||
// section would recompose (and re-count) all of them.
|
||||
val hiddenHere by remember(section) { derivedStateOf { state.hiddenCount(section) } }
|
||||
|
||||
CatalogCard(
|
||||
icon = section.icon,
|
||||
title = stringRes(section.titleRes),
|
||||
expanded = expanded,
|
||||
onToggleExpand = onToggleExpand,
|
||||
trailing = {
|
||||
if (hiddenHere > 0) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.drawer_settings_hidden_count, hiddenHere, hiddenHere),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Bulk actions: turning ~29 feed rows off one at a time is the kind of chore that makes
|
||||
// people give up halfway and leave the menu in a worse state than they found it.
|
||||
if (DrawerItemVisibility.hasHideableRows(section)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 6.dp, end = 6.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = { state.showAll(section) }) {
|
||||
Text(stringRes(R.string.drawer_settings_show_all))
|
||||
}
|
||||
TextButton(onClick = { state.hideAll(section) }) {
|
||||
Text(stringRes(R.string.drawer_settings_hide_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section.items.forEach { item ->
|
||||
val def = NavBarCatalog[item] ?: return@forEach
|
||||
val mandatory = item in MandatoryDrawerItems
|
||||
val visible = state.isVisible(item)
|
||||
CatalogRow(
|
||||
leading = { LeadingGlyph(def.icon) },
|
||||
label = stringRes(def.labelRes),
|
||||
onToggle = if (mandatory) null else ({ state.toggle(item) }),
|
||||
) {
|
||||
VisibilityPill(visible = visible, mandatory = mandatory, onClick = { state.toggle(item) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled "Visible" / outlined "Hidden" — the bottom bar's Add/Added pill, saying what this screen
|
||||
* says instead. A mandatory row gets a locked "Always on" badge: it reads as deliberately fixed
|
||||
* rather than as a control that ignores taps.
|
||||
*/
|
||||
@Composable
|
||||
private fun VisibilityPill(
|
||||
visible: Boolean,
|
||||
mandatory: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// One branch decides both halves of the pill, so a label can't drift away from its glyph.
|
||||
val (labelRes, icon) =
|
||||
when {
|
||||
mandatory -> R.string.drawer_settings_always_on to MaterialSymbols.Lock
|
||||
visible -> R.string.drawer_settings_visible to MaterialSymbols.Visibility
|
||||
else -> R.string.drawer_settings_hidden to MaterialSymbols.VisibilityOff
|
||||
}
|
||||
|
||||
TogglePill(
|
||||
on = visible,
|
||||
label = stringRes(labelRes),
|
||||
icon = icon,
|
||||
enabled = !mandatory,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
|
||||
|
||||
/**
|
||||
* State holder for the Side Menu settings screen: owns the set of switched-off drawer rows and the
|
||||
* show / hide / restore-default operations, so the composable only renders and forwards events.
|
||||
*
|
||||
* The rules themselves live in [DrawerItemVisibility] (pure, unit-tested); this adds only the Compose
|
||||
* state and the write-through to the account's synced settings. Unlike the bottom bar there is no
|
||||
* transient/commit split — a toggle is a single discrete edit, not a drag, so every change persists
|
||||
* immediately.
|
||||
*
|
||||
* No sanitizing here: every value in is either already sanitized by the persistence layer or produced
|
||||
* by a [DrawerItemVisibility] operation that can't introduce a mandatory row, and the write side
|
||||
* sanitizes again anyway. One authority, not three.
|
||||
*/
|
||||
@Stable
|
||||
class DrawerSettingsState(
|
||||
initial: Set<NavBarItem>,
|
||||
private val persist: (Set<NavBarItem>) -> Unit,
|
||||
) {
|
||||
var hidden by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
fun isVisible(item: NavBarItem): Boolean = DrawerItemVisibility.isVisible(hidden, item)
|
||||
|
||||
fun toggle(item: NavBarItem) = update(DrawerItemVisibility.toggle(hidden, item))
|
||||
|
||||
fun hiddenCount(section: DrawerSection): Int = DrawerItemVisibility.hiddenCount(section, hidden)
|
||||
|
||||
fun totalHidden(): Int = DrawerItemVisibility.totalHidden(hidden)
|
||||
|
||||
fun showAll(section: DrawerSection) = update(DrawerItemVisibility.showAll(hidden, section))
|
||||
|
||||
fun hideAll(section: DrawerSection) = update(DrawerItemVisibility.hideAll(hidden, section))
|
||||
|
||||
/** Back to the stock drawer: nothing hidden. */
|
||||
fun restoreDefault() = update(emptySet())
|
||||
|
||||
/**
|
||||
* Re-seed from an external change (the saved settings flow emitted) without re-persisting. A no-op
|
||||
* when equal, so the echo of our own [persist] doesn't fight an in-progress edit.
|
||||
*/
|
||||
fun syncFrom(items: Set<NavBarItem>) {
|
||||
if (items != hidden) hidden = items
|
||||
}
|
||||
|
||||
private fun update(newHidden: Set<NavBarItem>) {
|
||||
if (newHidden == hidden) return
|
||||
hidden = newHidden
|
||||
persist(newHidden)
|
||||
}
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size12dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size13dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size14dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size6dp
|
||||
|
||||
/**
|
||||
* The shared visual language of the navigation-configuration screens — the Bottom Navigation Bar
|
||||
* picker and the Side Menu picker. Both present the same shape (collapsible cards of catalog rows,
|
||||
* each row a glyph + label + a pill stating its current state), so the pieces live here once and
|
||||
* each screen supplies only its own semantics: the bottom bar pins and reorders entries, the side
|
||||
* menu switches rows on and off.
|
||||
*
|
||||
* [SectionExpand]/[SectionCollapse] reveal expandable sections by unrolling straight down from the
|
||||
* top edge (the default AnimatedVisibility enter also expands horizontally from the bottom-end,
|
||||
* which reads as a diagonal slide from the top-left).
|
||||
*/
|
||||
val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
|
||||
val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||
|
||||
/**
|
||||
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
|
||||
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
|
||||
* NIP-29 group under its relay, or a Concord channel under its community).
|
||||
*/
|
||||
private fun indentPadding(level: Int) =
|
||||
when (level) {
|
||||
0 -> Size13dp
|
||||
1 -> Size24dp
|
||||
else -> Size40dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Which collapsible rows of a picker are currently open, keyed by whatever identifies a row (a
|
||||
* section id, a string-resource id, a catalog item). Absent means collapsed, so the initial state
|
||||
* costs nothing and no list has to be seeded.
|
||||
*/
|
||||
@Stable
|
||||
class ExpandedKeys<K> {
|
||||
private val open = mutableStateMapOf<K, Boolean>()
|
||||
|
||||
fun isExpanded(key: K): Boolean = open[key] == true
|
||||
|
||||
fun toggle(key: K) {
|
||||
open[key] = !isExpanded(key)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <K> rememberExpandedKeys(): ExpandedKeys<K> = remember { ExpandedKeys() }
|
||||
|
||||
@Composable
|
||||
fun PickerSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = Size18dp, bottom = Size6dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A category/destination glyph in a soft accent-tinted circle. */
|
||||
@Composable
|
||||
fun LeadingGlyph(icon: MaterialSymbol) {
|
||||
Box(
|
||||
modifier = SimpleImage35Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(symbol = icon, contentDescription = null, modifier = Size19Modifier, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyChildHint(
|
||||
textRes: Int,
|
||||
indentLevel: Int = 1,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(textRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = indentPadding(indentLevel), end = Size13dp, top = Size6dp, bottom = Size6dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One catalog row: leading visual, label, and a caller-supplied [trailing] state control. Tapping
|
||||
* anywhere on the row runs [onToggle]; pass null for a row whose state can't change (a mandatory
|
||||
* side-menu item), which also drops the ripple so the row doesn't advertise an action it won't take.
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogRow(
|
||||
leading: @Composable () -> Unit,
|
||||
label: String,
|
||||
onToggle: (() -> Unit)?,
|
||||
indentLevel: Int = 0,
|
||||
trailing: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (onToggle != null) it.clickable(onClick = onToggle) else it }
|
||||
.padding(start = indentPadding(indentLevel), end = Size13dp, top = 7.dp, bottom = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size12dp),
|
||||
) {
|
||||
leading()
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state pill at the end of a catalog row: outlined in the "off" state, filled in the "on" state —
|
||||
* so it states both the current state and, by contrast, that it can be changed. Both states share one
|
||||
* Row body (only color/border/tint differ) so the pill keeps a constant height and rows stay aligned.
|
||||
*
|
||||
* [enabled] false renders the pill as a locked, non-interactive badge — used for a row the user isn't
|
||||
* allowed to switch off.
|
||||
*/
|
||||
@Composable
|
||||
fun TogglePill(
|
||||
on: Boolean,
|
||||
label: String,
|
||||
icon: MaterialSymbol,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val container = if (enabled) accent else MaterialTheme.colorScheme.surfaceVariant
|
||||
val content =
|
||||
when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
on -> MaterialTheme.colorScheme.onPrimary
|
||||
else -> accent
|
||||
}
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (on) container else Color.Transparent,
|
||||
border = if (on) null else BorderStroke(1.dp, content),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.let { if (enabled) it.clickable(onClick = onClick) else it }
|
||||
.padding(horizontal = Size14dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Size15Modifier,
|
||||
tint = content,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsible card holding catalog rows. [trailing] renders between the title and the chevron —
|
||||
* the side menu puts its "n hidden" counter there; the bottom bar leaves it empty.
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogCard(
|
||||
icon: MaterialSymbol,
|
||||
title: String,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
trailing: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(Size13dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size12dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Size34dp)
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Size24Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
|
||||
Column(Modifier.padding(bottom = Size6dp)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The accent-tinted card each picker opens with: a bold title, an optional [trailing] status, and a
|
||||
* body. The bottom bar puts its editable preview bar in the body; the side menu puts its description.
|
||||
*/
|
||||
@Composable
|
||||
fun PickerHeroCard(
|
||||
title: String,
|
||||
trailing: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
Surface(
|
||||
shape = RoundedCornerShape(Size22dp),
|
||||
color = accent.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
|
||||
) {
|
||||
Column(Modifier.padding(Size14dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = Size10dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The end-aligned "Restore Default" action both pickers put under their hero card. */
|
||||
@Composable
|
||||
fun RestoreDefaultRow(onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onClick) {
|
||||
Text(stringRes(R.string.bottom_bar_settings_restore_default))
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-13
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -46,11 +47,13 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -67,6 +70,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssem
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
|
||||
@@ -77,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
@@ -579,6 +584,18 @@ private fun SendReportSection(
|
||||
today: Long,
|
||||
memory: MemorySnapshot?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Suspending, and off Main: assembling the report walks every counter of every
|
||||
// retained day (UsageSummary makes ~54 passes per summary) over a key space the
|
||||
// churn counters grew by an order of magnitude. `scope` is Main.immediate, so a
|
||||
// bare `scope.launch { }` would still build it on the UI thread — the
|
||||
// withContext is what actually moves it. Only the handover (clipboard, chooser,
|
||||
// navigation) stays on Main.
|
||||
suspend fun buildReport(): String = withContext(Dispatchers.Default) { ResourceUsageReportAssembler().buildReport(days, today, memory) }
|
||||
|
||||
SettingsSection(R.string.resource_usage_send_section) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -589,21 +606,57 @@ private fun SendReportSection(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val report = ResourceUsageReportAssembler().buildReport(days, today, memory)
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
draftMessage = report,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
)
|
||||
}
|
||||
},
|
||||
// The DM route needs a composer to exist before the text does, which makes
|
||||
// it a poor fit for reading the report yourself — copying out of a draft
|
||||
// message is the only way to get at it. Copy and Share hand over the same
|
||||
// string directly, for pasting into an issue or saving to a file.
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(stringRes(R.string.resource_usage_send_button))
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch { clipboard.setText(buildReport()) }
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.resource_usage_copy_button))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val send =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, buildReport())
|
||||
putExtra(Intent.EXTRA_TITLE, stringRes(context, R.string.resource_usage_send_section))
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(send, stringRes(context, R.string.resource_usage_share_button)),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.resource_usage_share_button))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val report = buildReport()
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
draftMessage = report,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.resource_usage_send_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -72,6 +72,7 @@ fun buildSettingsCatalog(
|
||||
symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings),
|
||||
symEntry(R.string.messages_settings, MaterialSymbols.Mail, R.string.messages_settings_search_keywords, Route.MessagesSettings),
|
||||
symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings),
|
||||
symEntry(R.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, R.string.drawer_search_keywords, Route.DrawerSettings),
|
||||
symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings),
|
||||
symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings),
|
||||
symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
|
||||
|
||||
+4
-1
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -104,7 +105,9 @@ private fun VideoCardImage(
|
||||
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
|
||||
|
||||
val content by
|
||||
remember(note) {
|
||||
|
||||
+22
-37
@@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -38,10 +37,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop
|
||||
@@ -51,9 +47,10 @@ import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
|
||||
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderAttachmentCard
|
||||
import com.vitorpamplona.amethyst.ui.note.types.toMediaContent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
|
||||
@@ -101,45 +98,33 @@ private fun FileHeaderCardImage(
|
||||
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
|
||||
val mimeType = remember(note) { event.mimeType() }
|
||||
val blurHash = remember(note) { event.blurhash() }
|
||||
val thumbHash = remember(note) { event.thumbhash() }
|
||||
val dimensions = remember(note) { event.dimensions() }
|
||||
|
||||
val content by remember(note) {
|
||||
val hash = event.hash()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
|
||||
|
||||
mutableStateOf<BaseMediaContent>(
|
||||
if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
},
|
||||
)
|
||||
// Reachable despite VideoFeedFilter admitting only image/video types: the filter accepts on
|
||||
// `urls().any { … }` while this card renders `url()`, the first tag — so a multi-mirror event
|
||||
// whose first URL is unrenderable lands here. The gate wraps it for the same reason it wraps
|
||||
// the viewer in FileHeaderDisplay: a content warning is about the file, and the card still
|
||||
// spells out its filename, alt text, MIME and size. Sizing stays on the gate's defaults
|
||||
// (fillMaxWidth, no backdrop) — a link card has no aspect ratio to reserve and no blurhash
|
||||
// to show behind it.
|
||||
if (content == null) {
|
||||
ContentWarningGate(
|
||||
isSensitive = isSensitive,
|
||||
reasons = reasons,
|
||||
preloadUrls = emptyList(),
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
FileHeaderAttachmentCard(event, fullUrl, mimeType)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val isImage = content is MediaUrlImage
|
||||
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl)
|
||||
|
||||
ContentWarningGate(
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
<string name="connection_success_rate_description">Procento úspěšných připojení k relé</string>
|
||||
<string name="search_and_add_a_user">Vyhledat a přidat uživatele</string>
|
||||
<string name="add_a_relay">Přidat přeposílání</string>
|
||||
<string name="relay_url_not_valid">Neplatná adresa relaye. Použijte název hostitele nebo IP adresu v hranatých závorkách (například [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Moje @tag jméno</string>
|
||||
<string name="display_name">Zobrazované jméno</string>
|
||||
<string name="my_display_name">Moje zobrazované jméno</string>
|
||||
@@ -2024,14 +2025,105 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d relay</item>
|
||||
<item quantity="few">%1$s \u00b7 %2$d relaye</item>
|
||||
<item quantity="many">%1$s \u00b7 %2$d relaye</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d relayů</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Procházení</string>
|
||||
<string name="relay_purpose_media">Média</string>
|
||||
<string name="relay_purpose_tags">Hashtagy</string>
|
||||
<string name="relay_purpose_topics">Témata</string>
|
||||
<string name="relay_purpose_thread">Konverzace</string>
|
||||
<string name="relay_purpose_search">Hledání</string>
|
||||
<string name="relay_purpose_referenced">Hledání chybějících událostí</string>
|
||||
<string name="relay_purpose_engagement">Sledování událostí</string>
|
||||
<string name="relay_explain_referenced">Načítá podle ID události, na které se něco na obrazovce odkazuje, ale zatím je nemáte — citaci, rodiče odpovědi, kořen vlákna.</string>
|
||||
<string name="relay_explain_engagement">Sleduje právě zobrazené události kvůli novým odpovědím, reakcím, sdílením, zapům a nahlášením, takže se počty aktualizují během čtení.</string>
|
||||
<string name="relay_purpose_add_ons">Doplňky</string>
|
||||
<string name="relay_purpose_relay_info">Informace o relayi</string>
|
||||
<string name="relay_purpose_other">Ostatní</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Vyhledávač seznamů relayů</string>
|
||||
<string name="relay_purpose_observing_profiles">Sledování profilů</string>
|
||||
<string name="relay_purpose_your_account">Data účtu</string>
|
||||
<string name="relay_purpose_home_feed">Domovský zdroj</string>
|
||||
<string name="relay_purpose_relay_groups">Relay skupiny</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d skupina</item>
|
||||
<item quantity="few">%1$d skupiny</item>
|
||||
<item quantity="many">%1$d skupiny</item>
|
||||
<item quantity="other">%1$d skupin</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Mizící chaty</string>
|
||||
<string name="relay_purpose_geohash_chats">Chaty podle místa</string>
|
||||
<string name="relay_purpose_live_chat">Chat živého vysílání</string>
|
||||
<string name="relay_explain_relay_groups">Skupiny NIP-29, do kterých jste vstoupili. Každá skupina žije na jednom hostitelském relayi, takže se aplikace připojí ke každému relayi, který hostí některou vaši skupinu.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chatovací místnosti bez historie — zprávy existují jen po dobu vašeho připojení, proto zůstávají odebírané, aby vůbec něco přišlo.</string>
|
||||
<string name="relay_explain_geohash_chats">Místnosti podle polohy pro oblasti, které sledujete, dotazované na relayích, které je nesou.</string>
|
||||
<string name="relay_explain_live_chat">Chat a zapovací cíle připojené k živým vysíláním, která máte otevřená nebo sledujete.</string>
|
||||
<string name="relay_purpose_dm_inbox">Schránka DM</string>
|
||||
<string name="relay_purpose_your_wallet">Peněženka</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Schránka nutzapů</string>
|
||||
<string name="relay_purpose_mint_directory">Adresář mintů</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Chaty komunit</string>
|
||||
<string name="relay_purpose_community_feeds">Zdroje komunit</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Vaše relaye pro příjem a k tomu malý rotující vzorek relayů, kam publikují vaši sledovaní, pro případ, že by zmínka byla doručena jinam.</string>
|
||||
<string name="relay_explain_direct_messages">Vaše relaye pro schránku DM, kam se doručují zprávy zabalené v gift-wrapu.</string>
|
||||
<string name="relay_explain_public_chats">Domovský relay každého chatu, který máte otevřený nebo do kterého jste se připojili.</string>
|
||||
<string name="relay_explain_community_chats">Relaye, na které každá komunita publikuje své roviny.</string>
|
||||
<string name="relay_explain_encrypted_groups">Skupinové zprávy a balíčky klíčů na relayích každé skupiny.</string>
|
||||
<string name="relay_explain_live_rooms">Relaye místnosti, dokud je otevřená.</string>
|
||||
<string name="relay_explain_account_data">Váš vlastní profil, nastavení a koncepty na vašich domovských relayích.</string>
|
||||
<string name="relay_explain_profiles">Profily lidí právě na obrazovce.</string>
|
||||
<string name="relay_explain_relay_lists">Zjišťuje, na které relaye každý člověk publikuje, aby se jeho příspěvky daly načíst na správném místě.</string>
|
||||
<string name="relay_explain_follows">Seznamy sledovaných, ze kterých se sestavuje váš zdroj a vaše síť důvěry.</string>
|
||||
<string name="relay_explain_moderation">Nahlášení, která vaši sledovaní napsali o profilech právě na obrazovce, dotazovaná na každém relayi, kam tito sledovaní publikují.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Nahlášení od sledovaných</string>
|
||||
<string name="relay_explain_wallet">Události vaší vlastní peněženky, čtené zpět z relayů, na které jste je publikovali.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Naslouchá na vašich nutzap relayích a také na relayích pro příjem a DM, aby vám neunikla žádná platba.</string>
|
||||
<string name="relay_explain_mint_directory">Prohledává relaye, které minty existují a které lidé doporučují.</string>
|
||||
<string name="relay_explain_nwc">Upozornění z vaší připojené peněženky.</string>
|
||||
<string name="active_subs_title">Aktivní odběry relayů</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filtr</item>
|
||||
<item quantity="few">%1$d filtry</item>
|
||||
<item quantity="many">%1$d filtru</item>
|
||||
<item quantity="other">%1$d filtrů</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d relay</item>
|
||||
<item quantity="few">%1$d relaye</item>
|
||||
<item quantity="many">%1$d relaye</item>
|
||||
<item quantity="other">%1$d relayů</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtr zatím není přiřazen</item>
|
||||
<item quantity="few">%1$d filtry zatím nejsou přiřazeny</item>
|
||||
<item quantity="many">%1$d filtru zatím není přiřazeno</item>
|
||||
<item quantity="other">%1$d filtrů zatím není přiřazeno</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Nepřiřazeno k žádnému účtu</string>
|
||||
<string name="active_subs_no_entity">Vše</string>
|
||||
<string name="active_subs_scope_global">Všichni</string>
|
||||
<string name="active_subs_scope_follows">Lidé, které sledujete</string>
|
||||
<string name="active_subs_scope_authors">Vybraný seznam lidí</string>
|
||||
<string name="active_subs_scope_muted">Ztlumení lidé</string>
|
||||
<string name="active_subs_scope_all_communities">Vaše komunity</string>
|
||||
<string name="active_subs_scope_algo">Oblíbený algoritmický zdroj</string>
|
||||
<string name="active_subs_share">%1$d %% ze všech</string>
|
||||
<string name="active_subs_search_keywords">odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika</string>
|
||||
<string name="relay_explain_home">Příspěvky lidí, které sledujete, čtené z relayů, na které každý z nich publikuje.</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>
|
||||
@@ -3255,6 +3347,21 @@
|
||||
<string name="bottom_bar_category_feeds">Kanály</string>
|
||||
<string name="bottom_bar_category_apps">Aplikace a web</string>
|
||||
<string name="bottom_bar_category_other">Ostatní</string>
|
||||
<string name="drawer_settings">Boční nabídka</string>
|
||||
<string name="drawer_settings_title">Vaše boční nabídka</string>
|
||||
<string name="drawer_settings_description">Otevřete sekci a vypněte řádky, které nikdy nepoužíváte. Nastavení zůstává vždy viditelné, takže se sem vždy vrátíte. Pořadí sekcí je pevné.</string>
|
||||
<string name="drawer_settings_sections">Sekce</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d skrytá</item>
|
||||
<item quantity="few">%1$d skryté</item>
|
||||
<item quantity="many">%1$d skrytých</item>
|
||||
<item quantity="other">%1$d skrytých</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">Viditelné</string>
|
||||
<string name="drawer_settings_hidden">Skryté</string>
|
||||
<string name="drawer_settings_always_on">Vždy zapnuto</string>
|
||||
<string name="drawer_settings_show_all">Zobrazit vše</string>
|
||||
<string name="drawer_settings_hide_all">Skrýt vše</string>
|
||||
<string name="home_tabs_settings">Záložky domova</string>
|
||||
<string name="home_tabs_settings_description">Vyberte, které záložky se zobrazí na domovské obrazovce. Pokud je aktivní jen jedna záložka, lišta záložek se skryje.</string>
|
||||
<string name="home_tab_everything">Vše</string>
|
||||
@@ -3634,6 +3741,10 @@
|
||||
<string name="git_repo_settings_save">Uložit</string>
|
||||
<string name="git_repositories">Git repozitáře</string>
|
||||
<string name="highlights">Zvýraznění</string>
|
||||
<string name="git_repositories_search_open">Filtrovat repozitáře</string>
|
||||
<string name="git_repositories_search_close">Zavřít filtr</string>
|
||||
<string name="git_repositories_search_placeholder">Filtrovat podle názvu, tématu, hostitele, správce…</string>
|
||||
<string name="git_repositories_search_no_results">Tomuto hledání neodpovídají žádné repozitáře v aktuálním kanálu.</string>
|
||||
<string name="nsite_title">Statický web: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Oprávnění:</string>
|
||||
@@ -4726,6 +4837,7 @@
|
||||
<string name="buzz_workflow_gate_needs_you">Potřebuje vaše schválení</string>
|
||||
<string name="buzz_workflow_gate_awaiting">Čeká na schválení</string>
|
||||
<string name="buzz_workflow_no_description">(bez popisu)</string>
|
||||
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
|
||||
<string name="buzz_workflow_by">od</string>
|
||||
<string name="buzz_workflow_waiting_on">čeká na</string>
|
||||
<string name="buzz_workflow_readonly_approver">Jste schvalovatel, ale toto přihlášení nemůže rozhodnutí podepsat.</string>
|
||||
@@ -4761,6 +4873,7 @@
|
||||
<string name="buzz_workflow_no_defs_hint">Zatím žádná workflow. Otevřete nabídku výše, zvolte „Nová definice…“, vytvořte ho a pak spusťte.</string>
|
||||
<string name="buzz_workflow_task_label">Co má udělat?</string>
|
||||
<string name="buzz_workflow_trigger_run">Spustit běh</string>
|
||||
<string name="buzz_workflow_picker_label">Workflow</string>
|
||||
<string name="buzz_workflow_picker_empty">Zatím nejsou definována žádná workflow</string>
|
||||
<string name="buzz_workflow_picker_choose">Vyberte workflow</string>
|
||||
<string name="buzz_workflow_new_definition">Nová definice…</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Prozentsatz erfolgreicher Verbindungen zum Relay</string>
|
||||
<string name="search_and_add_a_user">Benutzer suchen und hinzufügen</string>
|
||||
<string name="add_a_relay">Relay hinzufügen</string>
|
||||
<string name="relay_url_not_valid">Keine gültige Relay-Adresse. Verwende einen Hostnamen oder eine IP-Adresse in Klammern (zum Beispiel [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Mein @tag-Name</string>
|
||||
<string name="display_name">Anzeigename</string>
|
||||
<string name="my_display_name">Mein Anzeigename</string>
|
||||
@@ -1942,14 +1943,91 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d Relay</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d Relays</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Stöbern</string>
|
||||
<string name="relay_purpose_media">Medien</string>
|
||||
<string name="relay_purpose_topics">Themen</string>
|
||||
<string name="relay_purpose_thread">Unterhaltung</string>
|
||||
<string name="relay_purpose_search">Suche</string>
|
||||
<string name="relay_purpose_referenced">Fehlende Events finden</string>
|
||||
<string name="relay_purpose_engagement">Events beobachten</string>
|
||||
<string name="relay_explain_referenced">Holt Events per ID, auf die etwas auf deinem Bildschirm verweist, die du aber noch nicht hast — ein Zitat, die übergeordnete Antwort, eine Thread-Wurzel.</string>
|
||||
<string name="relay_explain_engagement">Beobachtet die gerade angezeigten Events auf neue Antworten, Reaktionen, Reposts, Zaps und Meldungen, damit die Zähler beim Lesen aktuell bleiben.</string>
|
||||
<string name="relay_purpose_add_ons">Erweiterungen</string>
|
||||
<string name="relay_purpose_relay_info">Relay-Info</string>
|
||||
<string name="relay_purpose_other">Sonstiges</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Relay-Listen-Finder</string>
|
||||
<string name="relay_purpose_observing_profiles">Profile beobachten</string>
|
||||
<string name="relay_purpose_your_account">Kontodaten</string>
|
||||
<string name="relay_purpose_home_feed">Startseiten-Feed</string>
|
||||
<string name="relay_purpose_relay_groups">Relay-Gruppen</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d Gruppe</item>
|
||||
<item quantity="other">%1$d Gruppen</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Verschwindende Chats</string>
|
||||
<string name="relay_purpose_geohash_chats">Standort-Chats</string>
|
||||
<string name="relay_purpose_live_chat">Live-Stream-Chat</string>
|
||||
<string name="relay_explain_relay_groups">NIP-29-Gruppen, denen du beigetreten bist. Jede Gruppe liegt auf einem Host-Relay, daher verbindet sich die App mit jedem Relay, das eine deiner Gruppen beherbergt.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chaträume ohne Verlauf — Nachrichten existieren nur, solange du verbunden bist, deshalb bleiben sie abonniert, damit überhaupt etwas ankommt.</string>
|
||||
<string name="relay_explain_geohash_chats">Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen.</string>
|
||||
<string name="relay_explain_live_chat">Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst.</string>
|
||||
<string name="relay_purpose_dm_inbox">DM-Posteingang</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Nutzap-Posteingang</string>
|
||||
<string name="relay_purpose_mint_directory">Mint-Verzeichnis</string>
|
||||
<string name="relay_purpose_community_chats">Community-Chats</string>
|
||||
<string name="relay_purpose_community_feeds">Community-Feeds</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Deine Posteingangs-Relays plus eine kleine, rotierende Stichprobe der Relays, auf denen deine Gefolgten veröffentlichen, falls eine Erwähnung woanders zugestellt wurde.</string>
|
||||
<string name="relay_explain_direct_messages">Deine DM-Posteingangs-Relays, an die Gift-Wrap-Nachrichten zugestellt werden.</string>
|
||||
<string name="relay_explain_public_chats">Das Heim-Relay jedes Chats, den du geöffnet hast oder dem du beigetreten bist.</string>
|
||||
<string name="relay_explain_community_chats">Die Relays, auf denen jede Community ihre Planes veröffentlicht.</string>
|
||||
<string name="relay_explain_encrypted_groups">Gruppennachrichten und Schlüsselpakete auf den Relays der jeweiligen Gruppe.</string>
|
||||
<string name="relay_explain_live_rooms">Die Relays des Raums, solange er geöffnet ist.</string>
|
||||
<string name="relay_explain_account_data">Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays.</string>
|
||||
<string name="relay_explain_profiles">Profile der gerade angezeigten Personen.</string>
|
||||
<string name="relay_explain_relay_lists">Findet heraus, auf welchen Relays jede Person veröffentlicht, damit ihre Beiträge an der richtigen Stelle abgerufen werden können.</string>
|
||||
<string name="relay_explain_follows">Folgelisten, aus denen dein Feed und dein Web of Trust aufgebaut werden.</string>
|
||||
<string name="relay_explain_moderation">Meldungen, die deine Gefolgten über die gerade angezeigten Profile geschrieben haben, abgefragt bei jedem Relay, auf dem diese Gefolgten veröffentlichen.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Meldungen von Gefolgten</string>
|
||||
<string name="relay_explain_wallet">Deine eigenen Wallet-Events, zurückgelesen von den Relays, auf denen du sie veröffentlicht hast.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Lauscht auf deinen Nutzap-Relays sowie deinen Posteingangs- und DM-Relays, damit keine Zahlung durchrutscht.</string>
|
||||
<string name="relay_explain_mint_directory">Sucht über Relays hinweg, welche Mints existieren und welche empfohlen werden.</string>
|
||||
<string name="relay_explain_nwc">Benachrichtigungen von deiner verbundenen Wallet.</string>
|
||||
<string name="active_subs_title">Aktive Relay-Abonnements</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d Filter</item>
|
||||
<item quantity="other">%1$d Filter</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d Relay</item>
|
||||
<item quantity="other">%1$d Relays</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item>
|
||||
<item quantity="other">%1$d Filter sind noch nicht zugeordnet</item>
|
||||
</plurals>
|
||||
<string name="active_subs_unattributed">Keinem Konto zugeordnet</string>
|
||||
<string name="active_subs_no_entity">Alle</string>
|
||||
<string name="active_subs_scope_global">Jeder</string>
|
||||
<string name="active_subs_scope_follows">Personen, denen du folgst</string>
|
||||
<string name="active_subs_scope_authors">Eine ausgewählte Liste von Personen</string>
|
||||
<string name="active_subs_scope_muted">Stummgeschaltete Personen</string>
|
||||
<string name="active_subs_scope_all_communities">Deine Communitys</string>
|
||||
<string name="active_subs_scope_algo">Ein bevorzugter Feed-Algorithmus</string>
|
||||
<string name="active_subs_share">%1$d %% von allen</string>
|
||||
<string name="active_subs_search_keywords">abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose</string>
|
||||
<string name="relay_explain_home">Beiträge von Personen, denen du folgst, gelesen von den Relays, auf denen jede von ihnen veröffentlicht.</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>
|
||||
@@ -3134,6 +3212,19 @@
|
||||
<string name="bottom_bar_category_feeds">Feeds</string>
|
||||
<string name="bottom_bar_category_apps">Apps & Web</string>
|
||||
<string name="bottom_bar_category_other">Sonstiges</string>
|
||||
<string name="drawer_settings">Seitenmenü</string>
|
||||
<string name="drawer_settings_title">Dein Seitenmenü</string>
|
||||
<string name="drawer_settings_description">Öffne einen Bereich und schalte die Zeilen aus, die du nie nutzt. Einstellungen bleibt immer sichtbar, damit du jederzeit hierher zurückkommst. Die Reihenfolge der Bereiche ist fest.</string>
|
||||
<string name="drawer_settings_sections">Bereiche</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d ausgeblendet</item>
|
||||
<item quantity="other">%1$d ausgeblendet</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">Sichtbar</string>
|
||||
<string name="drawer_settings_hidden">Ausgeblendet</string>
|
||||
<string name="drawer_settings_always_on">Immer an</string>
|
||||
<string name="drawer_settings_show_all">Alle anzeigen</string>
|
||||
<string name="drawer_settings_hide_all">Alle ausblenden</string>
|
||||
<string name="home_tabs_settings">Startseiten-Tabs</string>
|
||||
<string name="home_tabs_settings_description">Wähle, welche Tabs auf der Startseite erscheinen. Wenn nur ein Tab aktiv ist, wird die Tab-Leiste ausgeblendet.</string>
|
||||
<string name="home_tab_everything">Alles</string>
|
||||
@@ -3506,6 +3597,10 @@
|
||||
<string name="git_repo_settings_topics">Themen (durch Komma getrennt)</string>
|
||||
<string name="git_repo_settings_save">Speichern</string>
|
||||
<string name="git_repositories">Git Repositories</string>
|
||||
<string name="git_repositories_search_open">Repositories filtern</string>
|
||||
<string name="git_repositories_search_close">Filter schließen</string>
|
||||
<string name="git_repositories_search_placeholder">Nach Name, Thema, Host, Betreuer filtern…</string>
|
||||
<string name="git_repositories_search_no_results">Keine Repositories im aktuellen Feed passen zu dieser Suche.</string>
|
||||
<string name="nsite_title">Statische Website: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Berechtigungen:</string>
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
<string name="violence">हिंसा</string>
|
||||
<string name="unknown_author">अज्ञात लेखक</string>
|
||||
<string name="copy_text">लेख की अनुकृति करें</string>
|
||||
<string name="copy_user_pubkey">लेखक विभेदक की अनुकृति करें</string>
|
||||
<string name="copy_note_id">टीका विभेदक की अनुकृति करें</string>
|
||||
<string name="copy_user_pubkey">लेखक सूचक की अनुकृति करें</string>
|
||||
<string name="copy_note_id">टीका सूचक की अनुकृति करें</string>
|
||||
<string name="copy_raw_json">कच्चा जेसोन॰ की अनुकृति करें</string>
|
||||
<string name="broadcast">प्रसारण</string>
|
||||
<string name="timestamp_it">समयांकन करें</string>
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">सफल संयोजनों का प्रतिशत पुनःप्रसारक के साथ</string>
|
||||
<string name="search_and_add_a_user">ढूँढें तथा प्रयेक्ता जोडें</string>
|
||||
<string name="add_a_relay">पुनःप्रसारक जोडें</string>
|
||||
<string name="relay_url_not_valid">मान्य पुनःप्रसारक पता नहीं। एक जालावास नाम का उपयोग करें। अथवा कोष्ठकों में एक अंकीय जालपता उदाहरण [201:d0e:9ba5:8bbc::1]:8080 के जैसे।</string>
|
||||
<string name="my_name">मेरा @सूचक नाम</string>
|
||||
<string name="display_name">प्रदर्शन नाम</string>
|
||||
<string name="my_display_name">मेरा प्रदर्शन नाम</string>
|
||||
@@ -219,7 +220,7 @@
|
||||
<string name="download_to_phone">संचारयन्त्र में अभिलेखन करें</string>
|
||||
<string name="image_saved_to_the_gallery">चित्र का अभिलेखन किया गया चित्रालय क्रमक में</string>
|
||||
<string name="video_download_has_started_toast">चलचित्र अवरोहण आरम्भ हुआ …</string>
|
||||
<string name="media_download_has_started_toast">अभिलेख अवरोहण आरम्भ हुआ …</string>
|
||||
<string name="media_download_has_started_toast">श्रव्यदृश्याभिलेख अवरोहण आरम्भ हुआ …</string>
|
||||
<string name="pin_conversation">ऊपर टाँकें</string>
|
||||
<string name="unpin_conversation">टाँका हटाएँ</string>
|
||||
<string name="pinned_to_top">ऊपर टँकित</string>
|
||||
@@ -270,7 +271,7 @@
|
||||
<string name="relays">" पुनःप्रसारक"</string>
|
||||
<string name="website">जालस्थान</string>
|
||||
<string name="lightning_address">लैटनिंग पता</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">एनसेक॰ विभेदक (आपका गुप्त पारणशब्द) की अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">एनसेक॰ सूचक (आपका गुप्त पारणशब्द) की अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए</string>
|
||||
<string name="show_private_key_qr_code">निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ।</string>
|
||||
<string name="show_encrypted_private_key_qr_code">रहस्यीकृत निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ।</string>
|
||||
<string name="send_a_direct_message">सीधा संदेश भेजें</string>
|
||||
@@ -278,7 +279,7 @@
|
||||
<string name="follow">अनुचरण करें</string>
|
||||
<string name="follow_back">प्रत्यानुचरण करें</string>
|
||||
<string name="unblock">बाधा हटाएँ</string>
|
||||
<string name="copy_user_id">उपयोगकर्ता विभेदक की अनुकृति</string>
|
||||
<string name="copy_user_id">प्रयोक्ता सूचक की अनुकृति</string>
|
||||
<string name="unblock_user">उपयोगकर्ता बाधा हटाएँ</string>
|
||||
<string name="user_is_blocked_hidden">प्रयोक्ता बाधित अथवा मौनकृत। उनके पत्र छिपाए गए।</string>
|
||||
<string name="npub_hex_username">"एनपुब॰, उपयोगकर्ता नाम, लेख"</string>
|
||||
@@ -326,7 +327,7 @@
|
||||
<string name="concord_channel_name_label">प्रणाली नाम</string>
|
||||
<string name="concord_channel_delete">प्रणाली मिटाएँ</string>
|
||||
<string name="concord_channel_delete_title">क्या प्रणाली मिटा दें।</string>
|
||||
<string name="concord_channel_delete_message">क्या #%1$s को मिटा दें। इसको पूर्ववत नहीं किया जा सकता। तथा उसी विभेदक के साथ प्रणाली का पुनःउत्पादन नहीं किया जा सकता।</string>
|
||||
<string name="concord_channel_delete_message">क्या #%1$s को मिटा दें। इसको पूर्ववत नहीं किया जा सकता। तथा उसी सूचक के साथ प्रणाली का पुनःउत्पादन नहीं किया जा सकता।</string>
|
||||
<string name="concord_channel_delete_confirm">मिटाएँ</string>
|
||||
<string name="concord_leave_community">समुदाय छोडें</string>
|
||||
<string name="concord_leave_title">क्या समुदाय छोडें।</string>
|
||||
@@ -509,7 +510,7 @@
|
||||
<string name="award_granted_to">पदक पुरस्कार इनको दिया गया</string>
|
||||
<string name="copied_note_text_to_clipboard">टीका लेख की अनुकृति की गई टाँकाफलक में</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">लेखक के @npub की अनुकृति की गई टाँकाफलक में</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">टीका विभेदक (@note1) की अनुकृति की गई टाँकाफलक में</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">टीका सूचक (@note1) की अनुकृति की गई टाँकाफलक में</string>
|
||||
<string name="private_conversation_notification">"<निजी संदेश का अरहस्यीकरण असफल>\n\nआप का उल्लेख किया गया एक निजी अथवा रहस्यीकृत संवाद में %1$s तथा %2$s के बीच।"</string>
|
||||
<string name="account_switch_add_account_dialog_title">नयी लेखा जोडें</string>
|
||||
<string name="drawer_accounts">लेखाएँ</string>
|
||||
@@ -538,14 +539,14 @@
|
||||
<string name="share_as_qr_code_description_web">क्यूआर॰ चित्र जिसमें इस टीका का एक जाल योजक समाविष्ट है</string>
|
||||
<string name="share_as_qr_code_description_nostr">क्यूआर॰ चित्र जिसमें इस टीका का एक नोस्टर योजक समाविष्ट है</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">अंगुलचित्र छिपाया गया संवेदनशिल विषयवस्तु के कारण</string>
|
||||
<string name="quick_action_copy_user_id">लेखक विभेदक</string>
|
||||
<string name="quick_action_copy_note_id">टीका विभेदक</string>
|
||||
<string name="quick_action_copy_user_id">लेखक सूचक</string>
|
||||
<string name="quick_action_copy_note_id">टीका सूचक</string>
|
||||
<string name="quick_action_copy_text">लेख की अनुकृति करें</string>
|
||||
<string name="quick_action_delete">मिटाएँ</string>
|
||||
<string name="quick_action_unfollow">अनुचरण ना करें</string>
|
||||
<string name="quick_action_follow">अनुचरण करें</string>
|
||||
<string name="quick_action_request_deletion_gallery_title">चित्रालय से मिटाएँ</string>
|
||||
<string name="quick_action_request_deletion_gallery_alert_body_v2">इस अभिलेख को आपके चित्रालय से हटाएँ।</string>
|
||||
<string name="quick_action_request_deletion_gallery_alert_body_v2">इस श्रव्यदृश्याभिलेख को आपके चित्रालय से हटाएँ।</string>
|
||||
<string name="quick_action_request_deletion_alert_title">हटाने की याचना</string>
|
||||
<string name="quick_action_request_deletion_alert_body">अमेथिस्ट अनुरोध करेगा कि आपका टीका मिटा दिया जाए उन पुनःप्रसारकों से जिनके साथ आप अब जुडे हुए हैं। कोई आश्वासन नहीं कि आपका टीका सर्वदा के लिए मिटा दिया जाएगा उन पुनःप्रसारकों से, अथवा अन्य पुनःप्रसारकों में से जहाँ यह रखा गया हो।</string>
|
||||
<string name="quick_action_block_dialog_btn">बाधित करें</string>
|
||||
@@ -637,7 +638,7 @@
|
||||
<item quantity="one">%d पुनःप्रसारक तक</item>
|
||||
<item quantity="other">%d पुनःप्रसारकों तक</item>
|
||||
</plurals>
|
||||
<string name="scheduled_posts_event_id_copied">टीका विभेदक की अनुकृति की गई</string>
|
||||
<string name="scheduled_posts_event_id_copied">टीका सूचक की अनुकृति की गई</string>
|
||||
<string name="polls">मतदान</string>
|
||||
<string name="open_polls">खुला</string>
|
||||
<string name="closed_polls">आवृत</string>
|
||||
@@ -649,7 +650,7 @@
|
||||
<string name="new_community_description">विवरण</string>
|
||||
<string name="new_community_rules">नियम (विकल्पात्मक)</string>
|
||||
<string name="new_community_pick_cover">आवरण चित्र जोडें</string>
|
||||
<string name="new_community_pick_cover_hint">चित्र चुनने के लिए दबाएँ। वह आपके प्रसारसंगणक तक आरोहित किया जाएगा।</string>
|
||||
<string name="new_community_pick_cover_hint">चित्र चुनने के लिए दबाएँ। वह आपके श्रव्यदृश्याभिलेख सेवासंगणक तक आरोहित किया जाएगा।</string>
|
||||
<string name="new_community_moderators_section">नियामक</string>
|
||||
<string name="new_community_moderators_hint">नियामक अनुमति दे सकते हैं पत्र प्रकाशन के लिए। आप सदैव नियामक हैं।</string>
|
||||
<string name="new_community_add_moderator">नियामक जोडें</string>
|
||||
@@ -845,7 +846,7 @@
|
||||
<string name="napplet_cap_storage_desc">उसका अपना निजी भण्डार</string>
|
||||
<string name="napplet_cap_value_desc">लैटनिंग चालान का भुगतान</string>
|
||||
<string name="napplet_cap_resource_desc">जाल तथा ब्लोस्सम॰ संसाधन ले आएँ</string>
|
||||
<string name="napplet_cap_upload_desc">आपके प्रसारसंगणक तक अभिलेखों का आरोहण</string>
|
||||
<string name="napplet_cap_upload_desc">आपके श्रव्यदृश्याभिलेख सेवासंगणक तक अभिलेखों का आरोहण</string>
|
||||
<string name="napplet_cap_theme">प्रदर्शनशैली</string>
|
||||
<string name="napplet_cap_notify">सूचनाएँ</string>
|
||||
<string name="napplet_cap_inc">सन्देश प्रेषण</string>
|
||||
@@ -869,7 +870,7 @@
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_pay_no_amount">सावधान। यह नोस्टरसंलग्नक्रमक एक लैटनिंग चालान का भुगतान करना चाहता है जिसमें कोई मात्रा स्पष्टीकृत नहीं। प्राप्तकर्ता निर्णय करेगा कितना लिया जाएगा। इसे तभी अनुमति दें यदि आप इस पर विश्वास करते हैं।</string>
|
||||
<string name="napplet_consent_resource">यह नोस्टर संलग्नक्रमक एक जाल संसाधन लाना चाहता है।</string>
|
||||
<string name="napplet_consent_upload">यह नोस्टर संलग्नक्रमक एक अभिलेख को आपके प्रसारसंगणक तक आरोहण करना चाहता है।</string>
|
||||
<string name="napplet_consent_upload">यह नोस्टर संलग्नक्रमक एक अभिलेख को आपके श्रव्यदृश्याभिलेख सेवासंगणक तक आरोहण करना चाहता है।</string>
|
||||
<string name="napplet_consent_notify">यह नोस्टर संलग्नक्रमक आपको सूचनाएँ दिखाना चाहता है।</string>
|
||||
<string name="napplet_consent_sign">यह स्थान आपकी नोस्टर कुंचिका के साथ एक %1$d प्रकार घटना का हस्ताक्षर करना चाहता है।</string>
|
||||
<plurals name="napplet_consent_pay_amount">
|
||||
@@ -1375,7 +1376,7 @@
|
||||
<string name="podcast_trailer_upload_cta">पूर्वावलोकन अम्श जोडें</string>
|
||||
<string name="podcast_trailer_upload_hint">लघु ध्वनि अथवा दृश्य पूर्वीक्षण</string>
|
||||
<string name="podcast_trailer_title_placeholder">पूर्वावलोकन शीर्षक</string>
|
||||
<string name="podcast_trailer_url_label">अभिलेख जालपता</string>
|
||||
<string name="podcast_trailer_url_label">श्रव्यदृश्याभिलेख जालपता</string>
|
||||
<string name="podcast_your_podcast">आपका पुटप्रसार</string>
|
||||
<string name="podcast_untitled">शीर्षकरहित</string>
|
||||
<string name="podcast_no_episodes_yet">कोई कडी नहीं अब तक। नयी कडी दबाएँ अपने प्रथम कडी प्रकाशित करने के लिए।</string>
|
||||
@@ -1390,7 +1391,7 @@
|
||||
<string name="podcast_value_add_recipient">प्राप्तकर्ता जोडें</string>
|
||||
<string name="podcast_value_add_address">पता जोडें स्वयम हाथ से</string>
|
||||
<string name="podcast_value_search_user">नोस्टर प्रयोक्ता जोडें</string>
|
||||
<string name="podcast_value_search_user_hint">नाम अथवा @विभेदक द्वारा ढूँढें</string>
|
||||
<string name="podcast_value_search_user_hint">नाम अथवा @लेखासूचक द्वारा ढूँढें</string>
|
||||
<string name="podcast_value_user_no_lnaddress">इस प्रयोक्ता का कोई लैटनिंग॰ पता नहीं</string>
|
||||
<string name="podcast_value_remove_recipient">प्राप्तकर्ता हटाएँ</string>
|
||||
<string name="podcast_value_recipient_name">नाम (विकल्पात्मक)</string>
|
||||
@@ -1563,7 +1564,7 @@
|
||||
<string name="hash_verification_info_title">इसका अर्थ क्या है?</string>
|
||||
<string name="hash_verification_passed">यह विषयवस्तु वैसे ही है जैसे पत्र प्रकाशन पर</string>
|
||||
<string name="hash_verification_failed">यह विषयवस्तु परिवर्तित हुआ है। हो सकता है लेखक ने परिवर्तन देखा नहीं अथवा अनुमति दिया नहीं।</string>
|
||||
<string name="content_description_add_media">चित्र चलचित्र जोडें</string>
|
||||
<string name="content_description_add_media">श्रव्यदृश्याभिलेख जोडें</string>
|
||||
<string name="content_description_add_image">चित्र जोडें</string>
|
||||
<string name="content_description_add_video">चलचित्र जोडें</string>
|
||||
<string name="content_description_add_document">पत्र जोडें</string>
|
||||
@@ -1588,8 +1589,8 @@
|
||||
<string name="post_anonymously">नामरहित</string>
|
||||
<string name="file_server">अभिलेख सेवासंगणक</string>
|
||||
<string name="file_server_description">इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें</string>
|
||||
<string name="media_servers">प्रसारसंगणक</string>
|
||||
<string name="set_preferred_media_servers">आपके प्रसारसंगणक आद्यताएँ स्थापित करें।</string>
|
||||
<string name="media_servers">श्रव्यदृश्याभिलेख सेवासंगणक</string>
|
||||
<string name="set_preferred_media_servers">आपके श्रव्यदृश्याभिलेख सेवासंगणक आद्यताएँ स्थापित करें।</string>
|
||||
<string name="use_local_blossom_cache">स्थानीय ब्लोस्सम॰ द्रुतस्मृति का प्रयोग करें</string>
|
||||
<string name="use_local_blossom_cache_caption">जब एक ब्लोस्सम॰ द्रुतस्मृति चल रही है इस यन्त्र पर (संयोजनद्वार २४२४२) तब चित्र चलचित्र अवरोहण उसके द्वारा करें।</string>
|
||||
<string name="local_blossom_cache_detected">स्थानीय द्रुतस्मृति का पता चला संयोजनद्वार २४२४२ पर।</string>
|
||||
@@ -1600,7 +1601,7 @@
|
||||
<string name="media_servers_upload_section">आरोहण व्यवहार</string>
|
||||
<string name="blossom_mirror_uploads">प्रतिबिम्ब आरोहण</string>
|
||||
<string name="blossom_mirror_uploads_caption">आरोहण पश्चात अभिलेख की अनुकृति आपके अन्य ब्लोस्सम सेवासंगणकों में करें जिससे वह उपलब्ध रहेगा एक संगणक असंयोजित होने पर भी।</string>
|
||||
<string name="blossom_optimize_media">सेवासंगणक पर अभिलेखों का अनुकूलन</string>
|
||||
<string name="blossom_optimize_media">सेवासंगणक पर श्रव्यदृश्याभिलेखों का अनुकूलन</string>
|
||||
<string name="blossom_optimize_media_caption">आरोहण करें सेवासंगणक के /media अन्तबिन्दु द्वारा जिससे वह अभिलेख के उपतथ्य मिटा सके तथा संकुचित कर सके। रखा गया अभिलेख मूल से पृथक हो सकता है।</string>
|
||||
<string name="manage_stored_files">रखे गए अभिलेखों का प्रबन्धन</string>
|
||||
<string name="my_blossom_data">मेरे ब्लोस्सम॰ अभिलेख</string>
|
||||
@@ -1661,11 +1662,11 @@
|
||||
<item quantity="one">%1$d अभिलेख का आयात</item>
|
||||
<item quantity="other">%1$d अभिलेखों का आयात</item>
|
||||
</plurals>
|
||||
<string name="recommended_media_servers">अनुशम्सित प्रसारसंगणक</string>
|
||||
<string name="recommended_media_servers">अनुशम्सित श्रव्यदृश्याभिलेख सेवासंगणक</string>
|
||||
<string name="built_in_servers_description">अमेथिस्त की मूलविकल्प सूची। आप एक एक करके जोड सकते हैं अथवा सूची जोड सकते हैं।</string>
|
||||
<string name="use_default_servers">मूलविकल्प सूची का प्रयोग करें</string>
|
||||
<string name="add_media_server">प्रसारसंगणक जोडें</string>
|
||||
<string name="delete_media_server">प्रसारसंगणक मिटाएँ</string>
|
||||
<string name="add_media_server">श्रव्यदृश्याभिलेख सेवासंगणक जोडें</string>
|
||||
<string name="delete_media_server">श्रव्यदृश्याभिलेख सेवासंगणक मिटाएँ</string>
|
||||
<string name="media_servers_reorder_hint">खींचे पुनःव्यवस्थित करने के लिए। आरोहण के लिए प्रत्येक सेवासंगणक के साथ प्रयास किया जाएगा ऊपर से नीचे।</string>
|
||||
<string name="media_servers_priority_section">आरोहण प्राथमिकता</string>
|
||||
<string name="media_servers_add_section">सेवासंगणक जोडें</string>
|
||||
@@ -1786,14 +1787,14 @@
|
||||
<string name="tor_use_money_operations_explainer">ज्साप लैटनिंग तथा काशयू व्यापार के लिए टोर का प्रयोग अवश्य करें</string>
|
||||
<string name="tor_use_nip05_verification">नोस्ट्र पता सत्यापन</string>
|
||||
<string name="tor_use_nip05_verification_explainer">निप॰-०५ पता सत्यापन के लिए टोर का प्रयोग अवश्य करें</string>
|
||||
<string name="tor_use_media_uploads">चित्र चलचित्र अभिलेख आरोहण</string>
|
||||
<string name="tor_use_media_uploads_explainer">चित्र चलचित्र अभिलेख आरोहण के लिए टोर का प्रयोग अवश्य करें</string>
|
||||
<string name="tor_use_media_uploads">श्रव्यदृश्याभिलेख आरोहण</string>
|
||||
<string name="tor_use_media_uploads_explainer">विषयवस्तु आरोहण के लिए टोर का प्रयोग अवश्य करें</string>
|
||||
<string name="tor_internal">आन्तरीय</string>
|
||||
<string name="tor_external">ओर्बोट</string>
|
||||
<string name="tor_off">निष्क्रिय</string>
|
||||
<string name="tor_when_needed">मूलभूत</string>
|
||||
<string name="tor_default">मूलविकल्प</string>
|
||||
<string name="tor_small_payloads">ध्वनिदृश्याभिलेख के अतिरिक्त सभी</string>
|
||||
<string name="tor_small_payloads">श्रव्यदृश्याभिलेख के अतिरिक्त सभी</string>
|
||||
<string name="tor_full_privacy">सम्पूर्णतः गुप्त</string>
|
||||
<string name="tor_custom">विशिष्ट</string>
|
||||
<string name="tor_when_needed_explainer">सेवासंगणक की आवश्यकता होने पर टोर का प्रयोग करें</string>
|
||||
@@ -1852,11 +1853,11 @@
|
||||
<string name="app_notification_reposts_channel_message">%1$s ने आपके पत्र को पुनःप्रकाशित किया</string>
|
||||
<string name="app_notification_reposts_summary">नए पुनःप्रकाशन</string>
|
||||
<!-- Media (picture & video posts that mention you) -->
|
||||
<string name="app_notification_media_channel_name">ध्वनिचित्राभिलेख</string>
|
||||
<string name="app_notification_media_channel_name">श्रव्यदृश्याभिलेख</string>
|
||||
<string name="app_notification_media_channel_description">आपको सूचित करता है जब कोई आपका उल्लेख करते हैं चित्र अथवा चलचित्र में</string>
|
||||
<string name="app_notification_media_channel_message_photo">%1$s ने चित्र बाँटा</string>
|
||||
<string name="app_notification_media_channel_message_video">%1$s ने चलचित्र बाँटा</string>
|
||||
<string name="app_notification_media_summary">नए ध्वनिचित्राभिलेख</string>
|
||||
<string name="app_notification_media_summary">नए श्रव्यदृश्याभिलेख</string>
|
||||
<!-- Articles & highlights -->
|
||||
<string name="app_notification_articles_channel_name">निबन्ध तथा उद्दीप्तव्य</string>
|
||||
<string name="app_notification_articles_channel_description">आपको सूचित करता है जब कोई आपका उल्लेख अथवा आपको उद्दीप्त करते हैं एक निबन्ध में</string>
|
||||
@@ -1947,14 +1948,14 @@
|
||||
<item quantity="other">%1$s \u00b7 %2$d पुनःप्रसारक</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">जालभ्रमण</string>
|
||||
<string name="relay_purpose_media">ध्वनिचित्राभिलेख</string>
|
||||
<string name="relay_purpose_media">श्रव्यदृश्याभिलेख</string>
|
||||
<string name="relay_purpose_tags">विषयसूचक</string>
|
||||
<string name="relay_purpose_topics">विषय सूची</string>
|
||||
<string name="relay_purpose_thread">वार्तालाप</string>
|
||||
<string name="relay_purpose_search">खोज</string>
|
||||
<string name="relay_purpose_referenced">लुप्त घटनाओं को ढूँढें</string>
|
||||
<string name="relay_purpose_engagement">घटना अवलोकन</string>
|
||||
<string name="relay_explain_referenced">घटनाओं को विभेदक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
|
||||
<string name="relay_explain_referenced">घटनाओं को सूचक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
|
||||
<string name="relay_explain_engagement">घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं।</string>
|
||||
<string name="relay_purpose_add_ons">संलग्न</string>
|
||||
<string name="relay_purpose_relay_info">पुनःप्रसारक जानकारी</string>
|
||||
@@ -2123,7 +2124,7 @@
|
||||
<string name="relay_count_subscriptions">गणना ग्राहकताएँ (%1$d)</string>
|
||||
<string name="relay_outbox_events">निर्गतपेटिका घटनाएँ (%1$d)</string>
|
||||
<string name="relay_filter_authors">%1$d लेखक</string>
|
||||
<string name="relay_filter_ids">%1$d विभेदक</string>
|
||||
<string name="relay_filter_ids">%1$d सूचक</string>
|
||||
<string name="relay_filter_since">%1$s से</string>
|
||||
<string name="relay_filter_until">%1$s तक</string>
|
||||
<string name="relay_filter_limit">सीमा %1$d</string>
|
||||
@@ -2146,7 +2147,7 @@
|
||||
<string name="max_subs">अधिकतम ग्राहकताएँ</string>
|
||||
<string name="max_filters_per_sub">अधिकतम छलनियाँ प्रति ग्राहकता</string>
|
||||
<string name="max_limit_events_returning">अधिकतम सीमा (घटनाएँ प्रतिफलित)</string>
|
||||
<string name="max_subid_length">अधिकतम ग्राहकताविभेदक लम्बाई</string>
|
||||
<string name="max_subid_length">अधिकतम ग्राहकतासूचक लम्बाई</string>
|
||||
<string name="cashu">काशयू अक्षरराशि</string>
|
||||
<string name="cashu_mint_label">टकसाल : %1$s</string>
|
||||
<string name="cashu_redeem">चुकाएँ</string>
|
||||
@@ -2221,7 +2222,7 @@
|
||||
<string name="dark">अन्धकारमय</string>
|
||||
<string name="application_preferences">क्रमक आद्यताएँ</string>
|
||||
<string name="settings_section_appearance">स्वरूप</string>
|
||||
<string name="settings_section_media">ध्वनिदृश्याभिलेख तथा जानकारी</string>
|
||||
<string name="settings_section_media">श्रव्यदृश्याभिलेख तथा जानकारी</string>
|
||||
<string name="settings_section_general">सामान्य</string>
|
||||
<string name="settings_section_tor_engine">संयोजन</string>
|
||||
<string name="settings_section_tor_routing">टोर॰ के माध्यम से</string>
|
||||
@@ -2332,7 +2333,7 @@
|
||||
<string name="music_track_cover_upload_cta">बाहरी चित्र आरोहण करें</string>
|
||||
<string name="music_track_cover_upload_hint">एक वर्गाकार चित्र का चयन करें इस अभिलेख की कलाकृती के रूप में।</string>
|
||||
<string name="music_track_audio_upload_cta">ध्वनि अभिलेख आरोहण</string>
|
||||
<string name="music_track_audio_upload_hint">एक एमपीत्री॰ अथवा वेव॰ अथवा फ्लाक॰ का चयन करें। इसका आरोहण किया जाएगा आपके प्रसारसंगणक पर अभिलेखन करने पर।</string>
|
||||
<string name="music_track_audio_upload_hint">एक एमपीत्री॰ अथवा वेव॰ अथवा फ्लाक॰ का चयन करें। इसका आरोहण किया जाएगा आपके श्रव्यदृश्याभिलेख सेवासंगणक पर अभिलेखन करने पर।</string>
|
||||
<string name="music_track_audio_picked">ध्वनि अभिलेख आरोहण के लिए तत्पर</string>
|
||||
<string name="music_track_uploading_banner">बाहरी चित्र तथा ध्वनि अभिलेख का आरोहण तथा प्रकाशन चालू। यह चलता रहेगा आप पटल से विगमन करें तो भी।</string>
|
||||
<string name="edit_music_playlist">संगीतसूची सम्पादन</string>
|
||||
@@ -2628,7 +2629,7 @@
|
||||
<string name="copy_stack_to_clipboard">चिति की अनुकृति करें</string>
|
||||
<string name="copy_to_clipboard">टाँकाफलक में अनुकृति करें</string>
|
||||
<string name="copied_to_clipboard">टाँकाफलक में अनुकृत</string>
|
||||
<string name="copy_nprofile_to_clipboard">टाँकाफलक में एन॰परिचय की अनुकृति करें</string>
|
||||
<string name="copy_nprofile_to_clipboard">टाँकाफलक में एनप्रोफैल॰ की अनुकृति करें</string>
|
||||
<string name="copy_npub_to_clipboard">टाँकाफलक में एनपुब॰ की अनुकृति करें</string>
|
||||
<string name="share_or_save">बाँटें अथवा अभिलेखन करें</string>
|
||||
<string name="share_target_as_dm">सीधेसन्देश के रूप में भेजें</string>
|
||||
@@ -2646,10 +2647,10 @@
|
||||
<string name="new_highlight_note_placeholder">आपके विचार जोडें…</string>
|
||||
<string name="highlight_action">उद्दीप्तव्य</string>
|
||||
<string name="copy_url_to_clipboard">टाँकाफलक में जालपता की अनुकृति करें</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">टाँकाफलक में टीका विभेदक की अनुकृति करें</string>
|
||||
<string name="add_media_to_gallery">अभिलेख को चित्रालय में जोडें</string>
|
||||
<string name="media_added">अभिलेख जोडा गया</string>
|
||||
<string name="media_added_to_profile_gallery">अभिलेख जोडा गया आपके परिचय चित्रालय में</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">टाँकाफलक में टीका सूचक की अनुकृति करें</string>
|
||||
<string name="add_media_to_gallery">श्रव्यदृश्याभिलेख को चित्रालय में जोडें</string>
|
||||
<string name="media_added">श्रव्यदृश्याभिलेख जोडा गया</string>
|
||||
<string name="media_added_to_profile_gallery">श्रव्यदृश्याभिलेख जोडा गया आपके परिचय चित्रालय में</string>
|
||||
<string name="created_at">तब बनाया गया</string>
|
||||
<string name="guidelines">दिशा निर्देश</string>
|
||||
<string name="moderators">नियामक</string>
|
||||
@@ -2766,23 +2767,23 @@
|
||||
<string name="classifieds_category_food">आहार</string>
|
||||
<string name="classifieds_category_misc">विभिन्न</string>
|
||||
<string name="classifieds_category_other">अन्य</string>
|
||||
<string name="failed_to_upload_media_no_details">चित्र चलचित्र आरोहण असफल</string>
|
||||
<string name="failed_to_upload_media_no_details">श्रव्यदृश्याभिलेख आरोहण असफल</string>
|
||||
<string name="could_not_open_the_compressed_file">संकुचित अभिलेख को खोल नहीं पाए</string>
|
||||
<string name="failed_to_upload_media">आरोहण अपक्रम : %1$s</string>
|
||||
<string name="server_did_not_provide_a_url_after_uploading">सेवासंगणक ने आरोहण पश्चात जालपता नहीं दिया</string>
|
||||
<string name="could_not_download_from_the_server">सेवासंगणक से आरोहणकृत चित्र चलचित्र का अवरोहण नहीं कर पाए</string>
|
||||
<string name="could_not_download_from_the_server">सेवासंगणक से आरोहणकृत श्रव्यदृश्याभिलेख का अवरोहण नहीं कर पाए</string>
|
||||
<string name="could_not_check_downloaded_file">आरोहण पश्चात अवरोहित अभिलेख की जाँच नहीं हो सकी : %1$s</string>
|
||||
<string name="failed_to_upload_to_server_with_message">%1$s पर आरोहण असफल : %2$s</string>
|
||||
<string name="failed_to_delete_with_message">मिटाने में असफल : %1$s</string>
|
||||
<string name="media_too_big_for_nip95">अभिलेख निप॰-९५ के लिए बहुत बडा है</string>
|
||||
<string name="media_too_big_for_nip95">श्रव्यदृश्याभिलेख निप॰-९५ के लिए बहुत बडा है</string>
|
||||
<string name="encrypt_files_label">अभिलेख रहस्यीकरण</string>
|
||||
<string name="encrypt_files_description">गोपनीयता के लिए अभिलेखों का रहस्यीकरण करें। कुछ सेवासंगणक रहस्यीकृत अभिलेखों को सम्भाव्यतः अस्वीकार कर सकते हैं निःशुल्क लेखाओं के लिए।</string>
|
||||
<string name="failed_to_upload_encrypted_media_title">रहस्यीकृत आरोहण असफल</string>
|
||||
<string name="failed_to_upload_encrypted_media_message">अनेक सेवासंगणक रहस्यीकृत अभिलेखों को स्वीकार नहीं करते निःशुल्क लेखाओं के लिए। आप पुनःप्रयास कर सकते हैं रहस्यीकरण के बिना।</string>
|
||||
<string name="retry_without_encryption">रहस्यीकरण के बिना पुनःप्रयास</string>
|
||||
<string name="upload_without_encryption_warning">सावधान : रहस्यीकरण के बिना कोई भी विषयवस्तु देख सकेगा अभिलेख योजक के साथ।</string>
|
||||
<string name="media_compression_quality_label">अभिलेख गुणस्तर</string>
|
||||
<string name="media_compression_quality_explainer">निम्न गुणस्तर चुनें अपने अभिलेख को अल्प गुणवत्ता युक्त छोटे आकार अभिलेख तक संकुचित करने के लिए अथवा उच्च गुणस्तर चुनें उच्चतर गुणवत्ता युक्त बृहत्तर अभिलेख तक संकुचित करने के लिए।</string>
|
||||
<string name="media_compression_quality_label">श्रव्यदृश्याभिलेख गुणस्तर</string>
|
||||
<string name="media_compression_quality_explainer">निम्न गुणस्तर चुनें अपने श्रव्यदृश्याभिलेख को अल्प गुणवत्ता युक्त छोटे आकार अभिलेख तक संकुचित करने के लिए अथवा उच्च गुणस्तर चुनें उच्चतर गुणवत्ता युक्त बृहत्तर श्रव्यदृश्याभिलेख तक संकुचित करने के लिए।</string>
|
||||
<string name="media_compression_quality_low">निम्न</string>
|
||||
<string name="media_compression_quality_medium">मध्यम</string>
|
||||
<string name="media_compression_quality_high">उच्च</string>
|
||||
@@ -2792,11 +2793,11 @@
|
||||
<string name="convert_gif_to_mp4_label">जिफ॰ से एमपी४॰ में परिवर्तित करें</string>
|
||||
<string name="convert_gif_to_mp4_description">चलन्त जिफ॰ से एमपी४॰ में परिवर्तित करता है सूक्ष्मतर अभिलेख आकार तथा अधिक चालन अनुकूलता के लिए।</string>
|
||||
<string name="strip_metadata_label">निजी परितथ्य हटाएँ</string>
|
||||
<string name="strip_metadata_description">निजी परितथ्य हटाने का प्रयास करता है आलम्बित चित्रध्वनिदृश्य अभिलेखों से आरोहण पूर्व</string>
|
||||
<string name="strip_metadata_description">निजी परितथ्य हटाने का प्रयास करता है आलम्बित श्रव्यदृश्याभिलेखों से आरोहण पूर्व</string>
|
||||
<string name="metadata_strip_failed_title">परितथ्य हटाने में असफल</string>
|
||||
<string name="metadata_strip_failed_body">यह अभिलेख प्रारूप परितथ्य हटाने का अवलम्बन नहीं करता। निजी जानकारी जैसे स्थान तथा यन्त्र विवरण समाविष्ट हो सकते हैं। क्या आरोहण करें।</string>
|
||||
<string name="metadata_strip_failed_upload">आरोहण करें</string>
|
||||
<string name="metadata_strip_failed_upload_cancelled">अभिलेख से निजी परितथ्य हटाने में असफल। आरोहण निरस्त।</string>
|
||||
<string name="metadata_strip_failed_upload_cancelled">श्रव्यदृश्याभिलेख से निजी परितथ्य हटाने में असफल। आरोहण निरस्त।</string>
|
||||
<string name="upload_cancelled">आरोहण निरस्त</string>
|
||||
<string name="avif_metadata_strip_failed">एविफ॰ से परितथ्य नहीं मिटा सके : %1$s</string>
|
||||
<string name="edit_draft">पाण्डुलिपि सम्पादन</string>
|
||||
@@ -3221,6 +3222,19 @@
|
||||
<string name="bottom_bar_category_feeds">सूचनावलियाँ</string>
|
||||
<string name="bottom_bar_category_apps">क्रमक तथा जाल</string>
|
||||
<string name="bottom_bar_category_other">अन्य</string>
|
||||
<string name="drawer_settings">पार्श्व विकल्पसूची</string>
|
||||
<string name="drawer_settings_title">आपकी पार्श्व विकल्पसूची</string>
|
||||
<string name="drawer_settings_description">एक विभाग खोलें तथा उन पंक्तियों को निष्क्रिय करें जिनका उपयोग आप कभी नहीं करते। स्थापना विकल्प सर्वदा दृश्यमान रहते हैं। जिससे कि आप यहाँ कभी भी लौट सकेंगे। विभाग क्रम स्थायी है।</string>
|
||||
<string name="drawer_settings_sections">विभाग</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d छिपा हुआ</item>
|
||||
<item quantity="other">%1$d छिपे हुए</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">दृश्यमान</string>
|
||||
<string name="drawer_settings_hidden">छिपे हुए</string>
|
||||
<string name="drawer_settings_always_on">सर्वदा सक्रिय</string>
|
||||
<string name="drawer_settings_show_all">सभी दिखाएँ</string>
|
||||
<string name="drawer_settings_hide_all">सभी छिपाएँ</string>
|
||||
<string name="home_tabs_settings">मुख्यपटल पृष्ठसूचक</string>
|
||||
<string name="home_tabs_settings_description">चयन करें किन पृष्ठसूचक दिखने चाहिए मुख्यपटल पर। जब एक ही सक्रिय है तब पृष्ठसूचक पट्टी छुपाई जाएगी।</string>
|
||||
<string name="home_tab_everything">सभी</string>
|
||||
@@ -3438,7 +3452,7 @@
|
||||
<string name="show_npub_as_a_qr_code">क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ</string>
|
||||
<string name="show_nprofile_as_a_qr_code">क्यूआर॰ क्रमचित्र के रूप में एन॰परिचय को दिखाएँ</string>
|
||||
<string name="invalid_nip19_uri">अमान्य पता</string>
|
||||
<string name="invalid_nip19_uri_description">अमेथिस्ट को एक वैश्विक वस्तु विभेदक प्राप्त हुआ खोलने के लिए परन्तु वह विभेदक अमान्य था : %1$s</string>
|
||||
<string name="invalid_nip19_uri_description">अमेथिस्ट को एक वैश्विकवस्तुसूचक प्राप्त हुआ खोलने के लिए परन्तु वह सूचक अमान्य था : %1$s</string>
|
||||
<string name="dm_relays_title">सीधा संदेश आगतपेटिका पुनःप्रसारक</string>
|
||||
<string name="dm_relays_not_found">आपके निजी आगतपेटिका पुनःप्रसारकों की स्थापना करें</string>
|
||||
<string name="dm_relays_not_found_description">यह स्थापना विकल्प सब को सूचित करता है आपको सन्देश भेजने के लिए कौनसे पुनःप्रसारकों का प्रयोग करना चाहिए। इनके बिना आप कुछ सन्देश प्राप्त नहीं कर पाएँगे।</string>
|
||||
@@ -3594,6 +3608,10 @@
|
||||
<string name="git_repo_settings_save">अभिलेखन</string>
|
||||
<string name="git_repositories">गिट क्रमलेखकोश</string>
|
||||
<string name="highlights">प्रमुखताएँ</string>
|
||||
<string name="git_repositories_search_open">क्रमलेखकोश छलनी</string>
|
||||
<string name="git_repositories_search_close">छलनी आवृत</string>
|
||||
<string name="git_repositories_search_placeholder">छालन इनके अनुसार नाम विषय निमन्त्रक परिपालक…</string>
|
||||
<string name="git_repositories_search_no_results">कोई क्रमलेखकोश वर्तमान सूचनावली में इस खोज के अनुकूल नहीं।</string>
|
||||
<string name="nsite_title">नोस्टरस्थान : %1$s</string>
|
||||
<string name="napplet_card_title">नोस्टर संलग्नक्रमक : %1$s</string>
|
||||
<string name="napplet_card_permissions">अनुमतियाँ :</string>
|
||||
@@ -3644,7 +3662,7 @@
|
||||
<string name="http_status_412">पूर्वावस्था असफल - अनुरोध के शीर्षक प्रपत्रस्थानों में निर्दिष्ट पूर्वावस्थाओं की पूर्ति में सेवासंगणक असफल</string>
|
||||
<string name="http_status_413">भार अत्याधिक - अनुरोध सेवासंगणक के निरूपित सीमाएँ से बडा है, तथा सेवासंगणक ने इस पर काम करना नकार दिया</string>
|
||||
<string name="http_status_414">जालपता लम्बाई अत्याधिक - ग्राहक द्वारा अनुरोधित जालपता की लम्बाई सेवासंगणक के काम के लिए अत्याधिक है। </string>
|
||||
<string name="http_status_415">अनावलम्बित माध्यम प्रकार - अनुरोध में प्रयुक्त माध्यम प्रकार सेवासंगणक द्वारा अवलम्बित नहीं</string>
|
||||
<string name="http_status_415">अनावलम्बित श्रव्यदृश्याभिलेख प्रकार - अनुरोध में प्रयुक्त श्रव्यदृश्याभिलेख प्रकार सेवासंगणक द्वारा अवलम्बित नहीं</string>
|
||||
<string name="http_status_416">विस्तार असाध्य - अनुरोध के विस्तार शीर्षक प्रपत्रस्थान में सूचित मूल्य की पूर्ति सेवासंगणक द्वारा असाध्य। </string>
|
||||
<string name="http_status_417">अपेक्षा असफल - अनुरोध के अपेक्षा शीर्षक प्रपत्रस्थान में सूचित आवश्यकताओं की पूर्ति सेवासंगणक के लिए असाध्य</string>
|
||||
<string name="http_status_426">नवीकरण आवश्यक - ग्राहक वर्तमान से भिन्न संचारविधि तक नवीकरण नहीं करता तो सेवासंगणक इसके अनुरोध पर काम नहीं करेगा।</string>
|
||||
@@ -3658,7 +3676,7 @@
|
||||
<string name="http_status_507">स्मृतिस्थान का अभाव - सेवासंगणक में पर्याप्त स्मृतिस्थान उपलब्ध नहीं अनुरोध पर सफलतापूर्वक काम करने के लिए</string>
|
||||
<string name="http_status_508">क्रमचक्र दृष्ट - सेवासंगणक को अनन्त क्रमचक्र का पता चला अनुरोध पर काम करते हुए</string>
|
||||
<string name="http_status_511">जाल प्रमाणीकरण आवश्यक - जाल उपलब्ध होने के लिए ग्राहक का प्रमाणीकरण अनिवार्य</string>
|
||||
<string name="media_servers_blossom_section">ब्लोस्सम॰ प्रसारसंगणक</string>
|
||||
<string name="media_servers_blossom_section">ब्लोस्सम॰ सेवासंगणक</string>
|
||||
<string name="media_servers_blossom_explainer">सेवासंगणक जितना चाहें जोडें। किस संगणक का उपयोग करना है उसका चयन कर सकते हैं चित्र का आरोहण करते समय</string>
|
||||
<string name="add_a_nip96_server">निप॰-९६ सेवासंगणक जोडें</string>
|
||||
<string name="add_a_blossom_server">ब्लोस्सम॰ प्रसारसंगणक जोडें</string>
|
||||
@@ -3668,7 +3686,7 @@
|
||||
<string name="torrent_download">अवरोहण</string>
|
||||
<string name="torrent_failure">अभिलेख खोलने में असफल</string>
|
||||
<string name="torrent_no_apps">कोई अनेकत्रावरोहण क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए।</string>
|
||||
<string name="torrent_no_info">अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में</string>
|
||||
<string name="torrent_no_info">चुम्बकजाल योजक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में</string>
|
||||
<string name="my_lists">मेरे सूचियाँ</string>
|
||||
<string name="select_list_to_filter">सूचनावली छानने के लिए सूची चुनें</string>
|
||||
<string name="feed_group_feeds">सूचनावली</string>
|
||||
@@ -3768,7 +3786,7 @@
|
||||
<string name="resource_usage_http_requests">जाल अनुरोध</string>
|
||||
<string name="resource_usage_radio_bursts">विकिरणेन्द्रिय जाग उठना अवरोहणों के लिए (अनुमान)</string>
|
||||
<string name="resource_usage_http_active">सक्रिय स्थानान्तरण समय</string>
|
||||
<string name="resource_usage_media_play">ध्वनिदृश्याभिलेख चलन समय</string>
|
||||
<string name="resource_usage_media_play">श्रव्यदृश्याभिलेख चलन समय</string>
|
||||
<string name="resource_usage_verifies">हस्ताक्षर सत्यापित</string>
|
||||
<string name="resource_usage_cpu">क्रमवर्तक समय उपयुक्त</string>
|
||||
<string name="resource_usage_fg_time">क्रमकाभ्यन्तर समय</string>
|
||||
@@ -3822,8 +3840,10 @@
|
||||
<string name="resource_usage_memory_chatrooms">चर्चाशाला सूचियाँ स्मृति में</string>
|
||||
<string name="resource_usage_memory_device_class">यन्त्र स्मृति वर्ग</string>
|
||||
<string name="resource_usage_send_section">क्रमलेखकों के साथ बाँटें</string>
|
||||
<string name="resource_usage_send_explanation">यदि अमेथिस्ट द्वारा विद्युतकोष अथवा जानकारी यातायात का व्यय हो रहा है तो आप इस वृत्तान्त को क्रमलेखकों को भेज सकते हैं एक रहस्यीकृत सीधासन्देश में। इसमें केवल इस पटल पर दृश्यमान संख्याएँ हैं तथा इनके पीछे के तन्त्रविषयक संकलन \u2014 कोई पत्र सम्पर्क अथवा वीक्षण विवरण नहीं। कुछ भी नहीं भेजा जाएगा आपके द्वारा सन्देश पटल पर भेजें दबाने के पूर्व।</string>
|
||||
<string name="resource_usage_send_explanation">यदि अमेथिस्ट द्वारा विद्युतकोष अथवा जानकारी यातायात का व्यय हो रहा है तो आप इस वृत्तान्त को क्रमलेखकों को भेज सकते हैं एक रहस्यीकृत सीधासन्देश में। अथवा इसकी अनुकृति करके स्वयम बाँटें। इसमें केवल इस पटल पर दृश्यमान संख्याएँ हैं तथा इनके पीछे के तन्त्रविषयक संकलनसूचक \u2014 कोई पत्र अथवा सम्पर्क अथवा पुनःप्रसारक नाम अथवा जालभ्रमण विवरण नहीं। वृत्तान्त इस पटल से केवल तब जाएगा जब आप इसे भेजेंगे अथवा इसकी अनुकृति बाँटेंगे।</string>
|
||||
<string name="resource_usage_send_button">सीधासन्देश द्वारा वृत्तान्त भेजें</string>
|
||||
<string name="resource_usage_copy_button">अनुकृति</string>
|
||||
<string name="resource_usage_share_button">बाँटें</string>
|
||||
<string name="resource_usage_alert_title">उच्च संसाधन उपयोग का बोध</string>
|
||||
<string name="resource_usage_alert_message">अमेथिस्ट द्वारा अपेक्षित से अधिक उपभोग हुआ निकटकाल में : %1$s। क्या आप एक उपयोग वृत्तान्त भेजना चाहते क्रमलेखकों को एक रहस्यीकृत सीधासन्देश में। आप सम्पूर्ण वृत्तान्त देखेंगे कुछ भी भेजने के पूर्व।</string>
|
||||
<string name="resource_usage_reason_bg_data">चलनशील जानकारी %1$s पृष्ठभूत एक दिन में</string>
|
||||
@@ -3907,7 +3927,7 @@
|
||||
<string name="chess_loading_game">खेल आवहन चालू\u2026</string>
|
||||
<string name="chess_game_not_found">खेल अप्राप्त</string>
|
||||
<string name="chess_game_waiting">सम्भाव्यतः खेल समाप्त अथवा प्रतिपक्ष की प्रतीक्षा में।</string>
|
||||
<string name="chess_game_id">खेल विभेदक : %1$s\u2026</string>
|
||||
<string name="chess_game_id">खेल सूचक : %1$s\u2026</string>
|
||||
<string name="badge_and_n_others"> तथा %1$d अन्य</string>
|
||||
<string name="remove_language">%1$s हटाएँ</string>
|
||||
<!-- Kind display names for relay subscription filter chips -->
|
||||
@@ -4066,7 +4086,7 @@
|
||||
<string name="skip_for_now">अभी के लिए छोडें</string>
|
||||
<string name="fetching_follow_list">अनुचरण सूची प्राप्त की जा रही है…</string>
|
||||
<string name="no_follows_found">कोई अनुचरित नहीं</string>
|
||||
<string name="import_follows_tips">"किसी मित्र अथवा समूह नेता का परिचय प्रविष्ट करें। उनके एनपुब॰ अथवा निप॰०५ पता अथवा नामरूप्य नाम जैसे कि alice@example.com अथवा id/alice का उपयोग आप कर सकते हैं खण्डश्रृंखला सत्यापित विभेदकों के लिए।"</string>
|
||||
<string name="import_follows_tips">"किसी मित्र अथवा समूह नेता का परिचय प्रविष्ट करें। उनके एनपुब॰ अथवा निप॰०५ पता अथवा नामरूप्य नाम जैसे कि alice@example.com अथवा id/alice का उपयोग आप कर सकते हैं खण्डश्रृंखला सत्यापित परिचयसूचकों के लिए।"</string>
|
||||
<string name="select_all">सभी चुनें</string>
|
||||
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string>
|
||||
<string name="namecoin_settings">नामरूप्य स्थापना विकल्प</string>
|
||||
@@ -4182,7 +4202,7 @@
|
||||
<string name="mark_as_read_dialog_title">पढा हुआ चिह्नित करें</string>
|
||||
<string name="note_actions_dialog_title">टीका कार्य</string>
|
||||
<string name="profile_actions_dialog_title">परिचय कार्य</string>
|
||||
<string name="media_actions_dialog_title">अभिलेख कार्य</string>
|
||||
<string name="media_actions_dialog_title">श्रव्यदृश्याभिलेख कार्य</string>
|
||||
<string name="playback_actions_dialog_title">चालन</string>
|
||||
<string name="video_quality_auto">स्वचालित</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
@@ -4341,7 +4361,7 @@
|
||||
<string name="relay_management_ban_event">घटना प्रतिबन्धित करें</string>
|
||||
<string name="relay_management_allow_kind">प्रकार को अनुमति दें</string>
|
||||
<string name="relay_management_block_ip">अंकीय जालपता बाधित करें</string>
|
||||
<string name="relay_management_event_id_hex">घटना विभेदक (षोडशांक)</string>
|
||||
<string name="relay_management_event_id_hex">घटना सूचक (षोडशांक)</string>
|
||||
<string name="relay_management_kind_number">प्रकार संख्या</string>
|
||||
<string name="relay_management_ip_address">अंकीय जालपता</string>
|
||||
<string name="relay_management_reason_optional">कारण (विकल्पात्मक)</string>
|
||||
@@ -4717,7 +4737,7 @@
|
||||
<!-- Buzz agent persona editor -->
|
||||
<string name="buzz_persona_new_title">नयी व्यक्तिशैली</string>
|
||||
<string name="buzz_persona_edit_title">व्यक्तिशैली सम्पादन</string>
|
||||
<string name="buzz_persona_slug">सूचकाम्श (व्यक्तिशैली विभेदक)</string>
|
||||
<string name="buzz_persona_slug">सूचकाम्श (व्यक्तिशैली सूचक)</string>
|
||||
<string name="buzz_persona_slug_help">a-z तथा 0-9 तथा \'-\' तथा \'_\'। परिवर्तनीय नहीं बनाने के पश्चात।</string>
|
||||
<string name="buzz_persona_display_name">प्रदर्शन नाम</string>
|
||||
<string name="buzz_persona_system_prompt">यन्त्र प्रेरण</string>
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
<string name="connection_success_rate_description">Az átjátszóhoz való sikeres kapcsolatok százalékos aránya</string>
|
||||
<string name="search_and_add_a_user">Felhasználó keresése és hozzáadása</string>
|
||||
<string name="add_a_relay">Egy átjátszó hozzáadása</string>
|
||||
<string name="relay_url_not_valid">Az átjátszó címe érvénytelen. Használjon gazdagépnevet vagy zárójelben megadott IP-címet (például: [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Saját @említési név</string>
|
||||
<string name="display_name">Megjelenítendő név</string>
|
||||
<string name="my_display_name">Saját megjelenítendő név</string>
|
||||
@@ -3222,6 +3223,19 @@
|
||||
<string name="bottom_bar_category_feeds">Hírfolyamok</string>
|
||||
<string name="bottom_bar_category_apps">Alkalmazások és web</string>
|
||||
<string name="bottom_bar_category_other">Egyéb</string>
|
||||
<string name="drawer_settings">Oldalsó menü</string>
|
||||
<string name="drawer_settings_title">Saját oldalsó menü</string>
|
||||
<string name="drawer_settings_description">Nyisson meg egy szakaszt, és kapcsolja ki azokat a sorokat, amelyeket soha nem használ. A beállítások mindig láthatók maradnak, így bármikor visszatérhet ide. A szakaszok sorrendje rögzített.</string>
|
||||
<string name="drawer_settings_sections">Szakaszok</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d rejtett</item>
|
||||
<item quantity="other">%1$d rejtett</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">Látható</string>
|
||||
<string name="drawer_settings_hidden">Rejtett</string>
|
||||
<string name="drawer_settings_always_on">Mindig bekapcsolva</string>
|
||||
<string name="drawer_settings_show_all">Összes megjelenítése</string>
|
||||
<string name="drawer_settings_hide_all">Összes elrejtése</string>
|
||||
<string name="home_tabs_settings">Kezdőlap lapjai</string>
|
||||
<string name="home_tabs_settings_description">Válassza ki, mely lapok jelenjenek meg a kezdőlapon. Ha csak egy lap aktív, akkor a lapsáv rejtett.</string>
|
||||
<string name="home_tab_everything">Minden</string>
|
||||
@@ -3595,6 +3609,10 @@
|
||||
<string name="git_repo_settings_save">Mentés</string>
|
||||
<string name="git_repositories">Git-tárolók</string>
|
||||
<string name="highlights">Kiemelések</string>
|
||||
<string name="git_repositories_search_open">Tárolók szűrése</string>
|
||||
<string name="git_repositories_search_close">Szűrő bezárása</string>
|
||||
<string name="git_repositories_search_placeholder">Szűrés név, téma, kiszolgáló, karbantartó szerint…</string>
|
||||
<string name="git_repositories_search_no_results">A jelenlegi hírcsatornában nincs olyan tároló, amely megfelelne ennek a keresésnek.</string>
|
||||
<string name="nsite_title">nOldal: %1$s</string>
|
||||
<string name="napplet_card_title">nKisalkalmazás: %1$s</string>
|
||||
<string name="napplet_card_permissions">Engedélyek:</string>
|
||||
@@ -3823,8 +3841,10 @@
|
||||
<string name="resource_usage_memory_chatrooms">Csevegőszoba-listák a memóriában</string>
|
||||
<string name="resource_usage_memory_device_class">Eszköz memóriájának osztálya</string>
|
||||
<string name="resource_usage_send_section">Megosztás a fejlesztőkkel</string>
|
||||
<string name="resource_usage_send_explanation">Ha az Amethyst túl sok akkumulátort vagy adatot használna, elküldheti ezt a jelentést a fejlesztőknek egy titkosított közvetlen üzenetben (DM). Kizárólag a képernyőn látható számokat és az azok mögött álló technikai számlálókat tartalmazza \u2014 bejegyzéseket, névjegyeket vagy böngészési adatokat nem. Semmi sem kerül elküldésre addig, amíg az üzenetküldő képernyőn rá nem koppint a Küldés gombra.</string>
|
||||
<string name="resource_usage_send_explanation">Ha úgy tűnik, hogy az Amethyst túlzottan meríti az akkumulátort vagy fogyasztja az adatkeretet, elküldheti ezt a jelentést a fejlesztőknek egy titkosított közvetlen üzenetben vagy lemásolhatja és megoszthatja saját maga. A jelentés kizárólag az ezen a képernyőn látható számokat és a mögöttük lévő technikai számlálókat tartalmazza – bejegyzéseket, névjegyeket, átjátszóneveket vagy böngészési adatokat nem. A jelentés csak akkor hagyja el ezt a képernyőt, ha Ön elküldi, lemásolja vagy megosztja azt.</string>
|
||||
<string name="resource_usage_send_button">Jelentés elküldése közvetlen üzenetben</string>
|
||||
<string name="resource_usage_copy_button">Másolás</string>
|
||||
<string name="resource_usage_share_button">Megosztás</string>
|
||||
<string name="resource_usage_alert_title">Magas erőforrás-használat észlelhető</string>
|
||||
<string name="resource_usage_alert_message">Az Amethyst a közelmúltban a vártnál többet fogyasztott: %1$s. Szeretne használati jelentést küldeni a fejlesztőknek egy titkosított közvetlen üzenetben? A küldés előtt megtekintheti a teljes jelentést.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s mobiladat-forgalom a háttérben egyetlen nap alatt</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Percentage succesvolle verbindingen met deze relay</string>
|
||||
<string name="search_and_add_a_user">Zoek en voeg gebruiker toe</string>
|
||||
<string name="add_a_relay">Relay toevoegen</string>
|
||||
<string name="relay_url_not_valid">Geen geldig relay-adres. Gebruik een hostnaam, of een IP-adres tussen blokhaken (bijvoorbeeld [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Mijn @naam</string>
|
||||
<string name="display_name">Weergavenaam</string>
|
||||
<string name="my_display_name">Mijn weergavenaam</string>
|
||||
@@ -1929,14 +1930,95 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d relay</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d relays</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Bladeren</string>
|
||||
<string name="relay_purpose_media">Media</string>
|
||||
<string name="relay_purpose_tags">Hashtags</string>
|
||||
<string name="relay_purpose_topics">Onderwerpen</string>
|
||||
<string name="relay_purpose_thread">Gesprek</string>
|
||||
<string name="relay_purpose_search">Zoeken</string>
|
||||
<string name="relay_purpose_referenced">Ontbrekende events zoeken</string>
|
||||
<string name="relay_purpose_engagement">Events observeren</string>
|
||||
<string name="relay_explain_referenced">Haalt events op via hun id waar iets op je scherm naar verwijst maar die je nog niet hebt — een quote, de note waarop een reactie antwoordt, de start van een discussie.</string>
|
||||
<string name="relay_explain_engagement">Houdt de events die nu in beeld staan in de gaten voor nieuwe antwoorden, reacties, reposts, zaps en rapportages, zodat de tellers bijwerken terwijl je leest.</string>
|
||||
<string name="relay_purpose_add_ons">Add-ons</string>
|
||||
<string name="relay_purpose_relay_info">Relay-info</string>
|
||||
<string name="relay_purpose_other">Overig</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Relaylijsten zoeken</string>
|
||||
<string name="relay_purpose_observing_profiles">Profielen observeren</string>
|
||||
<string name="relay_purpose_your_account">Accountgegevens</string>
|
||||
<string name="relay_purpose_home_feed">Startfeed</string>
|
||||
<string name="relay_purpose_relay_groups">Relay-groepen</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d groep</item>
|
||||
<item quantity="other">%1$d groepen</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Vluchtige chats</string>
|
||||
<string name="relay_purpose_geohash_chats">Locatiechats</string>
|
||||
<string name="relay_purpose_live_chat">Livestream-chat</string>
|
||||
<string name="relay_explain_relay_groups">NIP-29-groepen waar je lid van bent. Elke groep staat op één host-relay, dus de app verbindt met elke relay die een groep van jou host.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chatruimtes die geen geschiedenis bewaren — berichten bestaan alleen zolang je verbonden bent, dus deze blijven geabonneerd om überhaupt iets te ontvangen.</string>
|
||||
<string name="relay_explain_geohash_chats">Locatiegebonden ruimtes voor de gebieden die je volgt, opgevraagd bij de relays die ze aanbieden.</string>
|
||||
<string name="relay_explain_live_chat">Chat en zap-doelen die horen bij livestreams die je open hebt staan of volgt.</string>
|
||||
<string name="relay_purpose_dm_inbox">DM-inbox</string>
|
||||
<string name="relay_purpose_your_wallet">Wallet</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Nutzap-inbox</string>
|
||||
<string name="relay_purpose_mint_directory">Mintoverzicht</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Community-chats</string>
|
||||
<string name="relay_purpose_community_feeds">Community-feeds</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Je inbox-relays, plus een kleine wisselende steekproef van de relays waarop de mensen die je volgt plaatsen, voor het geval een vermelding ergens anders is afgeleverd.</string>
|
||||
<string name="relay_explain_direct_messages">Je DM-inbox-relays, waar gift-wrapped berichten worden afgeleverd.</string>
|
||||
<string name="relay_explain_public_chats">De thuisrelay van elke chat die je open hebt staan of waar je lid van bent.</string>
|
||||
<string name="relay_explain_community_chats">De relays waarop elke community zijn planes publiceert.</string>
|
||||
<string name="relay_explain_encrypted_groups">Groepsberichten en sleutelpakketten, op de relays van elke groep.</string>
|
||||
<string name="relay_explain_live_rooms">De relays van de ruimte, zolang die open is.</string>
|
||||
<string name="relay_explain_account_data">Je eigen profiel, instellingen en concepten, op je eigen relays.</string>
|
||||
<string name="relay_explain_profiles">Profielen van de mensen die nu in beeld zijn.</string>
|
||||
<string name="relay_explain_relay_lists">Zoekt uit naar welke relays iemand publiceert, zodat hun posts van de juiste plek kunnen worden opgehaald.</string>
|
||||
<string name="relay_explain_follows">Volglijsten, gebruikt om je feed en je web-of-trust op te bouwen.</string>
|
||||
<string name="relay_explain_moderation">Rapportages die de mensen die je volgt schreven over de profielen die nu in beeld zijn, opgevraagd bij elke relay waarop die mensen plaatsen.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Rapportages van wie je volgt</string>
|
||||
<string name="relay_explain_wallet">Je eigen wallet-events, teruggelezen van de relays waarop je ze hebt gepubliceerd.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Luistert op je nutzap-relays plus je inbox- en DM-relays, zodat een betaling er niet langs kan glippen.</string>
|
||||
<string name="relay_explain_mint_directory">Kijkt op alle relays welke mints er bestaan en welke door mensen worden aanbevolen.</string>
|
||||
<string name="relay_explain_nwc">Meldingen van je verbonden wallet.</string>
|
||||
<string name="active_subs_title">Actieve relay-abonnementen</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filter</item>
|
||||
<item quantity="other">%1$d filters</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d relay</item>
|
||||
<item quantity="other">%1$d relays</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filter is nog niet toegewezen</item>
|
||||
<item quantity="other">%1$d filters zijn nog niet toegewezen</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Niet toegewezen aan een account</string>
|
||||
<string name="active_subs_no_entity">Alles</string>
|
||||
<string name="active_subs_scope_global">Iedereen</string>
|
||||
<string name="active_subs_scope_follows">Mensen die je volgt</string>
|
||||
<string name="active_subs_scope_authors">Een gekozen lijst mensen</string>
|
||||
<string name="active_subs_scope_muted">Gedempte mensen</string>
|
||||
<string name="active_subs_scope_all_communities">Jouw communities</string>
|
||||
<string name="active_subs_scope_algo">Een favoriete algo-feed</string>
|
||||
<string name="active_subs_share">%1$d%% van alles</string>
|
||||
<string name="active_subs_search_keywords">abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek</string>
|
||||
<string name="relay_explain_home">Posts van de mensen die je volgt, gelezen van de relays waarop ieder van hen publiceert.</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>
|
||||
@@ -2218,8 +2300,10 @@
|
||||
<string name="geohash_exclusive">Alleen locatie-exclusief bericht</string>
|
||||
<string name="geohash_exclusive_explainer">Alleen volgers van de locatie zien dit bericht.</string>
|
||||
<string name="hashtag_exclusive">Alleen hashtag-exclusief bericht</string>
|
||||
<string name="external_content_title">Externe inhoud</string>
|
||||
<string name="external_url_scope">Reageer op een website</string>
|
||||
<string name="external_id_scope">Reageer op een externe bron</string>
|
||||
<string name="url_preview_open_in_browser">Openen in browser</string>
|
||||
<string name="long_form_reading_minutes">%1$d min lezen</string>
|
||||
<plurals name="music_playlist_track_count">
|
||||
<item quantity="one">%1$d nummer</item>
|
||||
@@ -2531,6 +2615,9 @@
|
||||
<string name="share_to_dm_title">Verzenden naar…</string>
|
||||
<string name="share_to_dm_start_new">Nieuw bericht</string>
|
||||
<string name="share_target_as_highlight">Nieuwe Highlight</string>
|
||||
<string name="share_target_as_picture">Nieuwe afbeelding</string>
|
||||
<string name="share_target_as_short_video">Nieuwe Short</string>
|
||||
<string name="share_target_as_video">Nieuwe video</string>
|
||||
<string name="new_highlight_title">Nieuwe Highlight</string>
|
||||
<string name="new_highlight_passage_label">Gemarkeerde tekst</string>
|
||||
<string name="new_highlight_passage_placeholder">Wat viel je op?</string>
|
||||
|
||||
@@ -211,6 +211,8 @@
|
||||
<string name="connection_success_rate_description">Odsetek udanych połączeń z transmiterem</string>
|
||||
<string name="search_and_add_a_user">Szukaj i dodaj użytkownika</string>
|
||||
<string name="add_a_relay">Dodaj Transmiter</string>
|
||||
<string name="relay_url_not_valid">Nieprawidłowy adres transmitera. Użyj nazwy hosta lub adresu IP w nawiasach (na przykład
|
||||
[201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Moje imię @tag</string>
|
||||
<string name="display_name">Nazwa użytkownika</string>
|
||||
<string name="my_display_name">Mój nick</string>
|
||||
@@ -3347,6 +3349,21 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="bottom_bar_category_feeds">Kanały</string>
|
||||
<string name="bottom_bar_category_apps">Aplikacje & Strony</string>
|
||||
<string name="bottom_bar_category_other">Inne</string>
|
||||
<string name="drawer_settings">Menu boczne</string>
|
||||
<string name="drawer_settings_title">Twoje menu boczne</string>
|
||||
<string name="drawer_settings_description">Otwórz sekcję i wyłącz wiersze, których nigdy nie używasz. Ustawienia zawsze pozostają widoczne, więc zawsze możesz tu wrócić. Kolejność sekcji jest ustalona.</string>
|
||||
<string name="drawer_settings_sections">Sekcje</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d ukryta</item>
|
||||
<item quantity="few">%1$d ukryte</item>
|
||||
<item quantity="many">%1$d ukrytych</item>
|
||||
<item quantity="other">%1$d ukrytych</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">Widoczne</string>
|
||||
<string name="drawer_settings_hidden">Ukryte</string>
|
||||
<string name="drawer_settings_always_on">Zawsze włączony</string>
|
||||
<string name="drawer_settings_show_all">Pokaż wszystkie</string>
|
||||
<string name="drawer_settings_hide_all">Ukryj wszystkie</string>
|
||||
<string name="home_tabs_settings">Karty główne</string>
|
||||
<string name="home_tabs_settings_description">Wybierz, które karty pojawiają się na ekranie głównym. Gdy tylko jedna karta jest aktywna, pasek karty jest ukryty.</string>
|
||||
<string name="home_tab_everything">Wszystko</string>
|
||||
@@ -3726,6 +3743,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="git_repo_settings_save">Zapisz</string>
|
||||
<string name="git_repositories">Repozytoria Git</string>
|
||||
<string name="highlights">Wyróżnienia</string>
|
||||
<string name="git_repositories_search_open">Filtruj repozytoria</string>
|
||||
<string name="git_repositories_search_close">Zamknij filtr</string>
|
||||
<string name="git_repositories_search_placeholder">Filtruj według nazwy, tematu, hosta, opiekuna…</string>
|
||||
<string name="git_repositories_search_no_results">Brak repozytoriów w bieżącym kanale pasujących do tego wyszukiwania.</string>
|
||||
<string name="nsite_title">Statyczna Witryna: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Uprawnienia:</string>
|
||||
@@ -3958,6 +3979,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="resource_usage_send_section">Udostępnij deweloperom</string>
|
||||
<string name="resource_usage_send_explanation">Jeśli zauważysz, że aplikacja Amethyst nadmiernie zużywa baterię lub transfer danych, możesz przesłać ten raport do twórców w zaszyfrowanej wiadomości prywatnej. Zawiera on wyłącznie liczby widoczne na tym ekranie oraz związane z nimi liczniki techniczne \u2014 nie zawiera żadnych postów, kontaktów ani szczegółów dotyczących przeglądania stron. Żadne dane nie zostaną wysłane, dopóki nie klikniesz przycisku „Wyślij” na ekranie wiadomości.</string>
|
||||
<string name="resource_usage_send_button">Wyślij raport za pośrednictwem DM</string>
|
||||
<string name="resource_usage_copy_button">Kopiuj</string>
|
||||
<string name="resource_usage_share_button">Udostępnij</string>
|
||||
<string name="resource_usage_alert_title">Wykryto wysokie użycie zasobów</string>
|
||||
<string name="resource_usage_alert_message">W ostatnim czasie Amethyst zużył więcej zasobów, niż oczekiwano: %1$s. Czy chcesz wysłać raport dotyczący zużycia do twórców w zaszyfrowanej wiadomości prywatnej? Przed wysłaniem zobaczysz pełną treść raportu.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s danych komórkowych w tle w ciągu jednego dnia</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Porcentagem de conexões bem-sucedidas ao relay</string>
|
||||
<string name="search_and_add_a_user">Pesquisar e adicionar usuário</string>
|
||||
<string name="add_a_relay">Adicionar um Relay</string>
|
||||
<string name="relay_url_not_valid">Endereço de relay inválido. Use um nome de host ou um endereço IP entre colchetes (por exemplo [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Meu nome de @tag</string>
|
||||
<string name="display_name">Nome de Exibição</string>
|
||||
<string name="my_display_name">Meu nome de exibição</string>
|
||||
@@ -1940,14 +1941,84 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<string name="relay_purpose_browsing">Navegação</string>
|
||||
<string name="relay_purpose_media">Mídia</string>
|
||||
<string name="relay_purpose_topics">Tópicos</string>
|
||||
<string name="relay_purpose_thread">Conversa</string>
|
||||
<string name="relay_purpose_search">Pesquisa</string>
|
||||
<string name="relay_purpose_referenced">Encontrando eventos faltantes</string>
|
||||
<string name="relay_purpose_engagement">Observando eventos</string>
|
||||
<string name="relay_explain_referenced">Busca por id os eventos a que algo na sua tela se refere, mas que você ainda não tem — uma citação, o pai de uma resposta, a raiz de uma conversa.</string>
|
||||
<string name="relay_explain_engagement">Observa os eventos exibidos no momento em busca de novas respostas, reações, repostagens, zaps e denúncias, para que as contagens sejam atualizadas enquanto você lê.</string>
|
||||
<string name="relay_purpose_add_ons">Complementos</string>
|
||||
<string name="relay_purpose_relay_info">Informações do relay</string>
|
||||
<string name="relay_purpose_other">Outros</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Localizador de listas de relays</string>
|
||||
<string name="relay_purpose_observing_profiles">Observando perfis</string>
|
||||
<string name="relay_purpose_your_account">Dados da conta</string>
|
||||
<string name="relay_purpose_home_feed">Feed inicial</string>
|
||||
<string name="relay_purpose_relay_groups">Grupos de relay</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d grupo</item>
|
||||
<item quantity="other">%1$d grupos</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Chats efêmeros</string>
|
||||
<string name="relay_purpose_geohash_chats">Chats por localização</string>
|
||||
<string name="relay_purpose_live_chat">Chat de transmissão ao vivo</string>
|
||||
<string name="relay_explain_relay_groups">Grupos NIP-29 dos quais você participa. Cada grupo vive em um relay hospedeiro, então o app se conecta a todo relay que hospeda um grupo seu.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Salas de chat que não guardam histórico — as mensagens existem apenas enquanto você está conectado, então elas continuam assinadas para que algo chegue.</string>
|
||||
<string name="relay_explain_geohash_chats">Salas baseadas em localização para as áreas que você segue, consultadas nos relays que as hospedam.</string>
|
||||
<string name="relay_explain_live_chat">Chat e metas de zap ligados às transmissões ao vivo que você tem abertas ou segue.</string>
|
||||
<string name="relay_purpose_dm_inbox">Caixa de entrada de DM</string>
|
||||
<string name="relay_purpose_your_wallet">Carteira</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Caixa de entrada de nutzaps</string>
|
||||
<string name="relay_purpose_mint_directory">Diretório de mints</string>
|
||||
<string name="relay_purpose_community_chats">Chats de comunidades</string>
|
||||
<string name="relay_purpose_community_feeds">Feeds de comunidades</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Os relays da sua caixa de entrada, mais uma pequena amostra rotativa dos relays em que quem você segue publica, caso uma menção tenha sido entregue em outro lugar.</string>
|
||||
<string name="relay_explain_direct_messages">Os relays da sua caixa de entrada de DM, para onde as mensagens em gift wrap são entregues.</string>
|
||||
<string name="relay_explain_public_chats">O relay de origem de cada chat que você abriu ou do qual participa.</string>
|
||||
<string name="relay_explain_community_chats">Os relays em que cada comunidade publica seus planos.</string>
|
||||
<string name="relay_explain_encrypted_groups">Mensagens de grupo e pacotes de chaves, nos relays de cada grupo.</string>
|
||||
<string name="relay_explain_live_rooms">Os relays da sala, enquanto ela estiver aberta.</string>
|
||||
<string name="relay_explain_account_data">Seu próprio perfil, configurações e rascunhos, nos seus relays de origem.</string>
|
||||
<string name="relay_explain_profiles">Perfis das pessoas atualmente na tela.</string>
|
||||
<string name="relay_explain_relay_lists">Descobre em quais relays cada pessoa publica, para que as publicações dela possam ser buscadas no lugar certo.</string>
|
||||
<string name="relay_explain_follows">Listas de seguindo, usadas para montar seu feed e sua rede de confiança.</string>
|
||||
<string name="relay_explain_moderation">Denúncias que as pessoas que você segue escreveram sobre os perfis atualmente na sua tela, consultadas em cada relay em que essas pessoas publicam.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Denúncias de quem você segue</string>
|
||||
<string name="relay_explain_wallet">Os eventos da sua própria carteira, lidos de volta dos relays em que você os publicou.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Escuta nos seus relays de nutzap, além dos relays da caixa de entrada e de DM, para que nenhum pagamento passe despercebido.</string>
|
||||
<string name="relay_explain_mint_directory">Procura pelos relays quais mints existem e quais as pessoas recomendam.</string>
|
||||
<string name="relay_explain_nwc">Notificações da sua carteira conectada.</string>
|
||||
<string name="active_subs_title">Assinaturas de relay ativas</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filtro</item>
|
||||
<item quantity="other">%1$d filtros</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtro ainda não foi atribuído</item>
|
||||
<item quantity="other">%1$d filtros ainda não foram atribuídos</item>
|
||||
</plurals>
|
||||
<string name="active_subs_unattributed">Não atribuído a nenhuma conta</string>
|
||||
<string name="active_subs_no_entity">Tudo</string>
|
||||
<string name="active_subs_scope_global">Todos</string>
|
||||
<string name="active_subs_scope_follows">Pessoas que você segue</string>
|
||||
<string name="active_subs_scope_authors">Uma lista escolhida de pessoas</string>
|
||||
<string name="active_subs_scope_muted">Pessoas silenciadas</string>
|
||||
<string name="active_subs_scope_all_communities">Suas comunidades</string>
|
||||
<string name="active_subs_scope_algo">Um algoritmo de feed favorito</string>
|
||||
<string name="active_subs_share">%1$d%% de todos</string>
|
||||
<string name="active_subs_search_keywords">assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico</string>
|
||||
<string name="relay_explain_home">Publicações de pessoas que você segue, lidas dos relays em que cada uma delas publica.</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>
|
||||
@@ -3135,6 +3206,19 @@
|
||||
<string name="bottom_bar_category_feeds">Feeds</string>
|
||||
<string name="bottom_bar_category_apps">Apps e Web</string>
|
||||
<string name="bottom_bar_category_other">Outros</string>
|
||||
<string name="drawer_settings">Menu lateral</string>
|
||||
<string name="drawer_settings_title">Seu menu lateral</string>
|
||||
<string name="drawer_settings_description">Abra uma seção e desligue as linhas que você nunca usa. Configurações permanece sempre visível, então você sempre pode voltar aqui. A ordem das seções é fixa.</string>
|
||||
<string name="drawer_settings_sections">Seções</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d oculta</item>
|
||||
<item quantity="other">%1$d ocultas</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">Visíveis</string>
|
||||
<string name="drawer_settings_hidden">Ocultas</string>
|
||||
<string name="drawer_settings_always_on">Sempre ativo</string>
|
||||
<string name="drawer_settings_show_all">Mostrar tudo</string>
|
||||
<string name="drawer_settings_hide_all">Ocultar tudo</string>
|
||||
<string name="home_tabs_settings">Abas da Tela Inicial</string>
|
||||
<string name="home_tabs_settings_description">Escolha quais abas aparecem na Tela Inicial. Quando apenas uma aba está ativa, a barra de abas fica oculta.</string>
|
||||
<string name="home_tab_everything">Tudo</string>
|
||||
@@ -3508,6 +3592,10 @@
|
||||
<string name="git_repo_settings_save">Salvar</string>
|
||||
<string name="git_repositories">Repositórios Git</string>
|
||||
<string name="highlights">Destaques</string>
|
||||
<string name="git_repositories_search_open">Filtrar repositórios</string>
|
||||
<string name="git_repositories_search_close">Fechar filtro</string>
|
||||
<string name="git_repositories_search_placeholder">Filtrar por nome, tópico, host, mantenedor…</string>
|
||||
<string name="git_repositories_search_no_results">Nenhum repositório no feed atual corresponde a esta pesquisa.</string>
|
||||
<string name="nsite_title">Site Estático: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Permissões:</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Andel lyckade anslutningar till reläet</string>
|
||||
<string name="search_and_add_a_user">Sök och lägg till användare</string>
|
||||
<string name="add_a_relay">Lägg till Relä</string>
|
||||
<string name="relay_url_not_valid">Inte en giltig reläadress. Använd ett värdnamn eller en IP-adress inom hakparenteser (till exempel [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Mitt @tag-namn</string>
|
||||
<string name="display_name">Visningsnamn</string>
|
||||
<string name="my_display_name">Mitt visningsnamn</string>
|
||||
@@ -1940,14 +1941,95 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d relä</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d reläer</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Bläddring</string>
|
||||
<string name="relay_purpose_media">Media</string>
|
||||
<string name="relay_purpose_tags">Hashtaggar</string>
|
||||
<string name="relay_purpose_topics">Ämnen</string>
|
||||
<string name="relay_purpose_thread">Konversation</string>
|
||||
<string name="relay_purpose_search">Sök</string>
|
||||
<string name="relay_purpose_referenced">Hittar saknade händelser</string>
|
||||
<string name="relay_purpose_engagement">Observerar händelser</string>
|
||||
<string name="relay_explain_referenced">Hämtar händelser via id som något på din skärm hänvisar till men som du inte har ännu — ett citat, ett svars förälder, en trådrot.</string>
|
||||
<string name="relay_explain_engagement">Bevakar de händelser som visas just nu efter nya svar, reaktioner, återinlägg, zaps och rapporter, så att räknarna uppdateras medan du läser.</string>
|
||||
<string name="relay_purpose_add_ons">Tillägg</string>
|
||||
<string name="relay_purpose_relay_info">Reläinfo</string>
|
||||
<string name="relay_purpose_other">Övrigt</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Relälistsökare</string>
|
||||
<string name="relay_purpose_observing_profiles">Observerar profiler</string>
|
||||
<string name="relay_purpose_your_account">Kontots data</string>
|
||||
<string name="relay_purpose_home_feed">Hemflöde</string>
|
||||
<string name="relay_purpose_relay_groups">Relägrupper</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d grupp</item>
|
||||
<item quantity="other">%1$d grupper</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Försvinnande chattar</string>
|
||||
<string name="relay_purpose_geohash_chats">Platschattar</string>
|
||||
<string name="relay_purpose_live_chat">Livesändningschatt</string>
|
||||
<string name="relay_explain_relay_groups">NIP-29-grupper du gått med i. Varje grupp bor på ett värdrelä, så appen ansluter till varje relä som är värd för en av dina grupper.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chattrum som inte sparar någon historik — meddelanden finns bara medan du är ansluten, så dessa förblir prenumererade för att något alls ska komma fram.</string>
|
||||
<string name="relay_explain_geohash_chats">Platsbaserade rum för de områden du följer, efterfrågade från de reläer som bär dem.</string>
|
||||
<string name="relay_explain_live_chat">Chatt och zap-mål kopplade till livesändningar du har öppna eller följer.</string>
|
||||
<string name="relay_purpose_dm_inbox">DM-inkorg</string>
|
||||
<string name="relay_purpose_your_wallet">Plånbok</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Nutzap-inkorg</string>
|
||||
<string name="relay_purpose_mint_directory">Mint-katalog</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Gemenskapschattar</string>
|
||||
<string name="relay_purpose_community_feeds">Gemenskapsflöden</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Dina inkorgsreläer, plus ett litet roterande urval av de reläer personer du följer publicerar till, ifall ett omnämnande levererades någon annanstans.</string>
|
||||
<string name="relay_explain_direct_messages">Dina DM-inkorgsreläer, dit gift-wrap-meddelanden levereras.</string>
|
||||
<string name="relay_explain_public_chats">Hemrelät för varje chatt du har öppen eller har gått med i.</string>
|
||||
<string name="relay_explain_community_chats">Reläerna som varje gemenskap publicerar sina plan till.</string>
|
||||
<string name="relay_explain_encrypted_groups">Gruppmeddelanden och nyckelpaket, på varje grupps reläer.</string>
|
||||
<string name="relay_explain_live_rooms">Rummets reläer, medan det är öppet.</string>
|
||||
<string name="relay_explain_account_data">Din egen profil, dina inställningar och utkast, på dina hemreläer.</string>
|
||||
<string name="relay_explain_profiles">Profiler för personerna som just nu visas på skärmen.</string>
|
||||
<string name="relay_explain_relay_lists">Hittar vilka reläer varje person publicerar till, så att deras inlägg kan hämtas från rätt ställe.</string>
|
||||
<string name="relay_explain_follows">Följerlistor, som används för att bygga ditt flöde och ditt förtroendenät.</string>
|
||||
<string name="relay_explain_moderation">Rapporter som personer du följer har skrivit om profilerna som just nu visas på skärmen, efterfrågade från varje relä dessa personer publicerar till.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Rapporter från personer du följer</string>
|
||||
<string name="relay_explain_wallet">Dina egna plånbokshändelser, lästa tillbaka från de reläer du publicerade dem till.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Lyssnar på dina nutzap-reläer plus dina inkorgs- och DM-reläer, så att en betalning inte kan slinka förbi.</string>
|
||||
<string name="relay_explain_mint_directory">Söker över reläer efter vilka mints som finns och vilka folk rekommenderar.</string>
|
||||
<string name="relay_explain_nwc">Aviseringar från din anslutna plånbok.</string>
|
||||
<string name="active_subs_title">Aktiva reläprenumerationer</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filter</item>
|
||||
<item quantity="other">%1$d filter</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d relä</item>
|
||||
<item quantity="other">%1$d reläer</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filter är inte kopplat ännu</item>
|
||||
<item quantity="other">%1$d filter är inte kopplade ännu</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Inte kopplat till något konto</string>
|
||||
<string name="active_subs_no_entity">Allt</string>
|
||||
<string name="active_subs_scope_global">Alla</string>
|
||||
<string name="active_subs_scope_follows">Personer du följer</string>
|
||||
<string name="active_subs_scope_authors">En vald lista med personer</string>
|
||||
<string name="active_subs_scope_muted">Tystade personer</string>
|
||||
<string name="active_subs_scope_all_communities">Dina gemenskaper</string>
|
||||
<string name="active_subs_scope_algo">En favorit-flödesalgoritm</string>
|
||||
<string name="active_subs_share">%1$d %% av alla</string>
|
||||
<string name="active_subs_search_keywords">prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik</string>
|
||||
<string name="relay_explain_home">Inlägg från personer du följer, lästa från de reläer var och en av dem publicerar till.</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>
|
||||
@@ -3137,6 +3219,19 @@
|
||||
<string name="bottom_bar_category_feeds">Flöden</string>
|
||||
<string name="bottom_bar_category_apps">Appar & webb</string>
|
||||
<string name="bottom_bar_category_other">Övrigt</string>
|
||||
<string name="drawer_settings">Sidomeny</string>
|
||||
<string name="drawer_settings_title">Din sidomeny</string>
|
||||
<string name="drawer_settings_description">Öppna en sektion och stäng av raderna du aldrig använder. Inställningar förblir alltid synligt, så du kan alltid ta dig tillbaka hit. Sektionernas ordning är fast.</string>
|
||||
<string name="drawer_settings_sections">Sektioner</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d dold</item>
|
||||
<item quantity="other">%1$d dolda</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">Synliga</string>
|
||||
<string name="drawer_settings_hidden">Dolda</string>
|
||||
<string name="drawer_settings_always_on">Alltid på</string>
|
||||
<string name="drawer_settings_show_all">Visa alla</string>
|
||||
<string name="drawer_settings_hide_all">Dölj alla</string>
|
||||
<string name="home_tabs_settings">Hemflikar</string>
|
||||
<string name="home_tabs_settings_description">Välj vilka flikar som visas på startskärmen. När endast en flik är aktiv döljs flikraden.</string>
|
||||
<string name="home_tab_everything">Allt</string>
|
||||
@@ -3510,6 +3605,10 @@
|
||||
<string name="git_repo_settings_save">Spara</string>
|
||||
<string name="git_repositories">Git-repositories</string>
|
||||
<string name="highlights">Höjdpunkter</string>
|
||||
<string name="git_repositories_search_open">Filtrera repositories</string>
|
||||
<string name="git_repositories_search_close">Stäng filter</string>
|
||||
<string name="git_repositories_search_placeholder">Filtrera på namn, ämne, värd, underhållare…</string>
|
||||
<string name="git_repositories_search_no_results">Inga repositories i det aktuella flödet matchar den här sökningen.</string>
|
||||
<string name="nsite_title">Statisk webbplats: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Behörigheter:</string>
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
<string name="connection_success_rate_description">Percentage of successful connections to the relay</string>
|
||||
<string name="search_and_add_a_user">Search and add user</string>
|
||||
<string name="add_a_relay">Add a Relay</string>
|
||||
<string name="relay_url_not_valid">Not a valid relay address. Use a host name, or an IP address in brackets (for example [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">My @tag name</string>
|
||||
<string name="display_name">Display Name</string>
|
||||
<string name="my_display_name">My display name</string>
|
||||
@@ -2377,6 +2378,7 @@
|
||||
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save, signature, proof of work, pow, mining, nip-13</string>
|
||||
<string name="reactions_settings_search_keywords" translatable="false">emoji, reactions, like</string>
|
||||
<string name="bottom_bar_search_keywords" translatable="false">navigation, tabs, nav bar</string>
|
||||
<string name="drawer_search_keywords" translatable="false">side menu, drawer, hamburger, sections, hide, show</string>
|
||||
<string name="home_tabs_search_keywords" translatable="false">tabs, feeds, threads, conversations</string>
|
||||
<string name="profile_ui_search_keywords" translatable="false">profile, layout</string>
|
||||
<string name="backup_keys_search_keywords" translatable="false">nsec, private key, seed, mnemonic, export</string>
|
||||
@@ -3511,6 +3513,19 @@
|
||||
<string name="bottom_bar_category_feeds">Feeds</string>
|
||||
<string name="bottom_bar_category_apps">Apps & Web</string>
|
||||
<string name="bottom_bar_category_other">Other</string>
|
||||
<string name="drawer_settings">Side Menu</string>
|
||||
<string name="drawer_settings_title">Your side menu</string>
|
||||
<string name="drawer_settings_description">Open a section and switch off the rows you never use. Settings always stays visible, so you can always get back here. Section order is fixed.</string>
|
||||
<string name="drawer_settings_sections">Sections</string>
|
||||
<plurals name="drawer_settings_hidden_count">
|
||||
<item quantity="one">%1$d hidden</item>
|
||||
<item quantity="other">%1$d hidden</item>
|
||||
</plurals>
|
||||
<string name="drawer_settings_visible">Visible</string>
|
||||
<string name="drawer_settings_hidden">Hidden</string>
|
||||
<string name="drawer_settings_always_on">Always on</string>
|
||||
<string name="drawer_settings_show_all">Show all</string>
|
||||
<string name="drawer_settings_hide_all">Hide all</string>
|
||||
<string name="home_tabs_settings">Home Tabs</string>
|
||||
<string name="home_tabs_settings_description">Pick which tabs appear on the Home screen. When only one tab is active the tab bar is hidden.</string>
|
||||
<string name="home_tab_everything">Everything</string>
|
||||
@@ -3918,6 +3933,10 @@
|
||||
<string name="git_repo_settings_save">Save</string>
|
||||
<string name="git_repositories">Git Repositories</string>
|
||||
<string name="highlights">Highlights</string>
|
||||
<string name="git_repositories_search_open">Filter repositories</string>
|
||||
<string name="git_repositories_search_close">Close filter</string>
|
||||
<string name="git_repositories_search_placeholder">Filter by name, topic, host, maintainer…</string>
|
||||
<string name="git_repositories_search_no_results">No repositories in the current feed match this search.</string>
|
||||
<string name="nsite_title">nSite: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Permissions:</string>
|
||||
@@ -4169,8 +4188,10 @@
|
||||
<string name="resource_usage_memory_chatrooms">Chatroom lists in memory</string>
|
||||
<string name="resource_usage_memory_device_class">Device memory class</string>
|
||||
<string name="resource_usage_send_section">Share with the developers</string>
|
||||
<string name="resource_usage_send_explanation">If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, or browsing details. Nothing is sent until you tap Send in the message screen.</string>
|
||||
<string name="resource_usage_send_explanation">If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM, or copy it and share it yourself. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, relay names, or browsing details. The report leaves this screen only when you send, copy, or share it.</string>
|
||||
<string name="resource_usage_send_button">Send report via DM</string>
|
||||
<string name="resource_usage_copy_button">Copy</string>
|
||||
<string name="resource_usage_share_button">Share</string>
|
||||
<string name="resource_usage_alert_title">High resource usage detected</string>
|
||||
<string name="resource_usage_alert_message">Amethyst consumed more than expected recently: %1$s. Would you like to send a usage report to the developers in an encrypted DM? You will see the full report before anything is sent.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s of cellular data in the background in one day</string>
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.junit.Test
|
||||
class NewMessageTaggerKeyParseTest {
|
||||
val dao: Dao =
|
||||
object : Dao {
|
||||
override fun getOrCreateUser(hex: String): User = User(hex) { addr -> getOrCreateAddressableNoteInternal(addr) }
|
||||
override fun getOrCreateUser(pubkey: String): User = User(pubkey) { addr -> getOrCreateAddressableNoteInternal(addr) }
|
||||
|
||||
override fun getOrCreateNote(hex: String) =
|
||||
com.vitorpamplona.amethyst.model
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.preferences
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountNavigationPreferencesInternal
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Locks the per-account side-menu persistence. The hidden rows ride along in the same NIP-78
|
||||
* app-specific data blob as the bottom bar, so every account keeps its own menu and it syncs across
|
||||
* the user's devices.
|
||||
*/
|
||||
class DrawerPersistenceTest {
|
||||
@Test
|
||||
fun defaultIsAnUntouchedMenu() {
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(JsonMapper.toJson(AccountNavigationPreferencesInternal()))
|
||||
|
||||
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blobWrittenBeforeTheFieldExistedDecodesToAnUntouchedMenu() {
|
||||
// Every existing account is in this state, and so is every account that never opens the
|
||||
// screen — which is exactly why the preference stores hidden rows rather than visible ones.
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>("{}")
|
||||
|
||||
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenRowsRoundTripThroughTheSyncedSettingsBlob() {
|
||||
val hidden = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES)
|
||||
|
||||
val json = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = hidden.toNames()))
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
|
||||
|
||||
assertEquals(hidden, navBarItemsFromNames(decoded.hiddenDrawerItems))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serializedFormIsDeterministic() {
|
||||
// Two equal sets must produce byte-identical JSON, or a republish that changed nothing would
|
||||
// still look like a change and churn the account's NIP-78 event.
|
||||
val a = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES).toNames()))
|
||||
val b = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.BADGES, NavBarItem.DRAFTS).toNames()))
|
||||
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIdFromANewerClientIsDroppedInsteadOfFailingTheWholeBlob() {
|
||||
// The names are stored as strings precisely for this: decoding them as the enum would throw
|
||||
// and take every other synced setting down with it.
|
||||
val json = """{"hiddenDrawerItems":["DRAFTS","SOME_SCREEN_FROM_THE_FUTURE"]}"""
|
||||
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
|
||||
|
||||
assertEquals(setOf(NavBarItem.DRAFTS), navBarItemsFromNames(decoded.hiddenDrawerItems))
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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.navigation
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The show/hide rules behind the Side Menu settings screen, exercised without the UI.
|
||||
*
|
||||
* The preference stores what is *hidden*, so the interesting cases are the empty set (a stock drawer,
|
||||
* and the state every new install and every newly shipped destination starts in) and the mandatory
|
||||
* rows, which no code path may switch off.
|
||||
*/
|
||||
class DrawerItemVisibilityTest {
|
||||
private val you = DrawerSections.first { it.id == DrawerSectionId.YOU }
|
||||
private val system = DrawerSections.first { it.id == DrawerSectionId.SYSTEM }
|
||||
|
||||
@Test
|
||||
fun nothingHiddenMeansEverythingVisible() {
|
||||
val visible = DrawerItemVisibility.visibleItems(you, emptySet())
|
||||
|
||||
assertEquals(you.items, visible)
|
||||
assertEquals(0, DrawerItemVisibility.hiddenCount(you, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toggleHidesThenShows() {
|
||||
val once = DrawerItemVisibility.toggle(emptySet(), NavBarItem.DRAFTS)
|
||||
assertEquals(setOf(NavBarItem.DRAFTS), once)
|
||||
assertFalse(DrawerItemVisibility.isVisible(once, NavBarItem.DRAFTS))
|
||||
|
||||
val twice = DrawerItemVisibility.toggle(once, NavBarItem.DRAFTS)
|
||||
assertEquals(emptySet<NavBarItem>(), twice)
|
||||
assertTrue(DrawerItemVisibility.isVisible(twice, NavBarItem.DRAFTS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHiddenRowDropsOutOfItsSectionKeepingTheOrderOfTheRest() {
|
||||
val hidden = setOf(NavBarItem.DRAFTS)
|
||||
val visible = DrawerItemVisibility.visibleItems(you, hidden)
|
||||
|
||||
assertEquals(you.items.filter { it != NavBarItem.DRAFTS }, visible)
|
||||
assertEquals(1, DrawerItemVisibility.hiddenCount(you, hidden))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsCannotBeHidden() {
|
||||
// The escape hatch: hiding Settings would leave no route back to the screen that hides rows.
|
||||
assertEquals(emptySet<NavBarItem>(), DrawerItemVisibility.toggle(emptySet(), NavBarItem.SETTINGS))
|
||||
assertTrue(DrawerItemVisibility.isVisible(setOf(NavBarItem.SETTINGS), NavBarItem.SETTINGS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeStripsMandatoryItemsSyncedFromElsewhere() {
|
||||
// Another client (or an older build) could put Settings in the set; reading it back must not
|
||||
// strand the row as hidden-yet-unhideable.
|
||||
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.SETTINGS, NavBarItem.DRAFTS))
|
||||
|
||||
assertEquals(setOf(NavBarItem.DRAFTS), sanitized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeKeepsIdsThisDeviceDoesNotRender() {
|
||||
// Favorite Apps is gated off below API 30. Editing the menu on such a device must not clear
|
||||
// the choice the same account made on a newer one.
|
||||
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.FAVORITE_APPS))
|
||||
|
||||
assertEquals(setOf(NavBarItem.FAVORITE_APPS), sanitized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hideAllLeavesTheMandatoryRowsOfASection() {
|
||||
val hidden = DrawerItemVisibility.hideAll(emptySet(), system)
|
||||
|
||||
assertTrue(DrawerItemVisibility.isVisible(hidden, NavBarItem.SETTINGS))
|
||||
assertEquals(0, DrawerItemVisibility.hiddenCount(system, hidden))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSectionOfOnlyMandatoryRowsHasNothingToHide() {
|
||||
// What gates the section's bulk Show all / Hide all actions.
|
||||
assertFalse(DrawerItemVisibility.hasHideableRows(system))
|
||||
assertTrue(DrawerItemVisibility.hasHideableRows(you))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hideAllThenShowAllRoundTripsASection() {
|
||||
val hidden = DrawerItemVisibility.hideAll(emptySet(), you)
|
||||
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, hidden))
|
||||
|
||||
val shown = DrawerItemVisibility.showAll(hidden, you)
|
||||
assertEquals(emptySet<NavBarItem>(), shown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showAllOnlyTouchesItsOwnSection() {
|
||||
val hidden = DrawerItemVisibility.hideAll(DrawerItemVisibility.hideAll(emptySet(), you), system)
|
||||
|
||||
val shown = DrawerItemVisibility.showAll(hidden, system)
|
||||
|
||||
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, shown))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateHolderPersistsEveryEditAndRestoresDefaults() {
|
||||
val saved = mutableListOf<Set<NavBarItem>>()
|
||||
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
|
||||
|
||||
state.toggle(NavBarItem.DRAFTS)
|
||||
state.toggle(NavBarItem.BOOKMARKS)
|
||||
|
||||
assertEquals(listOf(setOf(NavBarItem.DRAFTS), setOf(NavBarItem.DRAFTS, NavBarItem.BOOKMARKS)), saved)
|
||||
assertEquals(2, state.totalHidden())
|
||||
|
||||
state.restoreDefault()
|
||||
|
||||
assertEquals(emptySet<NavBarItem>(), state.hidden)
|
||||
assertEquals(0, state.totalHidden())
|
||||
assertEquals(emptySet<NavBarItem>(), saved.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateHolderDoesNotRepublishANoOpEdit() {
|
||||
// Tapping a mandatory row must not republish the account's NIP-78 settings event.
|
||||
val saved = mutableListOf<Set<NavBarItem>>()
|
||||
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
|
||||
|
||||
state.toggle(NavBarItem.SETTINGS)
|
||||
state.restoreDefault()
|
||||
|
||||
assertTrue(saved.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.navigation
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The drawer and its settings screen both render [DrawerSections], so a destination missing from
|
||||
* every section is invisible in both places at once — no compiler error, no crash, just a screen
|
||||
* nobody can reach from the menu. These pin the invariants that keep that from happening quietly.
|
||||
*/
|
||||
class DrawerSectionsTest {
|
||||
@Test
|
||||
fun everyCatalogItemAppearsInADrawerSection() {
|
||||
val sectioned = DrawerSections.flatMap { it.items }.toSet()
|
||||
val missing = NavBarCatalog.keys - sectioned
|
||||
|
||||
assertEquals(
|
||||
"a catalog destination is in no drawer section — add it to one in NavBarItem.kt, " +
|
||||
"or to SdkGatedDrawerItems with the reason it can't be there",
|
||||
emptySet<Any>(),
|
||||
missing - SdkGatedDrawerItems,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noItemIsListedInTwoSections() {
|
||||
val sectioned = DrawerSections.flatMap { it.items }
|
||||
|
||||
assertEquals("an item is listed in more than one drawer section", sectioned.size, sectioned.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everySectionedItemResolvesInTheCatalog() {
|
||||
// The drawer looks each id up in NavBarCatalog and skips misses, so an id with no catalog
|
||||
// entry would silently render nothing while still occupying a row in the settings screen.
|
||||
DrawerSections.forEach { section ->
|
||||
section.items.forEach { item ->
|
||||
assertTrue("$item has no NavBarCatalog entry", NavBarCatalog.containsKey(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sectionsAreOrderedWithCreateBetweenFeedsAndSystem() {
|
||||
// The drawer renders DrawerSections in order, so this list *is* the menu's layout. Create sits
|
||||
// between the feeds and System, and is the one section with nothing configurable in it.
|
||||
assertEquals(
|
||||
listOf(
|
||||
DrawerSectionId.YOU,
|
||||
DrawerSectionId.NAVIGATE,
|
||||
DrawerSectionId.FEEDS,
|
||||
DrawerSectionId.CREATE,
|
||||
DrawerSectionId.SYSTEM,
|
||||
),
|
||||
DrawerSections.map { it.id },
|
||||
)
|
||||
assertEquals(emptyList<Any>(), DrawerSections.first { it.id == DrawerSectionId.CREATE }.items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everySectionHasItsOwnId() {
|
||||
val ids = DrawerSections.map { it.id }
|
||||
|
||||
assertEquals("two sections share a DrawerSectionId", ids.size, ids.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSectionWithNoCatalogItemsRendersFixedRowsOrNothingAtAll() {
|
||||
// hasFixedRows is declared on the section but consumed by CatalogSection's `when (section.id)`,
|
||||
// in another file — so the flag and the branch that honours it can drift apart with no compile
|
||||
// error. A section that carries neither is unreachable in both directions at once: the settings
|
||||
// screen skips it on items.isEmpty(), and CatalogSection returns before rendering a heading.
|
||||
val unreachable = DrawerSections.filter { it.items.isEmpty() && !it.hasFixedRows }
|
||||
|
||||
assertEquals(
|
||||
"a drawer section has no catalog items and no fixed rows, so it renders nowhere — " +
|
||||
"give it items, set hasFixedRows and a branch in CatalogSection, or delete it",
|
||||
emptyList<Any>(),
|
||||
unreachable.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mandatoryItemsAreActuallyRenderedByASection() {
|
||||
// A mandatory item that no section renders would be unhideable *and* invisible — the worst
|
||||
// of both. Settings is mandatory precisely because it is the way back to this configuration.
|
||||
val sectioned = DrawerSections.flatMap { it.items }.toSet()
|
||||
|
||||
assertTrue("a mandatory drawer item is in no section", sectioned.containsAll(MandatoryDrawerItems))
|
||||
}
|
||||
}
|
||||
+822
@@ -20,12 +20,33 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaPlayTimeTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -778,3 +799,804 @@ class ResourceUsageAlertsTest {
|
||||
assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = true, nowSec = now))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard that keeps the counter-key grammar honest — see the reserved-segment
|
||||
* note on [UsageKeys] for the mechanism and what it would cost to get wrong.
|
||||
*/
|
||||
class UsageKeyGrammarTest {
|
||||
/** Every diagnostic key shape, with values that would be obvious if they leaked into a sum. */
|
||||
private fun churnKeys(): Map<String, Long> {
|
||||
val out = mutableMapOf<String, Long>()
|
||||
var n = 1_000_000L
|
||||
for (mobile in listOf(true, false)) {
|
||||
for (fg in listOf(true, false)) {
|
||||
out[UsageKeys.relayDials(mobile, fg)] = n++
|
||||
out[UsageKeys.relayDisconnects(mobile, fg)] = n++
|
||||
for (ms in listOf(1L, 10_000L, 45_000L, 90_000L, 200_000L, 900_000L)) {
|
||||
out[UsageKeys.relayLife(ms, mobile, fg)] = n++
|
||||
}
|
||||
for (verb in CMD_LABELS) {
|
||||
out[UsageKeys.relayVerb(verb, received = false, mobile, fg)] = n++
|
||||
}
|
||||
for (verb in MSG_LABELS) {
|
||||
out[UsageKeys.relayVerb(verb, received = true, mobile, fg)] = n++
|
||||
}
|
||||
}
|
||||
}
|
||||
out[UsageKeys.RELAY_LIFE_OVERWRITE] = n++
|
||||
out[UsageKeys.RELAY_LIFE_ORPHAN] = n++
|
||||
for (cause in listOf(
|
||||
UsageKeys.TRIGGER_NETID,
|
||||
UsageKeys.TRIGGER_TRANSPORT,
|
||||
UsageKeys.TRIGGER_TOR_POLICY,
|
||||
UsageKeys.TRIGGER_CLASSIFICATION,
|
||||
UsageKeys.TRIGGER_COLD_START,
|
||||
UsageKeys.TRIGGER_OFF,
|
||||
UsageKeys.TRIGGER_BUZZ,
|
||||
)) {
|
||||
out[UsageKeys.relayTrigger(cause)] = n++
|
||||
}
|
||||
for (mobile in listOf(true, false)) {
|
||||
for (fg in listOf(true, false)) {
|
||||
out[UsageKeys.relaySubsSent(mobile, fg)] = n++
|
||||
out[UsageKeys.relaySubsClosed(mobile, fg)] = n++
|
||||
out[UsageKeys.relaySubsReplay(mobile, fg)] = n++
|
||||
}
|
||||
}
|
||||
UsageKeys.NOTICE_REASONS.forEach { out[UsageKeys.relayNotice(it)] = n++ }
|
||||
for (mobile in listOf(true, false)) {
|
||||
for (fg in listOf(true, false)) {
|
||||
out[UsageKeys.relaySubsResent(mobile, fg)] = n++
|
||||
out[UsageKeys.relayEventsSeen(mobile, fg)] = n++
|
||||
out[UsageKeys.relayEventsDup(mobile, fg)] = n++
|
||||
out[UsageKeys.relayEventsDupBytes(mobile, fg)] = n++
|
||||
for (ms in listOf(0L, 200L, 1_000L, 5_000L, 20_000L, 60_000L)) {
|
||||
out[UsageKeys.relayHandshake(ms, mobile, fg)] = n++
|
||||
out[UsageKeys.relayDialGap(ms, mobile, fg)] = n++
|
||||
}
|
||||
}
|
||||
}
|
||||
(SubPurpose.entries.map { UsageKeys.purposeKeyPart(it) } + UsageKeys.PURPOSE_UNEXPLAINED + UsageKeys.PURPOSE_UNATTRIBUTED).forEach {
|
||||
out[UsageKeys.relayPurposeSent(it)] = n++
|
||||
out[UsageKeys.relayPurposeBytes(it)] = n++
|
||||
out[UsageKeys.relayPurposeDown(it)] = n++
|
||||
out[UsageKeys.relayPurposeDownCount(it)] = n++
|
||||
out[UsageKeys.relayPurposeDupBytes(it)] = n++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A baseline of the pre-existing counters the summary is actually built from. */
|
||||
private fun baseline(): Map<String, Long> =
|
||||
mapOf(
|
||||
UsageKeys.relayMsg(mobile = true, foreground = false, received = true) to 500L,
|
||||
UsageKeys.relayMsg(mobile = true, foreground = false, received = false) to 60L,
|
||||
UsageKeys.relayMsg(mobile = false, foreground = true, received = true) to 900L,
|
||||
UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 70L,
|
||||
UsageKeys.netReqs(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true) to 3L,
|
||||
UsageKeys.netActiveMs(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true) to 40L,
|
||||
UsageKeys.radioBursts(mobile = true, foreground = true) to 2L,
|
||||
UsageKeys.relayConnMs(mobile = true, foreground = false) to 1_234L,
|
||||
UsageKeys.relayConnects(mobile = true, foreground = false) to 11L,
|
||||
UsageKeys.relayConnectFails(mobile = true, foreground = false) to 22L,
|
||||
UsageKeys.workerRuns("calendarReminder") to 1L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun newKeysDoNotDisturbSummary() {
|
||||
val before = UsageSummary.from(baseline())
|
||||
val after = UsageSummary.from(baseline() + churnKeys())
|
||||
assertEquals(before, after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noNewKeyContainsAReservedSegment() {
|
||||
val reserved =
|
||||
setOf(
|
||||
UsageKeys.RX,
|
||||
UsageKeys.TX,
|
||||
"msg",
|
||||
"connms",
|
||||
"connects",
|
||||
"connfails",
|
||||
"reqs",
|
||||
"bursts",
|
||||
"activems",
|
||||
"worker",
|
||||
"runs",
|
||||
) + UsageKeys.HTTP_ROLES
|
||||
|
||||
churnKeys().keys.forEach { key ->
|
||||
val clash = key.split('.').filter { it in reserved }
|
||||
assertTrue("Key '$key' uses reserved segment(s) $clash", clash.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The verb segments taken straight from quartz's own wire labels — the same
|
||||
* source [RelayUsageListener] reads, so a new subtype cannot be tested against
|
||||
* a stale hand-written list.
|
||||
*/
|
||||
val CMD_LABELS = listOf(ReqCmd.LABEL, EventCmd.LABEL, AuthCmd.LABEL, CloseCmd.LABEL, CountCmd.LABEL)
|
||||
val MSG_LABELS =
|
||||
listOf(
|
||||
EventMessage.LABEL,
|
||||
EoseMessage.LABEL,
|
||||
OkMessage.LABEL,
|
||||
NoticeMessage.LABEL,
|
||||
AuthMessage.LABEL,
|
||||
ClosedMessage.LABEL,
|
||||
CountMessage.LABEL,
|
||||
NotifyMessage.LABEL,
|
||||
LimitsMessage.LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class UsageKeyHelpersTest {
|
||||
@Test
|
||||
fun lifeBucketsAreHalfOpen() {
|
||||
assertEquals("lt5s", UsageKeys.lifeBucket(0))
|
||||
assertEquals("lt5s", UsageKeys.lifeBucket(4_999))
|
||||
assertEquals("lt30s", UsageKeys.lifeBucket(5_000))
|
||||
assertEquals("lt60s", UsageKeys.lifeBucket(59_999))
|
||||
// The one that matters: exactly the stability bar is NOT "under a minute".
|
||||
assertEquals("lt120s", UsageKeys.lifeBucket(60_000))
|
||||
assertEquals("lt300s", UsageKeys.lifeBucket(299_999))
|
||||
assertEquals("gte300s", UsageKeys.lifeBucket(300_000))
|
||||
assertEquals("gte300s", UsageKeys.lifeBucket(Long.MAX_VALUE))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RelayUsageListenerTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private var now = 0L
|
||||
|
||||
private fun relay(url: String): IRelayClient {
|
||||
val r = mockk<IRelayClient>(relaxed = true)
|
||||
every { r.url } returns NormalizedRelayUrl(url)
|
||||
return r
|
||||
}
|
||||
|
||||
private fun runLedger(block: suspend (ResourceUsageAccountant, RelayUsageListener) -> Unit) =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(tmp.newFolder(), "usage.json"))
|
||||
// backgroundScope, as everywhere else in this file: the accountant's
|
||||
// debounced flush is auto-cancelled with the test instead of leaking a
|
||||
// pending 30s delay into the test body.
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L })
|
||||
val l =
|
||||
RelayUsageListener(
|
||||
accountant = accountant,
|
||||
isMobile = { false },
|
||||
isForeground = { true },
|
||||
nowMs = { now },
|
||||
)
|
||||
block(accountant, l)
|
||||
}
|
||||
|
||||
private suspend fun counters(accountant: ResourceUsageAccountant): Map<String, Long> = accountant.allDaysIncludingLive()[1L].orEmpty()
|
||||
|
||||
@Test
|
||||
fun bucketsASessionByHowLongItLived() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
now = 1_000
|
||||
l.onConnected(r, 10, false)
|
||||
now = 1_000 + 45_000
|
||||
l.onDisconnected(r)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.relayLife(45_000, mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relayConnects(mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relayDisconnects(mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.RELAY_LIFE_ORPHAN])
|
||||
assertNull(c[UsageKeys.RELAY_LIFE_OVERWRITE])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDialThatNeverConnectedProducesNoLifetime() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://nos.lol/")
|
||||
l.onConnecting(r)
|
||||
l.onCannotConnect(r, "WebSocket Failure: timeout (SocketTimeoutException)")
|
||||
l.onDisconnected(r)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.relayDials(mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relayConnectFails(mobile = false, foreground = true)])
|
||||
// No start stamp to consume: counted as an orphan rather than a 0ms session,
|
||||
// which would otherwise pile into lt5s and fake "instant failures".
|
||||
assertEquals(1L, c[UsageKeys.RELAY_LIFE_ORPHAN])
|
||||
assertNull(c[UsageKeys.relayLife(0, mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.relayConnects(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSecondConnectBeforeDisconnectIsCountedAsAnOverwrite() =
|
||||
runLedger { accountant, l ->
|
||||
// The teardown race: disconnect(); connect() runs synchronously, so a stale
|
||||
// failure callback for the old socket can land after the new one is open.
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
now = 0
|
||||
l.onConnected(r, 10, false)
|
||||
now = 500_000
|
||||
l.onConnected(r, 10, false)
|
||||
now = 500_100
|
||||
l.onDisconnected(r)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.RELAY_LIFE_OVERWRITE])
|
||||
assertEquals(2L, c[UsageKeys.relayConnects(mobile = false, foreground = true)])
|
||||
// The long session was lost; only the short one is recorded. `connects` is the
|
||||
// denominator that makes that deficit visible instead of silent.
|
||||
assertEquals(1L, c[UsageKeys.relayLife(100, mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.relayLife(500_000, mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoRelaysDoNotShareASlot() =
|
||||
runLedger { accountant, l ->
|
||||
val a = relay("wss://relay.damus.io/")
|
||||
val b = relay("wss://nos.lol/")
|
||||
now = 0
|
||||
l.onConnected(a, 10, false)
|
||||
l.onConnected(b, 10, false)
|
||||
now = 90_000
|
||||
l.onDisconnected(a)
|
||||
now = 200_000
|
||||
l.onDisconnected(b)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.relayLife(90_000, mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relayLife(200_000, mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.RELAY_LIFE_OVERWRITE])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sentVerbSplitSumsBackToTheByteTotal() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
val req = ReqCmd("sub1", listOf())
|
||||
val event = EventCmd(mockk(relaxed = true))
|
||||
l.onSent(r, "0123456789", req, success = true)
|
||||
l.onSent(r, "01234", event, success = true)
|
||||
// A failed send is not counted at all, matching relay.msg.*.tx.
|
||||
l.onSent(r, "0123456789012345", req, success = false)
|
||||
|
||||
val c = counters(accountant)
|
||||
val total = c[UsageKeys.relayMsg(mobile = false, foreground = true, received = false)]
|
||||
val split =
|
||||
c.filterKeys { it.startsWith("relay.verb.up.") }.values.sum()
|
||||
assertEquals(15L, total)
|
||||
assertEquals(total, split)
|
||||
assertEquals(10L, c[UsageKeys.relayVerb(ReqCmd.LABEL, received = false, mobile = false, foreground = true)])
|
||||
assertEquals(5L, c[UsageKeys.relayVerb(EventCmd.LABEL, received = false, mobile = false, foreground = true)])
|
||||
// The label is uppercase on the wire; the key segment is not.
|
||||
assertEquals("relay.verb.up.req.wifi.fg", UsageKeys.relayVerb(ReqCmd.LABEL, received = false, mobile = false, foreground = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receivedVerbSplitSumsBackToTheByteTotal() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onIncomingMessage(r, "0123456789", EoseMessage("sub1"))
|
||||
l.onIncomingMessage(r, "012", NoticeMessage("hi"))
|
||||
|
||||
val c = counters(accountant)
|
||||
val total = c[UsageKeys.relayMsg(mobile = false, foreground = true, received = true)]
|
||||
val split = c.filterKeys { it.startsWith("relay.verb.down.") }.values.sum()
|
||||
assertEquals(13L, total)
|
||||
assertEquals(total, split)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reqsInsideTheConnectWindowCountAsReplay() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
now = 10_000
|
||||
l.onConnected(r, 10, false)
|
||||
// syncState's burst: sent immediately after the socket is ready.
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf()), success = true)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub2", listOf()), success = true)
|
||||
// Well past the window — a user opening a screen, not a replay.
|
||||
now = 10_000 + UsageKeys.REPLAY_WINDOW_MS + 1
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub3", listOf()), success = true)
|
||||
l.onSent(r, "[\"CLOSE\"]", CloseCmd("sub1"), success = true)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(3L, c[UsageKeys.relaySubsSent(mobile = false, foreground = true)])
|
||||
assertEquals(2L, c[UsageKeys.relaySubsReplay(mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relaySubsClosed(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReqOnANeverConnectedRelayIsNotReplay() =
|
||||
runLedger { accountant, l ->
|
||||
// No onConnected, so no start stamp: must not be attributed to a burst.
|
||||
val r = relay("wss://nos.lol/")
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf()), success = true)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.relaySubsSent(mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.relaySubsReplay(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedSendCountsNowhere() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf()), success = false)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertNull(c[UsageKeys.relaySubsSent(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRefusalNoticeIsCountedByReason() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://nos.lol/")
|
||||
l.onIncomingMessage(r, "[\"NOTICE\",\"x\"]", NoticeMessage("ERROR: too many concurrent REQs"))
|
||||
l.onIncomingMessage(r, "[\"NOTICE\",\"y\"]", NoticeMessage("hello"))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.relayNotice(UsageKeys.NOTICE_TOO_MANY_SUBS)])
|
||||
assertEquals(1L, c[UsageKeys.relayNotice(UsageKeys.NOTICE_UNCLASSIFIED)])
|
||||
// Still part of the byte total, so the verb invariant is unaffected.
|
||||
assertEquals(
|
||||
c[UsageKeys.relayMsg(mobile = false, foreground = true, received = true)],
|
||||
c.filterKeys { it.startsWith("relay.verb.down.") }.values.sum(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReqForAnAlreadyOpenSubscriptionCountsAsAResend() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true)
|
||||
// Same subId, still open: the assembler changed its mind.
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub2", listOf(Filter())), success = true)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(3L, c[UsageKeys.relaySubsSent(mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relaySubsResent(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aClosedSubscriptionCanBeReopenedWithoutCountingAsAResend() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true)
|
||||
l.onSent(r, "[\"CLOSE\"]", CloseCmd("sub1"), success = true)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true)
|
||||
|
||||
assertNull(counters(accountant)[UsageKeys.relaySubsResent(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReconnectForgetsOpenSubscriptions() =
|
||||
runLedger { accountant, l ->
|
||||
// The relay forgets them too, so the post-reconnect replay is legitimately
|
||||
// new work and must not be booked as the client changing its mind.
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true)
|
||||
l.onDisconnected(r)
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true)
|
||||
|
||||
assertNull(counters(accountant)[UsageKeys.relaySubsResent(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reqsAreAttributedToTheirSubscriptionPurpose() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "0123456789", ReqCmd("a", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true)
|
||||
l.onSent(r, "01234", ReqCmd("b", listOf(ExplainedFilter(purpose = SubPurpose.OTHER))), success = true)
|
||||
// An assembler #3832 never tagged: its own bucket, not a guess.
|
||||
l.onSent(r, "012", ReqCmd("c", listOf(Filter())), success = true)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.relayPurposeSent("home_feed")])
|
||||
assertEquals(10L, c[UsageKeys.relayPurposeBytes("home_feed")])
|
||||
assertEquals(1L, c[UsageKeys.relayPurposeSent(UsageKeys.PURPOSE_OTHER)])
|
||||
assertEquals(1L, c[UsageKeys.relayPurposeSent(UsageKeys.PURPOSE_UNEXPLAINED)])
|
||||
// Purpose bytes reconcile with the REQ verb total.
|
||||
assertEquals(
|
||||
c[UsageKeys.relayVerb("REQ", received = false, mobile = false, foreground = true)],
|
||||
c.filterKeys { it.startsWith("relay.purpose.") && it.endsWith(".bytes") }.values.sum(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshakeAndDialGapSeparateTheRelayFromOurselves() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
now = 0
|
||||
l.onConnecting(r)
|
||||
// 3s of wall clock to get connected, of which the transport says the
|
||||
// upgrade round trip was 150ms — the other 2850ms is DNS/TCP/TLS/queueing.
|
||||
now = 3_000
|
||||
l.onConnected(r, pingMillis = 150, compressed = false)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(1L, c[UsageKeys.relayHandshake(150, mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relayDialGap(2_850, mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUntimeableHandshakeRecordsNothingRatherThanZero() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnecting(r)
|
||||
l.onConnected(r, pingMillis = 0, compressed = false)
|
||||
|
||||
val c = counters(accountant)
|
||||
assertNull(c[UsageKeys.relayHandshake(0, mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.relayDialGap(0, mobile = false, foreground = true)])
|
||||
// The connection itself is still counted.
|
||||
assertEquals(1L, c[UsageKeys.relayConnects(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundBytesAreAttributedToTheSubscriptionThatAskedForThem() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("mod1", listOf(ExplainedFilter(purpose = SubPurpose.MODERATION))), success = true)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("feed1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true)
|
||||
|
||||
l.onIncomingMessage(r, "0123456789", EventMessage("mod1", mockk(relaxed = true)))
|
||||
l.onIncomingMessage(r, "01234", EventMessage("feed1", mockk(relaxed = true)))
|
||||
l.onIncomingMessage(r, "012", EoseMessage("mod1"))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(13L, c[UsageKeys.relayPurposeDown("moderation")])
|
||||
assertEquals(5L, c[UsageKeys.relayPurposeDown("home_feed")])
|
||||
// Frames, not just bytes: 13 bytes of moderation arrived as two frames, so
|
||||
// the average frame size is recoverable per purpose.
|
||||
assertEquals(2L, c[UsageKeys.relayPurposeDownCount("moderation")])
|
||||
assertEquals(1L, c[UsageKeys.relayPurposeDownCount("home_feed")])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun framesStillInFlightAfterACloseAreStillAttributed() =
|
||||
runLedger { accountant, l ->
|
||||
// The bug this replaced: CLOSE removed the id, so events the relay had
|
||||
// already queued landed in `unattributed`. 8,159 CLOSEs in one session
|
||||
// sent 41% of the download there.
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true)
|
||||
l.onSent(r, "[\"CLOSE\"]", CloseCmd("s1"), success = true)
|
||||
l.onIncomingMessage(r, "0123456789", EventMessage("s1", mockk(relaxed = true)))
|
||||
|
||||
assertEquals(10L, counters(accountant)[UsageKeys.relayPurposeDown("home_feed")])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFrameArrivingAfterAReconnectIsStillAttributed() =
|
||||
runLedger { accountant, l ->
|
||||
// Purpose is a property of the subscription id, not of the socket, so a
|
||||
// disconnect must not forget it.
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.ENGAGEMENT))), success = true)
|
||||
l.onDisconnected(r)
|
||||
l.onConnected(r, 10, false)
|
||||
l.onIncomingMessage(r, "01234", EventMessage("s1", mockk(relaxed = true)))
|
||||
|
||||
assertEquals(5L, counters(accountant)[UsageKeys.relayPurposeDown("engagement")])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReconnectStillForgetsWhichSubscriptionsAreOpen() =
|
||||
runLedger { accountant, l ->
|
||||
// The other half of the split: the relay forgot them, so the replay is
|
||||
// new work and must not read as the client changing its mind.
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true)
|
||||
l.onDisconnected(r)
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true)
|
||||
|
||||
assertNull(counters(accountant)[UsageKeys.relaySubsResent(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInboundFrameForAnUnknownSubscriptionIsNotGuessedAt() =
|
||||
runLedger { accountant, l ->
|
||||
// Counters wiped mid-session, or a subscription opened before this
|
||||
// connection: attributing it to a purpose would be an invention.
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onIncomingMessage(r, "0123456789", EventMessage("ghost", mockk(relaxed = true)))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(10L, c[UsageKeys.relayPurposeDown(UsageKeys.PURPOSE_UNATTRIBUTED)])
|
||||
assertEquals(1L, c[UsageKeys.relayPurposeDownCount(UsageKeys.PURPOSE_UNATTRIBUTED)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayWideFramesAreLeftOutRatherThanMisattributed() =
|
||||
runLedger { accountant, l ->
|
||||
// NOTICE and OK name no subscription, so nothing may be booked for them.
|
||||
// This is why purpose.down deliberately does not reconcile to msg.rx.
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onIncomingMessage(r, "0123456789", NoticeMessage("hello"))
|
||||
l.onIncomingMessage(r, "01234", OkMessage("id", true, ""))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertTrue(c.keys.none { it.startsWith("relay.purpose.") && it.endsWith(".down") })
|
||||
// Still counted in the byte totals and the verb split.
|
||||
assertEquals(15L, c[UsageKeys.relayMsg(mobile = false, foreground = true, received = true)])
|
||||
}
|
||||
|
||||
private fun eventWithId(id: String): EventMessage {
|
||||
val ev = mockk<Event>(relaxed = true)
|
||||
every { ev.id } returns id
|
||||
val msg = mockk<EventMessage>(relaxed = true)
|
||||
every { msg.subId } returns "s1"
|
||||
every { msg.event } returns ev
|
||||
every { msg.label() } returns EventMessage.LABEL
|
||||
return msg
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSameEventFromTwoRelaysIsCountedOnceAsNewAndOnceAsDuplicate() =
|
||||
runLedger { accountant, l ->
|
||||
// The outbox fan-out asks many relays for the same authors, so one event
|
||||
// arrives once per relay carrying it. That repetition is the measurement.
|
||||
val a = relay("wss://relay.damus.io/")
|
||||
val b = relay("wss://nos.lol/")
|
||||
l.onConnected(a, 10, false)
|
||||
l.onConnected(b, 10, false)
|
||||
val id = "a".repeat(64)
|
||||
l.onIncomingMessage(a, "0123456789", eventWithId(id))
|
||||
l.onIncomingMessage(b, "0123456789", eventWithId(id))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(2L, c[UsageKeys.relayEventsSeen(mobile = false, foreground = true)])
|
||||
assertEquals(1L, c[UsageKeys.relayEventsDup(mobile = false, foreground = true)])
|
||||
assertEquals(10L, c[UsageKeys.relayEventsDupBytes(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateBytesAreAttributedToThePurposeThatReceivedThem() =
|
||||
runLedger { accountant, l ->
|
||||
// Global duplication says how much is wasted; this says where, which is
|
||||
// what separates "suppress redundant delivery" from "stop fetching it".
|
||||
val a = relay("wss://relay.damus.io/")
|
||||
val b = relay("wss://nos.lol/")
|
||||
l.onConnected(a, 10, false)
|
||||
l.onConnected(b, 10, false)
|
||||
l.onSent(a, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.USER_PROFILE))), success = true)
|
||||
l.onSent(b, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.USER_PROFILE))), success = true)
|
||||
|
||||
val id = "b".repeat(64)
|
||||
l.onIncomingMessage(a, "0123456789", eventWithId(id))
|
||||
l.onIncomingMessage(b, "0123456789", eventWithId(id))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(20L, c[UsageKeys.relayPurposeDown("user_profile")])
|
||||
// Only the second copy is waste.
|
||||
assertEquals(10L, c[UsageKeys.relayPurposeDupBytes("user_profile")])
|
||||
assertEquals(10L, c[UsageKeys.relayEventsDupBytes(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFirstDeliveryIsNeverBookedAsDuplicate() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true)
|
||||
l.onIncomingMessage(r, "0123456789", eventWithId("c".repeat(64)))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(10L, c[UsageKeys.relayPurposeDown("home_feed")])
|
||||
assertNull(c[UsageKeys.relayPurposeDupBytes("home_feed")])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun distinctEventsAreNotDuplicates() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
// Differ only past the 64-bit prefix would be a collision; differ within it.
|
||||
l.onIncomingMessage(r, "01234", eventWithId("1".repeat(64)))
|
||||
l.onIncomingMessage(r, "01234", eventWithId("2".repeat(64)))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(2L, c[UsageKeys.relayEventsSeen(mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.relayEventsDup(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMalformedEventIdIsCountedButNeverDeduplicated() =
|
||||
runLedger { accountant, l ->
|
||||
val r = relay("wss://relay.damus.io/")
|
||||
l.onConnected(r, 10, false)
|
||||
l.onIncomingMessage(r, "01234", eventWithId("short"))
|
||||
l.onIncomingMessage(r, "01234", eventWithId("short"))
|
||||
l.onIncomingMessage(r, "01234", eventWithId("zzzz".repeat(16)))
|
||||
|
||||
val c = counters(accountant)
|
||||
assertEquals(3L, c[UsageKeys.relayEventsSeen(mobile = false, foreground = true)])
|
||||
assertNull(c[UsageKeys.relayEventsDup(mobile = false, foreground = true)])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyCommandAndMessageSubtypeHasItsOwnVerb() {
|
||||
// Labels are read off *instances*, because `cmd.label()` is what
|
||||
// RelayUsageListener calls — a subtype whose label() didn't return its own
|
||||
// LABEL would be invisible to a constant-only check. Asserting these against
|
||||
// UsageKeyGrammarTest's lists then keeps the two inventories from drifting,
|
||||
// which they had already started to do.
|
||||
val cmdVerbs =
|
||||
listOf(
|
||||
ReqCmd("s", listOf()),
|
||||
CloseCmd("s"),
|
||||
EventCmd(mockk(relaxed = true)),
|
||||
AuthCmd(mockk(relaxed = true)),
|
||||
CountCmd("s", listOf()),
|
||||
).map { it.label() }
|
||||
val msgVerbs =
|
||||
listOf(
|
||||
EventMessage("s", mockk(relaxed = true)),
|
||||
EoseMessage("s"),
|
||||
OkMessage("id", true, ""),
|
||||
NoticeMessage("m"),
|
||||
AuthMessage("challenge"),
|
||||
ClosedMessage("s", "m"),
|
||||
CountMessage("s", mockk(relaxed = true)),
|
||||
NotifyMessage("m"),
|
||||
LimitsMessage(),
|
||||
).map { it.label() }
|
||||
|
||||
assertEquals(UsageKeyGrammarTest.CMD_LABELS.toSet(), cmdVerbs.toSet())
|
||||
assertEquals(UsageKeyGrammarTest.MSG_LABELS.toSet(), msgVerbs.toSet())
|
||||
|
||||
// No two labels may collide within a direction, and none may be key-hostile
|
||||
// (a dot would inject uncontrolled segments — see UsageKeys.sumMatching).
|
||||
assertEquals(cmdVerbs.size, cmdVerbs.distinct().size)
|
||||
assertEquals(msgVerbs.size, msgVerbs.distinct().size)
|
||||
(cmdVerbs + msgVerbs).forEach {
|
||||
assertFalse("Label '$it' is not usable as a counter key segment", it.isEmpty() || it.contains('.'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTICE classification. The reason must come from a fixed set: this key is
|
||||
* persisted for 30 days and the text behind it is written by the relay.
|
||||
*/
|
||||
class NoticeReasonTest {
|
||||
@Test
|
||||
fun refusalsAreRecognisedWhateverTheRelayCallsThem() {
|
||||
// strfry's, the one Hypothesis N is about. Its NIP-01 prefix is just
|
||||
// "error", so prefix matching alone would not have caught it.
|
||||
assertEquals(UsageKeys.NOTICE_TOO_MANY_SUBS, UsageKeys.noticeReason("ERROR: too many concurrent REQs"))
|
||||
assertEquals(UsageKeys.NOTICE_TOO_MANY_SUBS, UsageKeys.noticeReason("too many concurrent NEG requests"))
|
||||
assertEquals(UsageKeys.NOTICE_TOO_MANY_SUBS, UsageKeys.noticeReason("blocked: too many subscriptions"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardPrefixesAreCategorised() {
|
||||
assertEquals(UsageKeys.NOTICE_AUTH_REQUIRED, UsageKeys.noticeReason("auth-required: we need to know you"))
|
||||
assertEquals(UsageKeys.NOTICE_RATE_LIMITED, UsageKeys.noticeReason("rate-limited: slow down"))
|
||||
assertEquals(UsageKeys.NOTICE_RESTRICTED, UsageKeys.noticeReason("restricted: not on the allowlist"))
|
||||
assertEquals(UsageKeys.NOTICE_INVALID, UsageKeys.noticeReason("invalid: bad filter"))
|
||||
assertEquals(UsageKeys.NOTICE_BLOCKED, UsageKeys.noticeReason("blocked: you are banned"))
|
||||
assertEquals(UsageKeys.NOTICE_ERROR, UsageKeys.noticeReason("error: something broke"))
|
||||
}
|
||||
|
||||
/** Verbatim from a device on 2026-08-02 — the wordings the allowlist was missing. */
|
||||
@Test
|
||||
fun realWorldNoticesAreClassified() {
|
||||
// Refusals. Each one drops a REQ that then never EOSEs, so its `since` never
|
||||
// advances and syncState re-sends it on every reconnect.
|
||||
assertEquals(UsageKeys.NOTICE_QUERY_COST, UsageKeys.noticeReason("Kgo0HH: closed: too many steps"))
|
||||
assertEquals(UsageKeys.NOTICE_QUERY_COST, UsageKeys.noticeReason("too many kinds"))
|
||||
assertEquals(UsageKeys.NOTICE_REQ_REFUSED, UsageKeys.noticeReason("Denied! This relay does not accept REQs."))
|
||||
|
||||
// Chatter that costs bytes and means nothing.
|
||||
assertEquals(UsageKeys.NOTICE_BENIGN, UsageKeys.noticeReason("keepalive"))
|
||||
assertEquals(UsageKeys.NOTICE_BENIGN, UsageKeys.noticeReason("as7rp4: PERF: [/!\\ LS] 1087 scan, 0 dedup, 500 match"))
|
||||
|
||||
// A bare subscription id carries no meaning and must stay unclassified rather
|
||||
// than being read as a machine-readable prefix.
|
||||
assertEquals(UsageKeys.NOTICE_UNCLASSIFIED, UsageKeys.noticeReason("AccountFollowsLoaderSubAssemblerxE1r8A"))
|
||||
assertEquals(UsageKeys.NOTICE_UNCLASSIFIED, UsageKeys.noticeReason("Kgo0HH"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSubscriptionIdPrefixDoesNotHideTheRealOne() {
|
||||
// These relays send "<subId>: <prefix>: <text>", which makes the subId the
|
||||
// prefix and buries the standard one behind it.
|
||||
assertEquals(UsageKeys.NOTICE_AUTH_REQUIRED, UsageKeys.noticeReason("sub123: auth-required: need auth"))
|
||||
assertEquals(UsageKeys.NOTICE_RATE_LIMITED, UsageKeys.noticeReason("sub123: rate-limited: slow down"))
|
||||
// Still works without the prefix.
|
||||
assertEquals(UsageKeys.NOTICE_AUTH_REQUIRED, UsageKeys.noticeReason("auth-required: need auth"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freeFormProseNeverBecomesAKey() {
|
||||
// The bug RelayObserver had to fix: without a fixed output set, cardinality
|
||||
// grows with the number of distinct sentences relays happen to write.
|
||||
listOf(
|
||||
"hello there",
|
||||
"Please contact admin@example.com for access",
|
||||
"",
|
||||
"::::",
|
||||
"a".repeat(5000),
|
||||
).forEach {
|
||||
val reason = UsageKeys.noticeReason(it)
|
||||
assertTrue("'$it' produced unlisted reason '$reason'", reason in UsageKeys.NOTICE_REASONS)
|
||||
}
|
||||
assertEquals(UsageKeys.NOTICE_UNCLASSIFIED, UsageKeys.noticeReason("hello there"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnlistedReasonCannotMintAKey() {
|
||||
assertEquals(UsageKeys.relayNotice(UsageKeys.NOTICE_UNCLASSIFIED), UsageKeys.relayNotice("something-invented"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The report is sent as a NIP-17 DM, so its size is a correctness property, not a
|
||||
* cosmetic one: relays cap events and an oversized report is simply never delivered.
|
||||
*/
|
||||
class ReportSizeTest {
|
||||
private fun day(keys: Int) = (0 until keys).associate { "relay.purpose.p$it.bytes" to 123_456L }
|
||||
|
||||
@Test
|
||||
fun aFullLedgerStaysSendable() {
|
||||
// 30 retained days at the density a busy day now produces.
|
||||
val days = (1L..30L).associateWith { day(1_200) }
|
||||
val report = ResourceUsageReportAssembler().buildReport(days, today = 30L)
|
||||
|
||||
assertTrue("report was ${report.length} chars", report.length < 100_000)
|
||||
assertTrue("nothing was said about the omission", report.contains("omitted to keep this report sendable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSmallLedgerIsNotTruncatedAndSaysNothingAboutOmission() {
|
||||
val days = (1L..3L).associateWith { day(20) }
|
||||
val report = ResourceUsageReportAssembler().buildReport(days, today = 3L)
|
||||
|
||||
assertTrue(report.contains("day 1 "))
|
||||
assertTrue(report.contains("day 3 "))
|
||||
assertFalse(report.contains("omitted"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNewestDayIsKeptEvenIfItAloneExceedsTheBudget() {
|
||||
// Dropping everything would leave a report that says nothing at all.
|
||||
val days = mapOf(1L to day(50), 2L to day(20_000))
|
||||
val report = ResourceUsageReportAssembler().buildReport(days, today = 2L)
|
||||
|
||||
assertTrue("newest day missing", report.contains("day 2 "))
|
||||
assertTrue(report.contains("1 earlier day(s) omitted"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun omittedDaysStillCountTowardTheSummaryTables() {
|
||||
// The tables are built from every day; only the raw dump is bounded.
|
||||
val days = (1L..30L).associateWith { mapOf(UsageKeys.relayConnects(mobile = false, foreground = true) to 10L) }
|
||||
val report = ResourceUsageReportAssembler().buildReport(days, today = 30L)
|
||||
|
||||
// 7 days x 10 connections in the week table.
|
||||
assertTrue(report.contains("| Relay reconnections | 70 "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,4 +162,44 @@ class ShareHelperTest {
|
||||
file.writeBytes(bytes)
|
||||
return file
|
||||
}
|
||||
|
||||
// A slash-less Intent.type matches no IntentFilter, so the chooser opens with zero targets and
|
||||
// the share silently fails. Author-supplied `m` tags can carry exactly that (Primal iOS emits
|
||||
// `m jpeg`), so a bare subtype must be repaired rather than forwarded.
|
||||
@Test
|
||||
fun resolveShareMimeType_bareSubtype_isExpandedToFullMimeType() {
|
||||
assertEquals("image/jpeg", ShareHelper.resolveShareMimeType("jpeg", "jpg"))
|
||||
assertEquals("video/mp4", ShareHelper.resolveShareMimeType("mp4", "mp4"))
|
||||
assertEquals("image/jpeg", ShareHelper.resolveShareMimeType("JPEG", "jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveShareMimeType_wellFormedDeclaration_isPreserved() {
|
||||
assertEquals("image/png", ShareHelper.resolveShareMimeType("image/png", "png"))
|
||||
}
|
||||
|
||||
// An unusable declaration falls back to the extension, which is sniffed from the file's magic
|
||||
// numbers and so is not attacker-controlled.
|
||||
@Test
|
||||
fun resolveShareMimeType_unrecognizableDeclaration_fallsBackToSniffedExtension() {
|
||||
assertEquals("image/png", ShareHelper.resolveShareMimeType("notatype", "png"))
|
||||
assertEquals("image/png", ShareHelper.resolveShareMimeType("", "png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveShareMimeType_noDeclaration_usesCanonicalTypeForExtension() {
|
||||
// Not "image/jpg" -- jpg is not a registered subtype.
|
||||
assertEquals("image/jpeg", ShareHelper.resolveShareMimeType(null, "jpg"))
|
||||
assertEquals("video/quicktime", ShareHelper.resolveShareMimeType(null, "mov"))
|
||||
}
|
||||
|
||||
// The invariant the share sheet depends on, pinned across the whole set of extensions
|
||||
// getMediaExtension can sniff: whatever the event declared, Intent.type stays matchable.
|
||||
@Test
|
||||
fun resolveShareMimeType_alwaysProducesASlashSeparatedType() {
|
||||
listOf("jpg", "png", "gif", "webp", "webm", "avi", "mp4", "mov").forEach { extension ->
|
||||
val resolved = ShareHelper.resolveShareMimeType("notatype", extension)
|
||||
assertTrue("`$extension` resolved to un-matchable type `$resolved`", resolved.contains("/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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.navigation
|
||||
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavOptionsBuilder
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard
|
||||
* height for the whole app, because `WindowInsets.ime` is a single shared holder. The fix is that
|
||||
* [Nav] waits for the IME to be gone before it moves, so these assert the ordering rather than any
|
||||
* visual result: every transition must settle the keyboard *first*.
|
||||
*
|
||||
* Without the settle calls in [Nav] each of these records only "navigate" and fails.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NavImeSettleTest {
|
||||
private fun controllerRecording(order: MutableList<String>): NavHostController =
|
||||
mockk<NavHostController>(relaxed = true) {
|
||||
every { navigate(any<Route>(), any<NavOptionsBuilder.() -> Unit>()) } answers
|
||||
{ order.add("navigate") }
|
||||
every { navigate(any<Route>()) } answers { order.add("navigate") }
|
||||
every { navigateUp() } answers {
|
||||
order.add("navigate")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun popBackSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.popBack()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomBarSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
// The search tab focuses its field on arrival, so the keyboard is already up when the
|
||||
// user taps another tab — the exit that has no BackHandler and no top bar to guard it.
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.navBottomBar(Route.Home)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newStackSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.newStack(Route.Home)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSlowKeyboardStillHoldsTheNavigationBack() =
|
||||
runTest {
|
||||
// The real settler suspends for the length of the IME close animation. Navigation must
|
||||
// wait for it, not fire alongside it — that overlap is the bug.
|
||||
val order = mutableListOf<String>()
|
||||
val nav =
|
||||
Nav(
|
||||
controllerRecording(order),
|
||||
this,
|
||||
ImeSettler {
|
||||
delay(250)
|
||||
order.add("settle")
|
||||
},
|
||||
)
|
||||
|
||||
nav.popBack()
|
||||
assertEquals(emptyList<String>(), order)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
}
|
||||
+33
-4
@@ -254,7 +254,14 @@ val jlinkRuntime =
|
||||
}
|
||||
|
||||
// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE).
|
||||
// Cross-platform — the release workflow tars this up on every OS.
|
||||
// Cross-platform — the release workflow archives this on every OS (tar.gz on
|
||||
// unix, zip on Windows). We write BOTH a POSIX `amy` shell launcher AND a
|
||||
// Windows `amy.bat` launcher into `bin/` unconditionally so the same tree is
|
||||
// runnable on any target after extraction, regardless of which OS built it.
|
||||
// (The bundled jlink runtime is host-native — you still need to unzip a
|
||||
// Windows-built image on Windows to actually launch it — but the launcher
|
||||
// scripts themselves are host-agnostic, which keeps the layout uniform and
|
||||
// makes ad-hoc cross-machine inspection painless.)
|
||||
val amyImage =
|
||||
tasks.register<Sync>("amyImage") {
|
||||
group = "distribution"
|
||||
@@ -282,13 +289,35 @@ val amyImage =
|
||||
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
|
||||
exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
|
||||
""".trimIndent() + "\n"
|
||||
// Windows launcher. Uses %~dp0 (drive+path of this .bat, always ending in
|
||||
// a backslash) so it resolves the app root without depending on CWD, then
|
||||
// execs the bundled JRE against lib\*. `chcp 65001` pins the console to
|
||||
// UTF-8 so `sun.jnu.encoding` isn't the OS OEM code page — same rationale
|
||||
// as the installDist launcher patch above. CRLF line endings so cmd.exe
|
||||
// parses it correctly.
|
||||
//
|
||||
// The `for %%i in (...) do set DIR=%%~fi` trick canonicalises `\bin\..`
|
||||
// out of DIR to the parent directory — same idiom Gradle's own
|
||||
// installDist .bat uses to resolve APP_HOME. Java tolerates the `..`
|
||||
// segment but canonicalising once here keeps every classpath entry and
|
||||
// error message clean (and matches the loose-directory layout users see
|
||||
// after unzipping the release archive).
|
||||
val windowsLauncher =
|
||||
"@echo off\r\n" +
|
||||
"chcp 65001 > NUL 2>&1\r\n" +
|
||||
"setlocal\r\n" +
|
||||
"set \"DIR=%~dp0..\"\r\n" +
|
||||
"for %%i in (\"%DIR%\") do set \"DIR=%%~fi\"\r\n" +
|
||||
"\"%DIR%\\runtime\\bin\\java.exe\" -Djava.awt.headless=true -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n"
|
||||
|
||||
doLast {
|
||||
val binDir = amyImageDir.get().asFile.resolve("bin")
|
||||
binDir.mkdirs()
|
||||
val launcher = binDir.resolve("amy")
|
||||
launcher.writeText(unixLauncher)
|
||||
launcher.setExecutable(true, false)
|
||||
val unix = binDir.resolve("amy")
|
||||
unix.writeText(unixLauncher)
|
||||
unix.setExecutable(true, false)
|
||||
val windows = binDir.resolve("amy.bat")
|
||||
windows.writeText(windowsLauncher)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -668,7 +668,7 @@ class Context(
|
||||
val filters = relays.associateWith { listOf(responseFilter) }
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+7
-5
@@ -150,12 +150,14 @@ object ConcordChannelCommands {
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
): ConcordCommunityState {
|
||||
val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
val controlPlane = ConcordCommands.controlPlaneKeysFor(sc)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
// The relays gate the plane's kind-1059 behind NIP-42 as the derived stream key — register
|
||||
// it so the drain's AUTH challenge is answered as the control plane, not the account.
|
||||
ctx.registerConcordStreamKeys(relays, listOf(controlPlane.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
// The relays gate the plane's kind-1059 behind NIP-42 as the stream key — register it so
|
||||
// the drain's AUTH challenge is answered as the control plane, not the account. On a split
|
||||
// epoch only staff hold that secret (CORD-02 §2); a plain member registers nothing and
|
||||
// relies on the relay serving the plane unauthenticated.
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -119,6 +120,10 @@ object ConcordCommands {
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
// The creator is the founding staff member: it keeps the write secret and
|
||||
// publishes the pubkey to everyone else (CORD-02 §2).
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
generalChannelId = community.generalChannelIdHex,
|
||||
relays = relays,
|
||||
),
|
||||
@@ -179,6 +184,14 @@ object ConcordCommands {
|
||||
val imported =
|
||||
entries.map { e ->
|
||||
val prior = existing[e.id]
|
||||
// Control key material is per-epoch (CORD-02 §2): a stored value may only
|
||||
// backstop a list entry from the SAME epoch (e.g. another client republished
|
||||
// the list without the extension fields). Across a rotation the old pair is
|
||||
// stale — a prior-epoch control_root would derive a wrong address entirely,
|
||||
// and a prior-epoch control_pk would shadow a legacy rotation's address — so
|
||||
// it must never be carried forward (the invariant adoption enforces with its
|
||||
// derive-check, which this path has no way to run).
|
||||
val priorSameEpoch = prior?.takeIf { it.rootEpoch == e.rootEpoch }
|
||||
store.upsert(
|
||||
StoredCommunity(
|
||||
name = e.name.ifBlank { prior?.name ?: "" },
|
||||
@@ -187,15 +200,23 @@ object ConcordCommands {
|
||||
ownerSalt = e.ownerSalt,
|
||||
root = e.root,
|
||||
rootEpoch = e.rootEpoch,
|
||||
// Carried straight from the list entry: the Control Plane address is
|
||||
// delivered, never derivable (CORD-02 §2), and the write secret only
|
||||
// rides the list when this account is staff. Both blank on a legacy
|
||||
// community, which keeps its old single-key plane.
|
||||
controlPk = e.controlPk ?: priorSameEpoch?.controlPk ?: "",
|
||||
controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "",
|
||||
generalChannelId = prior?.generalChannelId ?: "",
|
||||
relays = e.relays,
|
||||
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key) },
|
||||
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") },
|
||||
),
|
||||
)
|
||||
mapOf(
|
||||
"name" to e.name,
|
||||
"community_id" to e.id,
|
||||
"root_epoch" to e.rootEpoch,
|
||||
"control_pk" to (e.controlPk ?: ""),
|
||||
"staff" to (e.controlRoot != null),
|
||||
"held_roots" to e.heldRoots.map { mapOf("epoch" to it.epoch, "root" to it.key) },
|
||||
)
|
||||
}
|
||||
@@ -216,7 +237,9 @@ object ConcordCommands {
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays)
|
||||
// The joiner cannot derive the Control Plane address, so the invite carries it
|
||||
// (CORD-05 §1); omitted for a legacy community, which has none to carry.
|
||||
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null })
|
||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
|
||||
val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc))
|
||||
RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it }
|
||||
@@ -257,6 +280,9 @@ object ConcordCommands {
|
||||
ownerSalt = bundle.ownerSalt,
|
||||
root = bundle.communityRoot,
|
||||
rootEpoch = bundle.rootEpoch,
|
||||
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
|
||||
// community is still pre-split and folds at the legacy address.
|
||||
controlPk = bundle.controlPk ?: "",
|
||||
relays = bundle.relays,
|
||||
),
|
||||
)
|
||||
@@ -276,6 +302,20 @@ object ConcordCommands {
|
||||
sc: StoredCommunity,
|
||||
): Set<NormalizedRelayUrl> = normalize(sc.relays).ifEmpty { ctx.outboxRelays() }
|
||||
|
||||
/**
|
||||
* The Control Plane keys for [sc] as this account holds them (CORD-02 §5): staff
|
||||
* (write key held), member (address held, read-only), or legacy (pre-split, keyed
|
||||
* by the `community_root` alone).
|
||||
*/
|
||||
fun controlPlaneKeysFor(sc: StoredCommunity) =
|
||||
ConcordActions.controlPlaneKeys(
|
||||
communityRoot = sc.root.hexToByteArray(),
|
||||
communityId = sc.communityId.hexToByteArray(),
|
||||
rootEpoch = sc.rootEpoch,
|
||||
controlPk = sc.controlPk.ifBlank { null },
|
||||
controlRoot = sc.controlRoot.ifBlank { null },
|
||||
)
|
||||
|
||||
fun notFound(handle: String): Int {
|
||||
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
|
||||
return 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user