Merge remote-tracking branch 'origin/main' into claude/nip-2421-pr-review-6znvdd

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
This commit is contained in:
Claude
2026-07-24 23:15:54 +00:00
206 changed files with 11841 additions and 2256 deletions
+17
View File
@@ -0,0 +1,17 @@
# Keep the Docker build context small + reproducible: exclude VCS metadata,
# Gradle/build outputs, IDE files, and local caches. The build stage runs a
# fresh `./gradlew :geode:installDist` inside the image, so nothing under any
# build/ directory needs to travel with the context.
.git
.github
**/build/
.gradle/
**/.gradle/
.idea/
*.iml
.kotlin/
**/.kotlin/
local.properties
# Docs + non-geode app modules aren't needed to build the relay, but the Gradle
# settings reference every module, so we can't drop the module source trees;
# only their build artifacts (covered above) are excluded.
+36
View File
@@ -110,6 +110,42 @@ jobs:
name: ${{ matrix.desktop-artifact-name }}
path: ${{ matrix.desktop-artifact-path }}
# geode (the standalone Nostr relay) is JVM-only, so it runs in its own job
# rather than the build-desktop matrix — one runner suffices (no reason to test
# a platform-independent module 3× across the desktop OS matrix). Isolating it
# also keeps its default suite's CPU-heavy throughput benchmarks (a 1M-event
# mirror sync, WireReqFloor, NegentropyServerReconcile) from contending with the
# timing-sensitive quartz relay-client tests under org.gradle.parallel — that
# contention flakes tests like NostrClientReqBypassingRelayLimitsTest.
test-geode:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Test geode (gradle)
run: ./gradlew :geode:test
- name: Upload geode Test Reports
uses: actions/upload-artifact@v7
if: failure()
with:
name: geode Test Reports
path: geode/build/reports
test-quartz-ios:
# Phase 1 of the iOS support plan
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
@@ -0,0 +1,155 @@
name: Bump Homebrew Formula (geode relay)
# Sibling of bump-homebrew-formula.yml (the amy CLI). Same mechanism, different
# artifact:
# - bump-homebrew-formula.yml -> Formula `amy` (the headless CLI)
# - this workflow -> Formula `geode` (the standalone relay)
#
# After a stable release, download the published `geode-<version>-jvm.tar.gz`
# bundle, compute its sha256, and open a PR that syncs
# `geode/packaging/homebrew/geode.rb`'s url + sha256 to that release. Keeping the
# in-repo reference formula accurate makes the eventual homebrew-core submission a
# copy-paste.
#
# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew
# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core,
# and `geode` has never been submitted there — that first submission is a manual,
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
# auto-bump here — see the "TODO(bootstrap)" note at the bottom of this file.
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-geode-formula-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.event.release.tag_name || inputs.tag }}"
VER="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.ver.outputs.tag }}"
VER="${{ steps.ver.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/geode-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# The `released` event can fire a hair before every matrix leg finishes
# uploading; retry with backoff (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o geode-jvm.tar.gz "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s geode-jvm.tar.gz
SHA=$(shasum -a 256 geode-jvm.tar.gz | awk '{print $1}')
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "geode-${VER}-jvm.tar.gz -> $SHA"
- name: Update reference formula
run: |
set -euo pipefail
FORMULA=geode/packaging/homebrew/geode.rb
URL="${{ steps.asset.outputs.url }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Rewrite the two indented lines in the formula block. Anchoring on the
# 2-space indent avoids touching the header comment's example curl url.
sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA"
sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA"
echo "----- $FORMULA -----"
grep -E "^ (url|sha256) " "$FORMULA"
- name: Open or update the formula-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-geode-formula-${{ steps.ver.outputs.tag }}
add-paths: geode/packaging/homebrew/geode.rb
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}'
title: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}'
body: |
Auto-synced `geode/packaging/homebrew/geode.rb` to the
`${{ steps.ver.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
Opened by `.github/workflows/bump-homebrew-geode-formula.yml`. Merge to
keep the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core, add
# a step here that opens the homebrew-core bump PR automatically (a pinned
# macauley/action-homebrew-bump-formula, or `brew bump-formula-pr geode
# --url=<url> --sha256=<sha>` with a HOMEBREW_TOKEN). It is intentionally
# omitted until then because bump-formula-pr errors on a formula that is not
# yet in the tap.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew-geode-formula failed for ${tag}`,
body: [
`geode Homebrew formula sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Formula (\`geode\` relay)`,
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Manually update \`geode/packaging/homebrew/geode.rb\` (url + sha256) from the release asset`,
`3. Check the release actually published \`geode-${tag.replace(/^v/, '')}-jvm.tar.gz\``
].join('\n'),
labels: ['release-ops', 'bug']
});
+299
View File
@@ -518,6 +518,305 @@ jobs:
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# geode relay build matrix. Same shape as build-cli — geode is the same kind
# of `application`-plugin JVM module — producing a self-contained bundle with a
# minimal jlink'd JRE (no system Java required) plus native Linux packages.
#
# geodeImage task (all legs): geode/build/geode-image/geode/ → geode-*.tar.gz
# jpackageDeb / jpackageRpm: geode/build/jpackage/geode_*.deb + geode-*.rpm
# no-JRE jvm bundle (linux): geode-<ver>-jvm.tar.gz for the Homebrew formula
#
# Unlike build-cli there is no "no Compose UI" assertion — geode depends only on
# :quartz and never pulls the Compose render stack. The GHCR Docker image is a
# separate job (docker-geode) below.
#
# Asset naming: geode-<version>-<family>-<arch>.<ext>. See scripts/asset-name.sh.
# ---------------------------------------------------------------------------
build-geode:
strategy:
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" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Resolve tag + version
id: ver
env:
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
TEST_TAG: ${{ github.event.inputs.test_tag || '' }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
TAG="${TEST_TAG:-v0.0.0-dryrun}"
else
TAG="${GITHUB_REF_NAME}"
fi
VER="${TAG#v}"
TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then
if [[ "$TOML_VER" != "$VER" ]]; then
echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG"
exit 1
fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Install RPM tooling (linux only)
if: matrix.family == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Build geode artifacts
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 15
command: ./gradlew --no-daemon :geode:${{ matrix.tasks }}
# Boot the bundled jlink image before shipping it. `--version` proves the
# JVM starts and the main class loads; the serve leg proves the jlink
# 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.
- 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 &
PID=$!
ok=0
for i in $(seq 1 20); do
if curl -fsS -H 'Accept: application/nostr+json' http://127.0.0.1:17447/ -o /tmp/nip11.json; then ok=1; break; fi
sleep 1
done
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
[[ "$ok" == 1 ]] || { echo "::error::geode image did not serve NIP-11 within 20s"; exit 1; }
echo "NIP-11 doc:"; head -c 400 /tmp/nip11.json; echo
grep -q '"supported_nips"' /tmp/nip11.json || { echo "::error::NIP-11 doc missing supported_nips"; exit 1; }
# macOS only: import the Developer ID cert (no-op without the secret) so
# the next step can codesign the jlink image. The jvm bundle for
# Homebrew-core is NOT signed here — Homebrew strips quarantine itself.
- name: Import Apple Developer ID certificate (macOS leg, if configured)
if: matrix.family == 'macos'
id: mac_keychain
uses: ./.github/actions/import-macos-cert
with:
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
# Codesign + notarize the macOS jlink image (geode-<ver>-macos-arm64.tar.gz)
# for operators who download it directly. A loose tarball can't be stapled
# (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization
# online on first run. Runs before "Collect" so the tarred image is signed.
- name: Sign + notarize geode image (macOS leg, if configured)
if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true'
env:
SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }}
NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
run: |
set -euo pipefail
IMG="geode/build/geode-image/geode"
ENTITLEMENTS="geode/packaging/macos/geode.entitlements"
# Sign the macOS Mach-O natives buried INSIDE the bundled jars
# (secp256k1/sqlite) first — Apple's notary recurses into jars and
# rejects any unsigned Mach-O, so this must run before notarize.
SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG"
# Sign every loose Mach-O binary in the bundled JRE. Executables get
# the hardened-runtime entitlements; dylibs don't.
while IFS= read -r f; do
case "$(file -b "$f")" in
*Mach-O*executable*)
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;;
*Mach-O*)
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$f" ;;
esac
done < <(find "$IMG" -type f)
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
# Notarize: zip the signed image, submit, wait for Apple's verdict.
ZIP="$RUNNER_TEMP/geode-notarize.zip"
OUT="$RUNNER_TEMP/notary-submit.json"
ditto -c -k --keepParent "$IMG" "$ZIP"
if ! xcrun notarytool submit "$ZIP" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then
echo "::warning::notarytool submit exited non-zero"
fi
cat "$OUT"
STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)"
SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)"
if [ "$STATUS" != "Accepted" ]; then
echo "::error::Notarization status: $STATUS"
if [ -n "$SUBMISSION_ID" ]; then
echo "----- notary log -----"
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" || true
fi
exit 1
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
- name: Relax libicu dependency in .deb
if: matrix.family == 'linux'
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh geode/build/jpackage/*.deb
- name: Collect + rename assets
run: |
set -euo pipefail
# shellcheck source=scripts/asset-name.sh
source scripts/asset-name.sh
collect_geode_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
# Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it
# cannot use the jlink tarball above (bundled runtime) nor build from
# source (its sandbox blocks Gradle's Maven downloads). installDist
# (bin/geode + lib/*.jar, no runtime/) is exactly that bundle. It is pure
# JVM bytecode, so one platform-independent asset serves every OS; we cut
# it on the linux leg only. geodeImage depends on installDist, so the
# geode/build/install/geode tree already exists here.
- name: Package no-JRE jvm bundle for Homebrew (linux leg only)
if: matrix.family == 'linux'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
SRC="geode/build/install/geode"
test -x "$SRC/bin/geode"
( cd "$SRC" && tar czf "$OLDPWD/dist/geode-${VER}-jvm.tar.gz" bin lib )
echo "Collected: dist/geode-${VER}-jvm.tar.gz"
- name: Enforce geode size budget (200 MB per asset)
run: |
set -euo pipefail
fail=0
for f in dist/*; do
if [[ -f "$f" ]]; then
size=$(wc -c < "$f")
mb=$(( size / 1048576 ))
if (( size > 209715200 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB geode budget"
fail=1
else
echo "OK: $f — ${mb} MB"
fi
fi
done
[[ "$fail" == 0 ]]
- name: Classify release
id: classify
run: |
TAG="${{ steps.ver.outputs.tag }}"
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
draft: false
fail_on_unmatched_files: true
generate_release_notes: false # Android job writes release notes (last-writer-wins race)
- name: Dry-run summary
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: |
echo "### Dry-run: geode ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# geode Docker image → GitHub Container Registry (GHCR). A relay is most often
# deployed as a container, so this is geode's primary distribution channel.
# Builds geode/Dockerfile (multi-stage: gradle installDist → temurin JRE) and
# pushes ghcr.io/<owner>/geode:<version> (+ :latest on a stable release).
# Tag-push only — skipped on the workflow_dispatch dry-run.
# ---------------------------------------------------------------------------
docker-geode:
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve version + tags
id: meta
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VER="${TAG#v}"
# Lowercase owner — GHCR repository paths must be lowercase.
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
IMAGE="ghcr.io/${OWNER}/geode"
TAGS="${IMAGE}:${VER}"
# Only move :latest for a stable vX.Y.Z tag, never a prerelease.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TAGS="${TAGS},${IMAGE}:latest"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: geode/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ---------------------------------------------------------------------------
# Android build + sign + direct-upload. Logic preserved from previous workflow;
# uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset.
@@ -0,0 +1,380 @@
# Push Notification Redesign — beautiful, modern, per-kind tray notifications
**Date:** 2026-07-23
**Goal:** Replace the flat, mostly-`BigTextStyle`, single-accent tray
notifications with a per-kind design system: distinct accent colors, status-bar
icons, notification *styles* (MessagingStyle / BigPictureStyle / colorized zap
cards), smart aggregation, and Conversation-section integration — so every kind
of Nostr notification (zap, reaction, reply, mention, DM, repost, nutzap, media,
git, badge, chess…) reads at a glance and looks native to modern Android
(12–15).
This is a **rendering / UX** plan. The delivery/transport layers (FCM,
UnifiedPush, the always-on relay service, gift-wrap unwrap, account matching,
mute/age/self gates) are untouched — everything below happens after
`EventNotificationConsumer.dispatchForAccount()` decides an event is notifiable.
---
## 1. Current state (what we render today)
**Pipeline (unchanged by this plan):**
`PushNotificationReceiverService` (FCM) / `PushMessageReceiver` (UnifiedPush) /
`NotificationRelayService` (always-on) → `PushWrapDecryptor.unwrapAndFeed` →
`LocalCache` → `NotificationDispatcher` (kind + age + `tagsAnEventByUser` gate) →
`EventNotificationConsumer.dispatchForAccount()` → per-kind `notify*()` →
`NotificationUtils` (`NotificationCompat.Builder`).
**What the tray looks like now:**
| Channel | Importance | Style used | Accent | Small icon | Actions |
| --- | --- | --- | --- | --- | --- |
| DMs (`PrivateMessagesID`) | HIGH | `MessagingStyle` ✅ | none | amethyst logo | Reply, Mark-read |
| Zaps (`ZapsID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
| Reactions (`ReactionsID`) | DEFAULT | `BigTextStyle` (+emoji avatar badge) | none | amethyst logo | — |
| Replies (`RepliesID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | Reply (inline) |
| Mentions (`MentionsID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
| Chess (`ChessID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
**Problems this plan fixes:**
1. **No color identity.** Every notification uses the same monochrome amethyst
status-bar icon and no `setColor()`. A zap, a like, and a git PR are visually
indistinguishable in the shade until you read the text.
2. **Only DMs get a real style.** Replies, mentions, public-chat, group chat all
fall back to `BigTextStyle` even though they are conversations that
`MessagingStyle` renders far better (avatar + name + threaded messages, and
it's what Android promotes into the Conversations section).
3. **Media notifications are text-only.** A `PictureEvent` / `VideoEvent`
mention says "X mentioned you" with no preview. `BigPictureStyle` should show
the actual image.
4. **A dozen kinds masquerade as "mentions."** Picture, video (×4), poll, git
(×4), highlight, long-form, wiki all route to the identical
`notifyMention()` → "X mentioned you". They deserve distinct titles/icons.
5. **No aggregation.** The in-app feed rolls up "5 people reacted / zapped your
note" into one `MultiSetCard`; the tray posts N separate notifications. This
is the biggest source of notification spam.
6. **Kind gaps vs. the in-app feed.** The push `when(event)` in
`dispatchForAccount` does **not** route `NutzapEvent` (9321), `OnchainZapEvent`
(8333), `RepostEvent`/`GenericRepostEvent` (6/16), `BadgeAwardEvent` (8),
`LiveActivitiesChatMessageEvent` (1311), or NIP-C7 `ChatEvent` (9) — all of
which *do* appear in the in-app Notifications tab. (`NotificationDispatcher`'s
`NOTIFICATION_KINDS` also has to grow for these to arrive at all.)
7. **Zaps look plain.** A zap — the app's signature interaction — renders as
grey `BigTextStyle` text. It should be a colorized gold card with the amount
as the hero.
---
## 2. Design principles ("beautiful & modern" made concrete)
Modern Android notification design (Material 3, API 26–35) gives us six levers.
The redesign uses all six, per kind:
- **P1 — Accent color (`setColor`) + status-bar icon per category.** Each
category gets a monochrome small icon and a brand-consistent accent so the
shade is scannable pre-read. Palette anchored on existing tokens
(`Color.kt`): zaps `BitcoinOrange 0xFFF7931A`, reactions a heart-red, replies
& mentions amethyst `Purple200/500`, DMs a messaging blue-green, git a slate,
badges a gold. Reserve `setColorized(true)` (full-surface color) for the two
highest-signal kinds only — **zaps** and **incoming calls** (calls already
effectively do this via `CallStyle`).
- **P2 — Right style per kind.** `MessagingStyle` for anything conversational
(DMs, replies, mentions-that-are-replies, public/group chat); `BigPictureStyle`
for media; a colorized `BigTextStyle` "amount card" for zaps/nutzaps;
`InboxStyle` for aggregated roll-ups; plain for the rest.
- **P3 — Aggregation that mirrors the in-app `MultiSetCard`.** Reactions,
reposts, and zaps on the *same target note* collapse into one updating
notification: "🤙 Alice, Bob & 3 others liked your post." Reuse the exact
bucketing rules from `CardFeedContentState.convertToCard` (per-target,
per-day, chunk-of-30) so tray and feed agree.
- **P4 — Conversations & People (API 30+).** Publish a dynamic
`ShortcutInfoCompat` per chat partner / thread, tag DM & reply notifications
with `setShortcutId` + `addPerson`, and attach `BubbleMetadata`. This moves
chats into the dedicated Conversations section, enables bubbles, and gives
long-press "priority/silence" controls — the single most "modern" upgrade.
- **P5 — Circular avatars + emoji/kind badges.** Circle-crop the large-icon
avatar (today it's the raw square bitmap); keep the existing NIP-30
custom-emoji corner-badge overlay and extend the same overlay helper to stamp
a small kind glyph (bolt/heart/reply) so the avatar itself signals the kind.
- **P6 — Richer, semantic actions.** Reply (have it), **Mark-read** (have it for
DMs — extend to all), plus per-kind: **Like back** / **Zap back** on
reaction & zap notifications, **View thread** / **Mute thread** on replies &
mentions. All via `SEMANTIC_ACTION_*` so Wear/Auto/Assistant render them
correctly.
---
## 3. Proposed architecture — a per-kind renderer registry
Today the logic is split between a large `when(event)` in
`EventNotificationConsumer` and six near-duplicate `send*Notification` builders
in `NotificationUtils`. We refactor to a **spec + one builder** shape (the same
idea the desktop path already uses via `notifKindFor` + `buildSpec` in
`DesktopNotificationAutoDispatcher`).
**New: `NotificationCategory` (enum) —** the visual identity, replacing the six
ad-hoc channels. Each carries: channel id/name/importance, accent color,
small-icon drawable, default style, and default group key.
```
enum class NotificationCategory {
DIRECT_MESSAGE, // DMs, group chat — HIGH, blue-green, MessagingStyle, Conversation
REPLY, // replies to me — DEFAULT, purple, MessagingStyle, per-thread group
MENTION, // text mention/quote — DEFAULT, purple, plain/MessagingStyle
ZAP, // ln + nutzap + onchain — DEFAULT, gold, colorized amount card, aggregated
REACTION, // likes/emoji — LOW, heart-red, aggregated InboxStyle
REPOST, // boosts — LOW, green, aggregated
MEDIA, // picture/video mention — DEFAULT, purple, BigPictureStyle
ARTICLE, // long-form/wiki/highlight mention — DEFAULT, purple, plain
CODE, // git issue/patch/PR — DEFAULT, slate, plain
BADGE, // NIP-58 award — DEFAULT, gold, BigPictureStyle (badge image)
CHESS, // game events — DEFAULT, brown, plain
// calls keep their own CallNotifier / CallStyle path (already modern)
}
```
**New: `NotificationSpec` (data class) —** the fully-resolved, ready-to-render
description of one notification, produced per event:
```
data class NotificationSpec(
val category: NotificationCategory,
val dedupeId: String, // event id (existing id.hashCode() keying)
val title: String,
val body: String,
val timestamp: Long,
val author: PersonRef?, // name + avatar url → Person / large icon
val bigPictureUrl: String?, // media / badge image
val badgeUrl: String?, // NIP-30 emoji / kind glyph overlay
val deepLinkUri: String, // existing nostr:...?account=...&scrollTo=...
val groupKey: String, // aggregation bucket (per-thread / per-target)
val actions: List<NotifAction>,// Reply / ZapBack / Mute / MarkRead …
val aggregatable: Boolean, // reactions/zaps/reposts → roll-up
)
```
**New: `NotificationSpecFactory` —** one `fun specFor(event, account, ctx):
NotificationSpec?` that owns all the per-kind text/style decisions currently
scattered across `notify()/notifyReply()/notifyMention()/notifyZap()`. Returns
`null` when the event shouldn't notify. This is the single place a future kind
gets added.
**Rewritten: `NotificationUtils.render(spec)` —** one builder that reads the
spec, selects the style, applies accent/icon/colorized, attaches actions and
Person/shortcut, and handles aggregation + group summaries. The six
`send*Notification` functions collapse into this.
**Kept as-is:** `EventNotificationConsumer.dispatchForAccount` remains the gate
(mute/age/self/known-room checks) but its tail becomes
`NotificationUtils.render(NotificationSpecFactory.specFor(event, account, ctx))`.
This keeps the *policy* (who gets notified) exactly where it is and isolates the
*presentation* (how it looks) into a testable, table-driven unit.
---
## 4. Per-kind visual specification (the heart of the redesign)
| Kind(s) | Category | Style | Accent / icon | Title → Body | Actions |
| --- | --- | --- | --- | --- | --- |
| NIP-17 `ChatMessageEvent` (14), file (15), NIP-04 `PrivateDmEvent` (4), Marmot group | DIRECT_MESSAGE | `MessagingStyle` + Conversation shortcut + Bubble | blue-green / `chat` | sender → message | Reply, Mark-read |
| `TextNoteEvent`(1)/`CommentEvent`(1111)/`ChannelMessageEvent`(42) **reply to me** | REPLY | `MessagingStyle` (parent as prior message) | purple / `reply` | "X replied" → excerpt (+ quoted parent) | Reply, View thread, Mute thread |
| Same kinds, **mention/quote only** | MENTION | plain (BigText) | purple / `alternate_email` | "X mentioned you" → excerpt | View, Mute thread |
| `LnZapEvent`(9735) | ZAP | **colorized** amount card (BigText) | gold / `bolt` | "⚡ 2,100 sats from X" → zap comment + zapped-note excerpt | Zap back, View |
| `NutzapEvent`(9321) *(new)* | ZAP | colorized amount card | gold / `bolt` (cashu tint) | "🥜 X nutzapped you 2,100 sats" → … | Zap back, View |
| `OnchainZapEvent`(8333) *(new)* | ZAP | colorized amount card | gold / `bolt` | "⛓ X sent an onchain zap" → … | View |
| `ReactionEvent`(7) | REACTION | aggregated `InboxStyle` | heart-red / `favorite` | "🤙 X & N others liked your post" → note excerpt | Like back, View |
| `RepostEvent`(6)/`GenericRepostEvent`(16) *(new)* | REPOST | aggregated | green / `repeat` | "X & N others reposted you" → excerpt | View |
| `PictureEvent`(20)/`VideoNormal`/`Short`/`Vertical`/`Horizontal` | MEDIA | `BigPictureStyle` | purple / `image` | "X shared a photo/video" → caption, image inline | View |
| `PollEvent`/`ZapPollEvent` | MENTION | plain | purple / `ballot` | "X asked a question" → poll title | Vote, View |
| `HighlightEvent`(9802) | ARTICLE | plain | purple / `format_quote` | "X highlighted your article" → highlighted text | View |
| `LongTextNoteEvent`(30023)/`WikiNoteEvent`(30818) | ARTICLE | plain | purple / `article` | "X mentioned you in an article" → title | View |
| `GitIssueEvent`/`GitPatchEvent`/`GitPullRequestEvent`/`…Update` | CODE | plain | slate / `merge` | "X opened an issue/PR" → subject | View |
| `BadgeAwardEvent`(8) *(new)* | BADGE | `BigPictureStyle` (badge art) | gold / `award_star` | "You earned a badge" → badge name, image | View |
| `LiveChessGameAcceptEvent`/`LiveChessMoveEvent` | CHESS | plain | brown / `chess` | "X accepted your challenge" / "your turn" | Open board |
| `CallOfferEvent` | (unchanged — `CallNotifier`/`CallStyle`) | — | — | — | Accept/Reject |
Titles/bodies stay in `strings.xml` (translatable) with plurals for the
aggregate counts (see §7).
---
## 5. Channel restructuring
Channels are a **user-visible, migration-sensitive** surface (users may have
customized importance/sound). Plan:
- **Keep** the six existing channel IDs (DMs, Zaps, Reactions, Replies,
Mentions, Chess) — never rename an ID, it orphans user settings.
- **Add** channels for the newly-rendered categories that don't map cleanly onto
an existing one: **Reposts** (`RepostsID`, LOW), **Media** (`MediaID`,
DEFAULT), **Git/Code** (`CodeID`, DEFAULT), **Badges** (`BadgesID`, DEFAULT).
Nutzaps/onchain fold into the existing **Zaps** channel; articles/highlights
fold into **Mentions**.
- **Group channels** with `NotificationChannelGroup` ("Social", "Payments",
"Messages", "Developer") so the system settings page and our in-app
`NotificationSettingsScreen` read as organized rather than a flat list of 10.
- Register every new channel in `NotificationChannels.contentChannels` (drives
the settings UI) with its Material Symbol + name, matching the existing
`Entry` pattern.
- Reactions/Reposts drop to `IMPORTANCE_LOW` (silent, no heads-up) — they're
ambient; zaps/DMs/replies stay DEFAULT/HIGH.
---
## 6. Capability upgrades (implementation notes)
- **Accent + icon:** add monochrome vector status-bar icons under
`amethyst/src/main/res/drawable/` (they must be white-on-transparent per
Android's small-icon rules — *not* the app logo). `builder.setColor(accent)`;
`setColorized(true)` only for ZAP + CALL. Store accent per category on the
enum.
- **MessagingStyle for replies/mentions/chat:** generalize the existing
`sendDMNotificationStyled` `MessagingStyle` construction into
`render()` for every conversational category. For replies, add the parent note
as an earlier `addMessage(...)` from the original author so the thread shows
context. Reuse the existing `Person` + avatar `IconCompat` code.
- **BigPictureStyle:** in `render()`, when `spec.bigPictureUrl != null`, load it
via the existing Coil `loadBitmap` helper and
`NotificationCompat.BigPictureStyle().bigPicture(bmp)` with the avatar as
large icon; collapse to `BigTextStyle` if the image fails to load.
- **Aggregation:** introduce a small `NotificationAggregator` keyed by
`groupKey` (per-target-note for reactions/zaps/reposts). On each new event it
reads `activeNotifications` for the group, recomputes the roll-up
(participants + count), and re-`notify()`s the same id with an updated
`InboxStyle`/title — mirroring `CardFeedContentState`'s per-target buckets.
Falls back to a single notification below the threshold. Keeps the existing
`sendGroupSummary` behavior for cross-target bundling.
- **Circular avatars:** add a `circleCrop(bitmap)` step in `loadBitmap` (reuse
the `Canvas` approach already in `overlayBadge`). Kind-glyph overlay reuses
`overlayBadge`.
- **Conversations/Bubbles:** publish `ShortcutInfoCompat` (long-lived, with the
partner `Person`) when a DM/reply notification posts; set `shortcutId`,
`addPerson`, and `BubbleMetadata` pointing at a
`MainActivity`/`CallActivity`-style chat entry. Guard by API level.
- **Dedup / dismiss-on-read:** unchanged (`isDuplicate`,
`dismissNotificationForEvent` by `eventId.hashCode()`); aggregation reuses a
stable per-group id so updates replace rather than stack.
---
## 7. Icons & strings work (required side-tasks)
- **Icons (two kinds):**
1. **Status-bar small icons** are Android drawables (white glyphs) — new
vector XML in `res/drawable/`. *Not* Material Symbols font glyphs.
2. **Settings-list icons** in `NotificationChannels` use `MaterialSymbols`. If
any new `MaterialSymbol("\uXXXX")` codepoint is introduced (e.g. `Repeat`,
`Merge`, `AwardStar`, `Ballot`, `Image`) that isn't already referenced,
**regenerate the subset font**: `./tools/material-symbols-subset/subset.sh`
and commit `material_symbols_outlined.ttf` — otherwise it renders as tofu.
- **Strings:** new titles per kind (repost, media, badge, poll, highlight, git,
nutzap, onchain) as translatable resources; aggregate counts become
`<plurals>` (`"%1$s & %2$d others liked your post"`) with the CLDR-category
discipline from `res/CLAUDE.md` (Slavic/Baltic/Arabic `few`/`many`/`zero`).
Use the `pluralStringRes(ctx, …)` helper (non-Compose) in the service path.
---
## 8. Parity gaps to close (behavioral, not just visual)
For these to *render*, two lists must include the kind **and**
`NotificationSpecFactory` must handle it:
- `NotificationDispatcher.NOTIFICATION_KINDS` (Android gate) — add
`NutzapEvent`, `OnchainZapEvent`, `RepostEvent`, `GenericRepostEvent`,
`BadgeAwardEvent`, `LiveActivitiesChatMessageEvent`, NIP-C7 `ChatEvent`.
- `commons/.../NotificationKinds.SUBSCRIPTION_KINDS` (relay REQ list) — must
already carry them so they arrive; verify against
`NotificationKindsContractTest` and extend if needed.
- Keep the in-app `NotificationFeedFilter.NOTIFICATION_KINDS` and the push path
in lockstep — the contract test should assert push-rendered ⊆ in-app-shown.
Decision needed (flagged for the maintainer, §11): reposts/reactions are
*aggregate-only* by design — confirm we want them in the tray at all (many users
consider like/repost notifications noise; hence `IMPORTANCE_LOW` + off-by-default
mirroring the desktop `KindToggles` where reaction/repost default off).
---
## 9. Phased implementation
- **Phase 0 — Refactor to spec+builder (no visual change).** Introduce
`NotificationCategory`, `NotificationSpec`, `NotificationSpecFactory`,
`NotificationUtils.render(spec)`; port the six existing kinds onto it. Prove
parity with existing behavior via unit tests. *No user-visible diff.*
- **Phase 1 — Color & icons.** Accent + status-bar icon per category, circular
avatars, colorized zap card. Highest visual ROI, lowest risk.
- **Phase 2 — Styles.** MessagingStyle for replies/mentions/chat;
BigPictureStyle for media & badges.
- **Phase 3 — Aggregation.** Roll-up for reactions/reposts/zaps via
`NotificationAggregator`; new Reposts channel; LOW importance for ambient
kinds.
- **Phase 4 — Parity kinds.** Route nutzap, onchain zap, repost, badge, live
chat; grow the dispatcher/subscription kind lists; strings + plurals.
- **Phase 5 — Conversations/Bubbles/People + richer actions.** Shortcuts,
bubbles, Zap-back / Like-back / Mute-thread actions.
- **Phase 6 — Channel groups + settings polish.** `NotificationChannelGroup`s;
update `NotificationSettingsScreen`.
Each phase is independently shippable and revertible.
---
## 10. Testing
- **JVM unit tests** for `NotificationSpecFactory.specFor(...)` — table-driven,
one fixture event per kind asserting category/title/body/style/actions. This
is the regression net that lets the six-kind refactor land safely.
- **Contract test** extending `NotificationKindsContractTest`: every kind the
factory produces a spec for is in `NOTIFICATION_KINDS` and `SUBSCRIPTION_KINDS`.
- **Aggregation tests:** feed 1 → 5 reactions on one note, assert a single
updating notification with correct count/participants (mirror
`CardFeedContentState` test fixtures if they exist).
- **Manual matrix** on Android 12 / 14 / 15 (and one heavily-skinned OEM):
screenshot each category light+dark, verify color/icon/heads-up/lockscreen
(public version) and Conversation-section placement for DMs.
- **`amy`**: no CLI surface for tray rendering, but the spec factory is pure
JVM and can be exercised headless.
---
## 11. Risks & decisions for the maintainer
- **Notification volume.** Aggregation + LOW-importance reactions/reposts are
the mitigation; still, **confirm** reactions/reposts belong in the tray
(default-off recommended, matching desktop).
- **Small-icon assets.** Must be true monochrome white glyphs; the current
colored amethyst logo is *not* a valid small icon and is the reason the shade
looks flat today. Needs new drawables.
- **Channel proliferation.** Ten channels risks a cluttered settings page —
hence `NotificationChannelGroup`s; alternatively keep the six and only vary
color/icon/style within them (color/style don't require a new channel; only
independent user importance/sound control does). *Recommend: add only
Reposts + Media + Badges channels, fold the rest.*
- **Font subset drift.** Any new Material Symbol must trigger `subset.sh` or it
renders as tofu — easy to forget; call it out in the PR checklist.
- **Marmot/encrypted content.** MessagingStyle/Bubbles must not leak decrypted
DM/group text to the lockscreen public version — the existing
`setPublicVersion` "New notification arrived" placeholder must be preserved for
all private categories.
---
## Survey (existing components reused vs. new)
- **Reused as-is:** the entire transport + gate layer (`PushWrapDecryptor`,
`NotificationDispatcher` gates, `EventNotificationConsumer` mute/age/self/
known-room checks), Coil `loadBitmap`, `overlayBadge`, `isDuplicate`,
`dismissNotificationForEvent`, `NotificationReplyReceiver` (extended for
new actions), `NotificationChannels`/`NotificationSettingsScreen` structure.
- **Extracted/generalized:** the `MessagingStyle` construction from
`sendDMNotificationStyled` becomes the shared conversational renderer; the six
`send*Notification` builders collapse into `render(spec)`.
- **Genuinely new (Android-only, correct module):** `NotificationCategory`,
`NotificationSpec`, `NotificationSpecFactory`, `NotificationAggregator`,
status-bar drawables, channel groups, shortcut/bubble publishing.
- **Aggregation logic** is a port of `CardFeedContentState.convertToCard`
bucketing — same rules, tray output instead of `Card`s. Consider extracting
the bucketing to `commons` so feed and tray share one implementation.
+1
View File
@@ -10,6 +10,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
## Archived (shipped)
@@ -97,8 +97,8 @@ class PushMessageReceiver : MessagingReceiver() {
Log.d(TAG) { "Building okHttpClient, useTor: ${Amethyst.instance.torManager.isSocksReady()}" }
Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady())
}
NotificationUtils.getOrCreateZapChannel(appContext)
NotificationUtils.getOrCreateDMChannel(appContext)
NotificationCategory.ZAP.ensureChannel(appContext)
NotificationCategory.DIRECT_MESSAGE.ensureChannel(appContext)
}
// } else {
Log.d(TAG) { "Same endpoint provided:- ${endpoint.url} for Instance: $instance $sanitizedEndpoint" }
@@ -137,7 +137,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDe
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
@@ -577,12 +576,6 @@ class AppModules(
)
}
// Application-wide ots verification cache
val otsVerifCache by lazy {
Log.d("AppModules", "OtsCache Init")
VerificationStateCache(otsResolverBuilder)
}
val torEvaluatorFlow =
TorRelayState(
okHttpClients,
@@ -747,7 +740,7 @@ class AppModules(
// Tries to verify new OTS events when they arrive.
val otsEventVerifier =
IncomingOtsEventVerifier(
otsVerifCache = { otsVerifCache },
otsResolverBuilder = otsResolverBuilder,
cache = cache,
scope = applicationIOScope,
)
@@ -1034,6 +1027,8 @@ class AppModules(
)
}
// androidx.security.crypto's EncryptedSharedPreferences is deprecated with no drop-in successor.
@Suppress("DEPRECATION")
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub)
fun initiate(appContext: Context) {
@@ -1092,9 +1087,11 @@ class AppModules(
uiState
}
// LRUCache should not be instanciated in the Main thread due to blocking
// LRUCache should not be instanciated in the Main thread due to blocking.
// trimToSize is a no-op on the empty cache; it just forces the object's
// (blocking) initialization to happen off the main thread.
applicationIOScope.launch {
CachedRobohash
CachedRobohash.trimToSize(100)
resourceCacheInit()
}
@@ -31,6 +31,9 @@ class EncryptedStorage {
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
// androidx.security.crypto is deprecated with no drop-in successor; migrating the
// on-disk key store is a separate, security-sensitive effort.
@Suppress("DEPRECATION")
fun preferences(
applicationContext: Context,
npub: String? = null,
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
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.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
@@ -156,6 +157,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
@@ -364,6 +366,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.utils.DualCase
@@ -2289,6 +2292,7 @@ class Account(
text: String,
replyTo: Note? = null,
replyMode: ReplyMode = ReplyMode.INLINE,
imetas: List<IMetaTag> = emptyList(),
): Boolean {
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
@@ -2302,8 +2306,11 @@ class Account(
val parent = replyTo?.event
val wrap =
when {
// A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9
// message quoting the parent; a fresh post is a plain kind-9 message.
// A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when
// the user attached media); an inline reply is a kind-9 message quoting the parent; a
// fresh post is a plain kind-9 message.
parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() ->
ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags)
parent != null && replyMode == ReplyMode.MINICHAT ->
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
parent != null ->
@@ -2350,6 +2357,7 @@ class Account(
suspend fun sendMinichatReply(
rootNote: Note,
text: String,
imetas: List<IMetaTag> = emptyList(),
): Boolean {
if (!isWriteable()) return false
val gatherers = rootNote.inGatherers
@@ -2361,16 +2369,24 @@ class Account(
text,
rootNote,
ReplyMode.MINICHAT,
imetas,
)
}
// Public chats: a plain public kind-1111 comment rooted at the message. NIP-29 groups
// additionally carry the `h` tag and go only to the host relay.
// additionally carry the `h` tag and go only to the host relay. Attached media rides as
// NIP-92 `imeta` tags, with each URL appended to the content so any client renders it.
val rootEvent = rootNote.event ?: return false
val finalText = appendMediaUrls(text, imetas)
gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat ->
val relays = chat.relays()
val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull())))
val signed =
signer.sign(
CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, relays.firstOrNull())) {
imetas(imetas)
},
)
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, relays.ifEmpty { outboxRelays.flow.value })
return true
@@ -2381,10 +2397,11 @@ class Account(
val signed =
if (BuzzRelayDialect.isBuzz(hostRelay)) {
// Buzz rejects kind-1111, so its minichat threads with a 40002 marked at the message's
// root (never `broadcast` — a minichat reply always lives in the thread).
// root (never `broadcast` — a minichat reply always lives in the thread). Attached
// media is carried as URLs appended to the content (no `imeta` on the stream event).
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
signer.sign(
StreamMessageV2Event.build(group.groupId.id, text) {
StreamMessageV2Event.build(group.groupId.id, finalText) {
buzzThread(root, rootEvent.id)
rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) }
previous(group.previousEventRefs(pubKey))
@@ -2392,9 +2409,10 @@ class Account(
)
} else {
signer.sign(
CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) {
CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, hostRelay)) {
hTag(group.groupId.id)
previous(group.previousEventRefs(pubKey))
imetas(imetas)
},
)
}
@@ -2406,6 +2424,21 @@ class Account(
return false
}
/**
* Appends each attachment URL not already present in [text] to the message content (newline
* separated), so a plaintext media link renders inline in any client — mirroring
* [com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat.imageMessage]. Returns [text]
* unchanged when there are no attachments.
*/
private fun appendMediaUrls(
text: String,
imetas: List<IMetaTag>,
): String {
if (imetas.isEmpty()) return text
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
}
/**
* React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors
* [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's
@@ -2434,6 +2467,37 @@ class Account(
return true
}
/**
* Edit my own Concord channel message [note] to [newText]. Mirrors
* [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the
* message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays
* inside the encrypted channel (a public edit would e-tag the private rumor id onto
* public relays). The receiving side overlays the newest edit onto the target message;
* only the *original author's* edits are applied, so we gate to my own kind-9 messages.
* Returns false if [note] isn't an editable Concord message I authored.
*/
suspend fun editConcordChannelMessage(
note: Note,
newText: String,
): Boolean {
if (!isWriteable()) return false
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false
val target = note.event ?: return false
// Edits only apply to plain kind-9 messages, and only the author may edit their own.
if (target !is ChatEvent || target.pubKey != signer.pubKey) return false
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message.
val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
/**
* Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at
* most every few seconds while composing. Not folded locally (we never show our own typing);
@@ -3010,6 +3074,33 @@ class Account(
newest?.let { cache.justConsumeMyOwnEvent(it) }
}
/**
* One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox
* fill in without the user opening each channel one by one. Per channel, a channel read before is
* caught up from its last-read time (accurate unread badge + the missed messages ready when it
* opens) while a channel never read pulls only its single newest wrap for a preview — see
* [ConcordSubscriptionPlanner.channelPreviewFilters].
*
* This is deliberately **not** a live subscription: every wrap the drain pulls flows through the
* global cache connector (`CacheClientConnector` → `LocalCache.justConsume` → `concordSessions.ingest`),
* so it lands in the channel's message store the previews/unread counts read — and the always-on
* plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward.
* So this only needs to run when a community's channels first fold (the account preload) or its
* screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay.
*/
suspend fun warmConcordChannelPreviews(entries: List<ConcordCommunityListEntry>) {
val filters =
entries.flatMap { entry ->
val state = concordSessions.sessionFor(entry.id)?.state?.value ?: return@flatMap emptyList()
ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { channelIdHex ->
loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex))
})
}
if (filters.isEmpty()) return
val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } }
client.fetchAll(filters = byRelay, timeoutMs = 20_000L)
}
// ── NIP-29 relay-group actions ───────────────────────────────────────────
// All group commands are published ONLY to the group's host relay, where
// relay29 authorizes them. The relay is the source of truth; the kind-10009
@@ -22,7 +22,6 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
@@ -117,7 +116,6 @@ import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
@@ -135,6 +133,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -198,14 +197,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.VerificationState
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
@@ -1383,16 +1379,6 @@ object LocalCache : ILocalCache, ICacheProvider {
(eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) }
}
is StreamMessageV2Event -> {
// Buzz threads 40002s with marked root/reply e-tags (thread_tags in
// buzz-sdk). Link both so inbound replies render their quote bubble —
// we emit these markers on send, so we must also read them.
listOfNotNull(
event.tags.buzzThreadRoot(),
event.tags.buzzThreadReply(),
).distinct().mapNotNull { checkGetOrCreateNote(it) }
}
else -> {
emptyList()
}
@@ -1484,12 +1470,17 @@ object LocalCache : ILocalCache, ICacheProvider {
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (version.event?.id == event.id) return false
if (version.event != null) return false
if (wasVerified || justVerify(event)) {
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.flowSet?.ots?.invalidateData()
version.loadEvent(event, author, emptyList())
// Anchor the attestation to the note it timestamps (like an edit to its message), so it
// survives exactly as long as that note and is dropped when the note is deleted or pruned.
// addTimestamp invalidates the target's `ots` flow so the OTS pill re-derives — the old
// code invalidated the attestation's OWN (observer-less) flow, so the target never updated.
event.digestEventId()?.let { targetId ->
getOrCreateNote(targetId).addTimestamp(version)
}
refreshNewNoteObservers(version)
@@ -2262,15 +2253,14 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean =
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
// earlier message's content, never a row of its own. Store it and record the
// overlay (keyed by the channel's UUID, so own sends with no provenance relay
// land too), but do NOT attach it to the timeline.
// earlier message's content, never a row of its own. Store it and anchor it to the
// message it edits (Note.edits) — like every other edit kind — so the overlay is held
// for as long as its message and never leaks into the timeline as a bubble.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.editedMessage() ?: return@also
val channelId = event.channel() ?: return@also
val editNote = getOrCreateNote(event.id)
if (editNote.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote)
getOrCreateNote(target).addEdit(editNote)
}
}
@@ -2851,12 +2841,48 @@ object LocalCache : ILocalCache, ICacheProvider {
if (wasVerified || justVerify(event)) {
note.loadEvent(event, author, emptyList())
// Anchor the modification to the note it edits, like every other edit kind — the read side
// (Note.textNoteModifications) then folds `edited.edits` instead of scanning the cache,
// and addEdit invalidates the note's edits flow so the UI re-derives.
event.editedNote()?.let {
checkGetOrCreateNote(it.eventId)?.let { editedNote ->
modificationCache.remove(editedNote.idHex)
// must update list of Notes to quickly update the user.
editedNote.flowSet?.edits?.invalidateData()
}
checkGetOrCreateNote(it.eventId)?.addEdit(note)
}
refreshNewNoteObservers(note)
return true
}
return false
}
fun consume(
event: ConcordChatEditEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
// A Concord edit rumor is unsigned (its sig is empty); the envelope open path already
// established authenticity, so we consume it as pre-verified like any other Concord rumor.
if (wasVerified || justVerify(event)) {
note.loadEvent(event, author, emptyList())
// Anchor the edit to the message it edits (like a reaction to its target), so it survives
// as long as that channel-retained message does. A Concord rumor is decrypted exactly once
// per session — the community session dedups re-delivered wraps — so an edit left orphaned
// in the soft cache could be GC'd and never re-downloaded. The bubble reads `note.edits`.
event.editedMessageId()?.let { targetId ->
getOrCreateNote(targetId).addEdit(note)
}
refreshNewNoteObservers(note)
@@ -3253,73 +3279,6 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getPeopleListNotesFor(user: User): List<AddressableNote> = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex)
suspend fun findEarliestOtsForNote(
note: Note,
otsVerifCacheBuilder: () -> VerificationStateCache,
): Long? {
checkNotInMainThread()
var minTime: Long? = null
val time = TimeUtils.now()
val candidates =
notes.mapNotNull { _, item ->
val noteEvent = item.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
val cachedTime = (otsVerifCacheBuilder().justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime
if (cachedTime != null) {
if (minTime == null || cachedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = cachedTime
}
null
} else {
// tries to verify again
noteEvent
}
} else {
null
}
}
candidates.forEach { noteEvent ->
(otsVerifCacheBuilder().cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime ->
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = stampedTime
}
}
}
return minTime
}
val modificationCache = LruCache<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? = modificationCache[note.idHex]
fun findLatestModificationForNote(note: Note): List<Note> {
checkNotInMainThread()
val noteAuthor = note.author ?: return emptyList()
modificationCache[note.idHex]?.let {
return it
}
val time = TimeUtils.now()
val newNotes =
notes
.filter { _, item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteAuthor == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
modificationCache.put(note.idHex, newNotes)
return newNotes
}
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" }
notes.cleanUp()
@@ -3410,13 +3369,6 @@ object LocalCache : ILocalCache, ICacheProvider {
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
}
// A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside
// `notes`, so the top-N reap never touches it. Drop overlay entries whose target
// message was just pruned, else they pin the edit note + author forever.
if (channel is RelayGroupChannel) {
BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys())
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
@@ -3643,6 +3595,17 @@ object LocalCache : ILocalCache, ICacheProvider {
getNoteIfExists(quotedId)?.removeBoost(note)
}
// Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo`
// back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag
// and drop it there, or a deleted edit would keep overlaying its message.
editedTargetIdOf(noteEvent)?.let { getNoteIfExists(it)?.removeEdit(note) }
// OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with
// no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there.
if (noteEvent is OtsEvent) {
noteEvent.digestEventId()?.let { getNoteIfExists(it)?.removeTimestamp(note) }
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
@@ -3681,6 +3644,15 @@ object LocalCache : ILocalCache, ICacheProvider {
refreshDeletedNoteObservers(note)
}
/** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */
private fun editedTargetIdOf(event: Event?): HexKey? =
when (event) {
is TextNoteModificationEvent -> event.editedNote()?.eventId
is ConcordChatEditEvent -> event.editedMessageId()
is StreamMessageEditEvent -> event.editedMessage()
else -> null
}
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
}
@@ -4998,6 +4970,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is ConcordChatEditEvent -> {
consume(event, relay, wasVerified)
}
is TorrentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -0,0 +1,69 @@
/*
* 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
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/*
* Per-kind resolution of a message's edit overlay from its own `Note.edits`. Every edit that
* targets a note is held there as a hard-referenced child (like a reaction), so these are cheap
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
* note rather than the cache.
*
* All three kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord)
* is trusted to enforce that, so a foreign-authored edit never rewrites your message.
*/
/**
* Every kind-1010 post modification of this note, oldest first (author-only, dropping NIP-40
* expired ones). A list because the post's EditState cycles through the original + each version.
*/
fun Note.textNoteModifications(): List<Note> {
val noteAuthor = author ?: return emptyList()
val now = TimeUtils.now()
return edits
.filter { item ->
val e = item.event
e is TextNoteModificationEvent && noteAuthor == item.author && !e.isExpirationBefore(now)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
}
/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */
fun Note.latestBuzzEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
// idHex tie-break so a same-second pair resolves identically on every client.
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
}
/** The kind-3302 Concord edit overlaying this message, or null — author-only, newest by CORD-02 §4 send time. */
fun Note.latestConcordEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent }
.maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex }))
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
class IncomingOtsEventVerifier(
private val otsVerifCache: () -> VerificationStateCache,
private val otsResolverBuilder: OtsResolverBuilder,
private val cache: LocalCache,
private val scope: CoroutineScope,
) {
@@ -49,11 +49,12 @@ class IncomingOtsEventVerifier(
null,
)
// Verifies each newly-arrived attestation once and memoizes the verdict on the note itself
// (Note.otsVerification), so the OTS pill can render off a cached result without re-hitting
// the blockchain. The verdict is evicted with the note — no separate cache to keep in sync.
suspend fun consume(note: Note) {
note.event?.let { event ->
if (event is OtsEvent) {
otsVerifCache().cacheVerify(event)
}
if (note.event is OtsEvent) {
note.cacheVerifyOts(otsResolverBuilder.build())
}
}
}
@@ -0,0 +1,111 @@
/*
* 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.nip03Timestamp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.VerificationState
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/*
* OTS (NIP-03) attestation resolution off a note's own [Note.timestamps] list. Every kind-1040
* attestation targeting a note is anchored there as a hard-referenced child (like a reaction), so
* finding a note's proofs is an in-memory fold — no LocalCache scan. Each attestation memoizes its
* blockchain verdict in [Note.otsVerification], which shares the attestation note's lifecycle: it is
* evicted when the target note is (a NIP-09 delete or a cache prune), so there is no separate,
* id-keyed verification cache to keep in sync. This is the read side that replaces the old
* VerificationStateCache + full-cache scan.
*/
/** The memoized OTS verdict for this attestation note, or null when it has never been verified. */
fun Note.justOtsVerification(): VerificationState? = otsVerification
/**
* Returns the OTS verdict for this attestation note, verifying against the blockchain only when no
* usable verdict exists yet (or a stale [VerificationState.NetworkError] is due for a retry). The
* result is stored on the note so later reads are free. Must run off the main thread — verification
* hits the network. No-op verdict ([VerificationState.Error]) when the note is not an OTS event.
*/
suspend fun Note.cacheVerifyOts(resolver: OtsResolver): VerificationState {
val event = event as? OtsEvent ?: return VerificationState.Error("Not an OTS event")
return when (val current = otsVerification) {
is VerificationState.Verified -> current
is VerificationState.Error -> current
is VerificationState.NetworkError ->
if (current.time < TimeUtils.fiveMinutesAgo()) verifyOts(event, resolver) else current
// null, or a leftover non-terminal state from an interrupted run: (re)verify.
else -> verifyOts(event, resolver)
}
}
/**
* Verifies the attestation and stores ONLY the terminal verdict. We deliberately never persist a
* `Verifying` sentinel: this runs inside a cancellable `LoadOts` LaunchedEffect, so if the coroutine
* were cancelled at the network suspension point after writing `Verifying` but before the verdict,
* that sentinel would stick on the (long-lived) note forever — there is no LRU eviction to recover
* it, unlike the old VerificationStateCache — and the OTS pill would silently vanish. Leaving the
* field untouched until a real verdict lands means a cancelled run simply retries on the next read;
* the cost is at worst two concurrent first-time verifications, which is harmless.
*/
private suspend fun Note.verifyOts(
event: OtsEvent,
resolver: OtsResolver,
): VerificationState = event.verifyState(resolver).also { otsVerification = it }
/**
* The earliest blockchain-verified time (unix seconds) among this note's non-expired OTS
* attestations, or null when none verify. Reads already-cached verdicts first — so a proof that was
* verified on arrival shows without waiting on the network — then verifies any still-unresolved
* proofs. Must run off the main thread.
*/
suspend fun Note.earliestOtsVerifiedTime(resolver: OtsResolver): Long? {
val now = TimeUtils.now()
var minTime: Long? = null
fun consider(time: Long) {
if (minTime.let { it == null || time < it }) minTime = time
}
val live =
timestamps.filter {
val e = it.event
e is OtsEvent && !e.isExpirationBefore(now)
}
val unresolved =
live.filter { proof ->
val verified = (proof.justOtsVerification() as? VerificationState.Verified)?.verifiedTime
if (verified != null) {
consider(verified)
false
} else {
true
}
}
unresolved.forEach { proof ->
(proof.cacheVerifyOts(resolver) as? VerificationState.Verified)?.verifiedTime?.let(::consider)
}
return minTime
}
@@ -0,0 +1,262 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
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.stringRes
/**
* Logical grouping of notification channels, shown as a section header in the
* Android system-settings page (API 26+). Lets the user silence a whole family
* (e.g. all Social notifications) with one switch, in addition to the per-kind
* channel switches.
*/
enum class NotifChannelGroup(
val id: String,
@param:StringRes val nameRes: Int,
) {
MESSAGES("com.vitorpamplona.amethyst.group.messages", R.string.app_notification_group_messages),
SOCIAL("com.vitorpamplona.amethyst.group.social", R.string.app_notification_group_social),
PAYMENTS("com.vitorpamplona.amethyst.group.payments", R.string.app_notification_group_payments),
CONTENT("com.vitorpamplona.amethyst.group.content", R.string.app_notification_group_content),
DEVELOPER("com.vitorpamplona.amethyst.group.developer", R.string.app_notification_group_developer),
GAMES("com.vitorpamplona.amethyst.group.games", R.string.app_notification_group_games),
}
/**
* The visual + behavioral identity of one notification kind. Each entry owns:
*
* - the [NotificationChannel] it posts on (importance, name, description) — the
* unit Android lets the user silence/customize. Existing channel ids are
* reused verbatim so we never orphan a user's per-channel settings.
* - the accent [color] (`setColor`) and monochrome status-bar [smallIcon] that
* make the kind recognizable in the shade before it's read.
* - the [group] it bundles under (`setGroup`) and the [summaryId] of that
* group's summary notification.
* - the [channelGroup] it sits inside in system settings.
* - the [settingsIcon] rendered next to it in the in-app settings screen.
*
* This replaces the six ad-hoc `getOrCreate*Channel` helpers with one
* table-driven definition every renderer reads from.
*/
enum class NotificationCategory(
@param:StringRes val channelIdRes: Int,
@param:StringRes val channelNameRes: Int,
@param:StringRes val channelDescriptionRes: Int,
@param:StringRes val summaryTextRes: Int,
val importance: Int,
val color: Int,
@param:DrawableRes val smallIcon: Int,
val settingsIcon: MaterialSymbol,
val channelGroup: NotifChannelGroup,
val group: String,
val summaryId: Int,
/** Full-surface color tint — reserved for the highest-signal kinds. */
val colorized: Boolean = false,
) {
DIRECT_MESSAGE(
channelIdRes = R.string.app_notification_dms_channel_id,
channelNameRes = R.string.app_notification_dms_channel_name,
channelDescriptionRes = R.string.app_notification_dms_channel_description,
summaryTextRes = R.string.app_notification_dms_summary,
importance = NotificationManager.IMPORTANCE_HIGH,
color = 0xFF2196F3.toInt(), // blue
smallIcon = R.drawable.ic_notif_message,
settingsIcon = MaterialSymbols.Mail,
channelGroup = NotifChannelGroup.MESSAGES,
group = "com.vitorpamplona.amethyst.DM_NOTIFICATION",
summaryId = 0x10000,
),
REPLY(
channelIdRes = R.string.app_notification_replies_channel_id,
channelNameRes = R.string.app_notification_replies_channel_name,
channelDescriptionRes = R.string.app_notification_replies_channel_description,
summaryTextRes = R.string.app_notification_replies_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF7C4DFF.toInt(), // deep purple
smallIcon = R.drawable.ic_notif_reply,
settingsIcon = MaterialSymbols.Chat,
channelGroup = NotifChannelGroup.SOCIAL,
// Replies group per-thread; renderers override group + summaryId. This
// base is only a fallback when no thread root is known.
group = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION",
summaryId = 0x50000,
),
MENTION(
channelIdRes = R.string.app_notification_mentions_channel_id,
channelNameRes = R.string.app_notification_mentions_channel_name,
channelDescriptionRes = R.string.app_notification_mentions_channel_description,
summaryTextRes = R.string.app_notification_mentions_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF9C27B0.toInt(), // purple
smallIcon = R.drawable.ic_notif_mention,
settingsIcon = MaterialSymbols.AlternateEmail,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION",
summaryId = 0x60000,
),
REACTION(
channelIdRes = R.string.app_notification_reactions_channel_id,
channelNameRes = R.string.app_notification_reactions_channel_name,
channelDescriptionRes = R.string.app_notification_reactions_channel_description,
summaryTextRes = R.string.app_notification_reactions_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFFE91E63.toInt(), // pink / heart
smallIcon = R.drawable.ic_notif_reaction,
settingsIcon = MaterialSymbols.Favorite,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION",
summaryId = 0x40000,
),
REPOST(
channelIdRes = R.string.app_notification_reposts_channel_id,
channelNameRes = R.string.app_notification_reposts_channel_name,
channelDescriptionRes = R.string.app_notification_reposts_channel_description,
summaryTextRes = R.string.app_notification_reposts_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFF4CAF50.toInt(), // green
smallIcon = R.drawable.ic_notif_repost,
settingsIcon = MaterialSymbols.Sync,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.REPOST_NOTIFICATION",
summaryId = 0x70000,
),
ZAP(
channelIdRes = R.string.app_notification_zaps_channel_id,
channelNameRes = R.string.app_notification_zaps_channel_name,
channelDescriptionRes = R.string.app_notification_zaps_channel_description,
summaryTextRes = R.string.app_notification_zaps_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFF7931A.toInt(), // bitcoin orange
smallIcon = R.drawable.ic_notif_zap,
settingsIcon = MaterialSymbols.Bolt,
channelGroup = NotifChannelGroup.PAYMENTS,
group = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION",
summaryId = 0x20000,
colorized = true,
),
MEDIA(
channelIdRes = R.string.app_notification_media_channel_id,
channelNameRes = R.string.app_notification_media_channel_name,
channelDescriptionRes = R.string.app_notification_media_channel_description,
summaryTextRes = R.string.app_notification_media_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF00BCD4.toInt(), // cyan
smallIcon = R.drawable.ic_notif_media,
settingsIcon = MaterialSymbols.Image,
channelGroup = NotifChannelGroup.CONTENT,
group = "com.vitorpamplona.amethyst.MEDIA_NOTIFICATION",
summaryId = 0x80000,
),
ARTICLE(
channelIdRes = R.string.app_notification_articles_channel_id,
channelNameRes = R.string.app_notification_articles_channel_name,
channelDescriptionRes = R.string.app_notification_articles_channel_description,
summaryTextRes = R.string.app_notification_articles_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF3F51B5.toInt(), // indigo
smallIcon = R.drawable.ic_notif_article,
settingsIcon = MaterialSymbols.Description,
channelGroup = NotifChannelGroup.CONTENT,
group = "com.vitorpamplona.amethyst.ARTICLE_NOTIFICATION",
summaryId = 0x90000,
),
CODE(
channelIdRes = R.string.app_notification_code_channel_id,
channelNameRes = R.string.app_notification_code_channel_name,
channelDescriptionRes = R.string.app_notification_code_channel_description,
summaryTextRes = R.string.app_notification_code_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF607D8B.toInt(), // slate
smallIcon = R.drawable.ic_notif_code,
settingsIcon = MaterialSymbols.Code,
channelGroup = NotifChannelGroup.DEVELOPER,
group = "com.vitorpamplona.amethyst.CODE_NOTIFICATION",
summaryId = 0xA0000,
),
BADGE(
channelIdRes = R.string.app_notification_badges_channel_id,
channelNameRes = R.string.app_notification_badges_channel_name,
channelDescriptionRes = R.string.app_notification_badges_channel_description,
summaryTextRes = R.string.app_notification_badges_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFFFC107.toInt(), // amber / gold
smallIcon = R.drawable.ic_notif_badge,
settingsIcon = MaterialSymbols.MilitaryTech,
channelGroup = NotifChannelGroup.SOCIAL,
group = "com.vitorpamplona.amethyst.BADGE_NOTIFICATION",
summaryId = 0xB0000,
),
CHESS(
channelIdRes = R.string.app_notification_chess_channel_id,
channelNameRes = R.string.app_notification_chess_channel_name,
channelDescriptionRes = R.string.app_notification_chess_channel_description,
summaryTextRes = R.string.app_notification_chess_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF795548.toInt(), // brown
smallIcon = R.drawable.ic_notif_chess,
settingsIcon = MaterialSymbols.ChessKnight,
channelGroup = NotifChannelGroup.GAMES,
group = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION",
summaryId = 0x30000,
),
;
fun channelId(context: Context): String = stringRes(context, channelIdRes)
@Volatile private var channelEnsured = false
/**
* Creates this category's channel group and channel exactly once per process.
* The `create*` calls are idempotent but each is a Binder IPC, so — like the
* original `getOrCreate*Channel` field-caching — we skip them after the first
* successful creation instead of paying two IPCs on every post and re-render.
* Returns the channel id to post on.
*/
fun ensureChannel(context: Context): String {
val id = channelId(context)
if (channelEnsured) return id
synchronized(this) {
if (!channelEnsured) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannelGroup(
NotificationChannelGroup(channelGroup.id, stringRes(context, channelGroup.nameRes)),
)
val channel =
NotificationChannel(id, stringRes(context, channelNameRes), importance).apply {
description = stringRes(context, channelDescriptionRes)
group = channelGroup.id
}
nm.createNotificationChannel(channel)
channelEnsured = true
}
}
return id
}
}
@@ -28,6 +28,7 @@ import androidx.core.app.NotificationManagerCompat
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.isDebug
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier
import com.vitorpamplona.amethyst.ui.stringRes
@@ -62,57 +63,36 @@ object NotificationChannels {
val ensure: (Context) -> Unit,
)
/** The per-kind content channels, derived from [NotificationCategory], in a
* sensible settings order, plus the two non-event channels (scheduled posts,
* calls) that don't map to a Nostr event kind. */
val contentChannels: List<Entry> =
listOf(
Entry(
nameRes = R.string.app_notification_dms_channel_name,
icon = MaterialSymbols.Mail,
channelId = { stringRes(it, R.string.app_notification_dms_channel_id) },
ensure = { NotificationUtils.getOrCreateDMChannel(it) },
),
Entry(
nameRes = R.string.app_notification_mentions_channel_name,
icon = MaterialSymbols.AlternateEmail,
channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) },
ensure = { NotificationUtils.getOrCreateMentionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_replies_channel_name,
icon = MaterialSymbols.Chat,
channelId = { stringRes(it, R.string.app_notification_replies_channel_id) },
ensure = { NotificationUtils.getOrCreateReplyChannel(it) },
),
Entry(
nameRes = R.string.app_notification_reactions_channel_name,
icon = MaterialSymbols.Favorite,
channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) },
ensure = { NotificationUtils.getOrCreateReactionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_zaps_channel_name,
icon = MaterialSymbols.Bolt,
channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) },
ensure = { NotificationUtils.getOrCreateZapChannel(it) },
),
Entry(
nameRes = R.string.app_notification_chess_channel_name,
icon = MaterialSymbols.ChessKnight,
channelId = { stringRes(it, R.string.app_notification_chess_channel_id) },
ensure = { NotificationUtils.getOrCreateChessChannel(it) },
),
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
ensure = { AndroidScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
),
)
NotificationCategory.entries
// Chess (NIP-64) is a debug-only feature for now — don't surface (or
// create) its channel in release settings.
.filter { it != NotificationCategory.CHESS || isDebug }
.map { category ->
Entry(
nameRes = category.channelNameRes,
icon = category.settingsIcon,
channelId = { category.channelId(it) },
ensure = { category.ensureChannel(it) },
)
} +
listOf(
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
ensure = { AndroidScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
),
)
fun statusOf(
context: Context,
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
/**
* Content-extraction helpers shared by the per-kind notification renderers:
* decrypting private payloads, pulling a clean one-line excerpt, and resolving
* the media URL to show as a notification's big picture.
*/
object NotificationContent {
/** First non-blank line of [content], trimmed to [max] chars, or "" if none. */
fun excerpt(
content: String?,
max: Int = 280,
): String =
content
?.split("\n")
?.firstOrNull { it.isNotBlank() }
?.take(max)
?: ""
/**
* The result of [resolveMentions]: the excerpt with every `nostr:npub` /
* `nostr:nprofile` token swapped for the cited user's `@DisplayName`, plus the
* [User]s that were cited so a renderer can add them to its enrichment window
* and re-render as their metadata loads.
*/
data class ResolvedText(
val text: String,
val citedUsers: List<User>,
)
/**
* Like [excerpt], but rewrites inline user mentions to readable names: each
* `nostr:npub1…` / `nostr:nprofile1…` (optionally `@`-prefixed) becomes
* `@<best display name>` for the cited user, and that user is returned in
* [ResolvedText.citedUsers]. Event/address references (`nevent`, `note`,
* `naddr`, …) are left untouched — they aren't people and have no name to show.
*
* Called from the build closure on every re-render, so as a cited user's
* kind:0 arrives the notification text updates in place from `@npub1abc…` to
* `@RealName`.
*/
fun resolveMentions(
content: String?,
max: Int = 280,
): ResolvedText {
val line =
content
?.split("\n")
?.firstOrNull { it.isNotBlank() }
?: return ResolvedText("", emptyList())
// Cheap opt-out: no mention tokens means no work and no allocations.
if (!line.contains("npub1", ignoreCase = true) && !line.contains("nprofile1", ignoreCase = true)) {
return ResolvedText(line.take(max), emptyList())
}
val cited = mutableListOf<User>()
val rewritten =
Nip19Parser.nip19regex.replace(line) { match ->
val type = match.groups[3]?.value ?: match.groups[5]?.value
val key = match.groups[4]?.value ?: match.groups[6]?.value
val trailing = match.groups[7]?.value ?: ""
val hex =
when (val entity = Nip19Parser.parseComponents(type ?: "", key, null)?.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
if (hex != null) {
val user = LocalCache.getOrCreateUser(hex)
cited.add(user)
"@${user.toBestDisplayName()}$trailing"
} else {
// nevent / note / naddr / parse failure — leave as-is.
match.value
}
}
return ResolvedText(rewritten.take(max), cited)
}
/**
* The result of [renderNoteText]: [ResolvedText] plus [imageUrl], the first
* inline image link found anywhere in the note. When present, a renderer shows
* it as the notification's big picture and the link is stripped from [text] —
* so a mention whose body is "look at this https://…/cat.jpg" shows the photo
* instead of the raw URL.
*/
data class RenderedText(
val text: String,
val citedUsers: List<User>,
val imageUrl: String?,
)
private val whitespace = Regex("\\s+")
private val trailingPunctuation = charArrayOf('.', ',', ')', '(', '!', '?', ';', ':', '"', '\'')
/**
* The first `http(s)` image URL in [content] (anywhere, not only the first
* line — images are usually on their own line), or null. Trailing sentence
* punctuation is trimmed so "…/cat.jpg." still resolves. Videos are ignored:
* Coil can't load them as a still, so their link stays in the text.
*/
fun firstImageUrl(content: String?): String? {
if (content == null) return null
content.split(whitespace).forEach { raw ->
if (!raw.startsWith("http://", ignoreCase = true) && !raw.startsWith("https://", ignoreCase = true)) {
return@forEach
}
val url = raw.trimEnd(*trailingPunctuation)
if (RichTextParser.isImageUrl(url)) return url
}
return null
}
/**
* [resolveMentions] plus inline-image extraction: resolves `@npub` mentions,
* pulls the first image link out as [RenderedText.imageUrl], and removes that
* link from the excerpt text (collapsing the whitespace it leaves behind).
*/
fun renderNoteText(
content: String?,
max: Int = 280,
): RenderedText {
val resolved = resolveMentions(content, max = Int.MAX_VALUE)
val imageUrl = firstImageUrl(content)
val text =
if (imageUrl != null) {
resolved.text
.replace(imageUrl, "")
.replace(whitespace, " ")
.trim()
.take(max)
} else {
resolved.text.take(max)
}
return RenderedText(text, resolved.citedUsers, imageUrl)
}
suspend fun decryptZapContentAuthor(
event: LnZapRequestEvent,
signer: NostrSigner,
): Event? =
if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) {
signer.decryptZapEvent(event)
} else {
event
}
suspend fun decryptContent(
note: Note,
signer: NostrSigner,
): String? =
when (val event = note.event) {
is PrivateDmEvent -> event.decryptContent(signer)
is LnZapRequestEvent -> decryptZapContentAuthor(event, signer)?.content
else -> event?.content
}
/**
* The primary image/thumbnail URL to render as a notification's big picture,
* or null if the event carries no displayable media. Pictures use their first
* imeta url; videos prefer the poster-frame `image`, falling back to the video
* url only when no poster is present.
*/
fun mediaImageUrl(event: Event?): String? =
when (event) {
is PictureEvent -> event.imetaTags().firstOrNull()?.url
is VideoEvent -> {
val meta = event.imetaTags().firstOrNull()
meta?.image?.firstOrNull() ?: meta?.url
}
else -> null
}
}
@@ -24,7 +24,9 @@ import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.renderers.BuzzDmNotification
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -34,6 +36,8 @@ import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -41,8 +45,12 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -102,12 +110,16 @@ class NotificationDispatcher(
// Direct-arrival
PrivateDmEvent.KIND,
LnZapEvent.KIND,
NutzapEvent.KIND,
OnchainZapEvent.KIND,
Bolt12ZapEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
BadgeAwardEvent.KIND,
TextNoteEvent.KIND,
CommentEvent.KIND,
// Public content kinds — routed to the Mentions channel when p-tagged.
// Public content kinds — routed to their channel when p-tagged.
PictureEvent.KIND,
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
@@ -117,12 +129,19 @@ class NotificationDispatcher(
PollEvent.KIND,
GitPatchEvent.KIND,
GitIssueEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
HighlightEvent.KIND,
LongTextNoteEvent.KIND,
WikiNoteEvent.KIND,
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
WakeUpEvent.KIND,
// Buzz DM messages in a `t=dm` relay-group channel (participant-
// routed; gated to Buzz DMs only in the predicate so ordinary
// kind-9 chats — Concord/NIP-C7 — don't leak into the tray).
StreamMessageV2Event.KIND,
ChatEvent.KIND,
// Unwrapped from GiftWrap → Seal
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
@@ -206,6 +225,25 @@ class NotificationDispatcher(
// tagsAnEventByUser's replyTo check would otherwise
// always miss for replies into addressable posts.
val note = LocalCache.getNoteIfExists(event) ?: return@predicate false
// Buzz DM messages (kind 40002 / kind 9 in a `t=dm`
// relay-group channel) are participant-routed, not
// p-tagged. Gate them EXCLUSIVELY on Buzz-DM
// membership so ordinary kind-9 chats (Concord /
// NIP-C7) don't fall through the generic gate below.
if (event is StreamMessageV2Event || event is ChatEvent) {
return@predicate pubkeys.any { pubkey ->
BuzzDmNotification.buzzDmChannelForMe(note, pubkey) != null
}
}
// Reactions/reposts are routed by tagsAnEventByUser
// (the reacted note's author == me), not by a `p`
// tag — so a Buzz-style bare `["e", id]` like, which
// carries no `p` tag, still notifies. tagsAnEventByUser
// below keeps it scoped to reactions on MY note.
val isReactionOrRepost =
event is ReactionEvent || event is RepostEvent || event is GenericRepostEvent
pubkeys.any { pubkey ->
// Public chat replies into my own messages often
// omit the `p` tag; relax the gate for them (the
@@ -213,6 +251,7 @@ class NotificationDispatcher(
// messages actually replying to me).
(
event.isTaggedUser(pubkey) ||
isReactionOrRepost ||
NotificationFeedFilter.isNotifiablePublicChatReply(note, pubkey)
) &&
NotificationFeedFilter.tagsAnEventByUser(note, pubkey)
@@ -0,0 +1,222 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import android.os.PowerManager
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
/**
* Makes a tray notification *observable*: it renders immediately from whatever
* is already in [com.vitorpamplona.amethyst.model.LocalCache], then — if the
* involved users' names/pictures or the involved notes' content/media haven't
* loaded yet — opens a bounded relay window, subscribes to those users and
* notes, and re-renders the same notification (replacing it in place) as the
* missing data arrives.
*
* This is the generalized form of the ad-hoc subscribe-and-wait that
* `notifyIncomingCall` and `wakeUpFor` do: on a cold push the process has no
* cached metadata, so without this a notification would show a raw pubkey and
* no avatar. With it, the notification fills in the moment the kind:0 (and the
* post itself, for media) lands.
*
* The re-render path relies on notifications being keyed by a stable id and on
* `setOnlyAlertOnce(true)` (see [PushNotifier]) so replacements update silently
* instead of re-buzzing.
*/
object NotificationEnricher {
private const val TAG = "NotificationEnricher"
private const val WINDOW_MS = 25_000L
/** Collapses the StateFlow initial-value burst and rapid relay arrivals into
* one re-render, instead of rebuilding (reloading avatars, re-posting) on
* every emission. */
private const val RENDER_DEBOUNCE_MS = 250L
/** Caps concurrent enrichment windows so a push burst can't hold N
* simultaneous relay windows + wakelocks + subscriptions. */
private const val MAX_CONCURRENT_WINDOWS = 4
private val windowLimiter = Semaphore(MAX_CONCURRENT_WINDOWS)
/**
* Posts [build] now, then observes [users] and [notes] for the enrichment
* window, re-running [build] whenever their metadata/content changes, until
* [isComplete] reports everything needed is present or the window elapses.
*
* Non-blocking: the observation runs detached on the app IO scope under its
* own wakelock, so the caller (the notification dispatcher) is never held up.
* When [isComplete] is already satisfied, no relay window is opened.
*/
fun enrichAndPost(
context: Context,
account: Account,
notificationId: String,
users: Collection<User>,
notes: Collection<Note>,
isComplete: () -> Boolean,
build: suspend () -> Unit,
) {
Amethyst.instance.applicationIOScope.launch {
withEnrichmentWakeLock(context) {
// 1. Immediate render from cache — under the wakelock so a
// cold-push post can't be lost to Doze before it reaches the
// tray (the parent dispatcher wakelock is already released).
runBuild(build)
// 2. If we already have everything, we're done — no relay window.
if (isComplete()) return@withEnrichmentWakeLock
// 3. Bound concurrent relay windows. When the cap is hit the
// cached render still stands; it just won't live-enrich.
if (!windowLimiter.tryAcquire()) return@withEnrichmentWakeLock
try {
observeUntilComplete(context, notificationId, account, users, notes, isComplete, build)
} finally {
windowLimiter.release()
}
}
}
}
@OptIn(FlowPreview::class)
private suspend fun observeUntilComplete(
context: Context,
notificationId: String,
account: Account,
users: Collection<User>,
notes: Collection<Note>,
isComplete: () -> Boolean,
build: suspend () -> Unit,
) {
val userSubs = users.map { UserFinderQueryState(it, account) }
val noteSubs = notes.map { EventFinderQueryState(it, account) }
val authSub = ScreenAuthAccount(account)
try {
Amethyst.instance.authCoordinator.subscribe(authSub)
userSubs.forEach {
Amethyst.instance.sources.userFinder
.subscribe(it)
}
noteSubs.forEach {
Amethyst.instance.sources.eventFinder
.subscribe(it)
}
coroutineScope {
// Keep the relay pool connected for the duration of the window.
val relayJob =
launch {
try {
withTimeout(WINDOW_MS) {
Amethyst.instance.relayProxyClientConnector.relayServices
.collect()
}
} catch (_: CancellationException) {
// window elapsed or observation finished first
}
}
// Re-render on metadata/content changes. Debounced so the
// StateFlow initial-value burst and rapid arrivals collapse into
// one rebuild. Stops as soon as everything needed is present, or
// the user dismissed the notification in-app.
val changes =
users.map { it.metadata().flow.map { } } +
notes.map {
it
.flow()
.metadata.stateFlow
.map { }
}
if (changes.isNotEmpty()) {
withTimeoutOrNull(WINDOW_MS) {
merge(*changes.toTypedArray())
.debounce(RENDER_DEBOUNCE_MS)
.onEach { runBuild(build) }
.takeWhile { !NotificationUtils.wasDismissed(notificationId) && !isComplete() }
.collect { }
}
}
relayJob.cancel()
}
} finally {
noteSubs.forEach {
Amethyst.instance.sources.eventFinder
.unsubscribe(it)
}
userSubs.forEach {
Amethyst.instance.sources.userFinder
.unsubscribe(it)
}
Amethyst.instance.authCoordinator.unsubscribe(authSub)
}
}
private suspend fun runBuild(build: suspend () -> Unit) {
try {
build()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Notification build failed", e)
}
}
private inline fun <T> withEnrichmentWakeLock(
context: Context,
block: () -> T,
): T {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakeLock =
powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"amethyst:notification_enrichment",
)
wakeLock.acquire(WINDOW_MS + 5_000L)
try {
return block()
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
/** Deep-link URIs consumed by `MainActivity.uriToRoute` when a notification is tapped. */
object NotificationRoutes {
private const val ACCOUNT = "?account="
private const val SCROLL_TO = "&scrollTo="
fun accountNpub(account: Account): String =
account.signer.pubKey
.hexToByteArray()
.toNpub()
/** Opens the note directly (used for replies, mentions, DMs, media, git). */
fun noteUri(
note: Note,
accountNpub: String,
): String = note.toNEvent() + ACCOUNT + accountNpub
/** Opens the Notifications tab, scrolled to [scrollToId] (used for zaps, reactions, chess). */
fun notificationsUri(
accountNpub: String,
scrollToId: String,
): String = "notifications$ACCOUNT$accountNpub$SCROLL_TO$scrollToId"
/** Opens a Marmot group chatroom (welcome + group message). */
fun marmotUri(
nostrGroupId: String,
accountNpub: String,
): String = "marmot:$nostrGroupId$ACCOUNT$accountNpub"
/**
* Opens a NIP-29 relay-group / Buzz chatroom. [channelNAddr] is the channel's
* kind-39000 naddr, which `MainActivity.uriToRoute` already routes to
* `Route.RelayGroup` via the standard nostr-entity path.
*/
fun relayGroupUri(
channelNAddr: String,
accountNpub: String,
): String = "$channelNAddr$ACCOUNT$accountNpub"
}
internal fun Context.notificationManager(): NotificationManager = ContextCompat.getSystemService(this, NotificationManager::class.java) as NotificationManager
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
/**
* Article & highlight notifications — long-form (kind 30023), wiki (30818), and
* NIP-84 highlights (9802) that mention or highlight your writing. Rendered as an
* indigo card. Highlights show the highlighted passage; long-form/wiki mentions
* show the excerpt. Author name + avatar enriched observably.
*/
object ArticleNotification {
suspend fun notify(
context: Context,
account: Account,
event: Event,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(note)) return
val author = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(note, accountNpub)
val isHighlight = event is HighlightEvent
val titleRes =
if (isHighlight) {
R.string.app_notification_articles_channel_message_highlight
} else {
R.string.app_notification_articles_channel_message
}
val bodySource = if (event is HighlightEvent) event.quote() else event.content
val rendered = NotificationContent.renderNoteText(bodySource)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + rendered.citedUsers,
notes = listOf(note),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
rendered.citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val body = NotificationContent.renderNoteText(bodySource)
nm.postStandard(
category = NotificationCategory.ARTICLE,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
bigPictureUrl = body.imageUrl,
)
}
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
/**
* Badge-award notifications — NIP-58 kind 8. Rendered as a gold card
* ("You earned a badge", awarded by X). Issuer name + avatar enriched observably.
*/
object BadgeNotification {
suspend fun notify(
context: Context,
account: Account,
event: BadgeAwardEvent,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(note)) return
val issuer = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.notificationsUri(accountNpub, event.id)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(issuer),
notes = listOf(note),
isComplete = { issuer.metadataOrNull()?.bestName() != null },
) {
nm.postStandard(
category = NotificationCategory.BADGE,
id = event.id,
messageTitle = stringRes(context, R.string.app_notification_badges_channel_message),
messageBody = stringRes(context, R.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()),
time = event.createdAt,
pictureUrl = issuer.profilePicture(),
uri = uri,
applicationContext = context,
)
}
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Buzz DM notifications — a message (NIP-29 relay-group `StreamMessageV2Event`
* kind 40002 or `ChatEvent` kind 9) posted into a Buzz `t=dm` channel whose 39000
* participant list includes me. These carry no `p` tag, so — like Marmot/Concord
* — being a participant of the DM channel is the relevance signal, resolved from
* the channel's already-loaded metadata.
*
* Rendered with MessagingStyle on the Private Messages channel; the channel name
* is the conversation title, the message shows "sender: text", and tapping opens
* the relay-group chatroom (via the channel's kind-39000 naddr). The sender's
* name + avatar are enriched observably.
*/
object BuzzDmNotification {
/**
* The Buzz DM channel [note] belongs to when it's a DM addressed to [me] —
* else null. Shared with the dispatcher predicate so push and the in-app feed
* agree on "this Buzz DM is for me". Returns null (no notification) when the
* channel's 39000 metadata isn't loaded yet, mirroring the feed.
*/
fun buzzDmChannelForMe(
note: Note,
me: HexKey,
): RelayGroupChannel? {
val channel = LocalCache.getRelayGroupChannelForContent(note) ?: return null
val metadata = channel.event ?: return null
return if (metadata.isBuzzDm() && metadata.buzzParticipants().contains(me)) channel else null
}
suspend fun notify(
context: Context,
account: Account,
event: Event,
) {
// Honor the "show messages in notifications" toggle, like the in-app feed.
if (!account.settings.showMessagesInNotifications.value) return
val note = LocalCache.getNoteIfExists(event.id) ?: return
val channel = buzzDmChannelForMe(note, account.signer.pubKey) ?: return
val sender = LocalCache.getOrCreateUser(event.pubKey)
val body = event.content.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.app_notification_new_message)
val accountNpub = NotificationRoutes.accountNpub(account)
// The channel's kind-39000 naddr routes straight to the chatroom via the
// existing naddr → Route.RelayGroup path (no message load needed on tap).
val uri =
channel.toNAddr()?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
?: NotificationRoutes.noteUri(note, accountNpub)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(sender),
notes = listOf(note),
isComplete = { sender.metadataOrNull()?.bestName() != null },
) {
nm.postConversation(
category = NotificationCategory.DIRECT_MESSAGE,
id = event.id,
senderName = channel.toBestDisplayName(),
pictureUrl = sender.profilePicture() ?: channel.profilePicture(),
messageBody = "${sender.toBestDisplayName()}: $body",
time = event.createdAt,
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = null,
)
}
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
/**
* Chess game notifications — NIP-64 challenge-accepted and move events. Rendered
* as a brown card ("Chess" title, "X accepted your challenge" / "X moved — your
* turn"). Opponent name + avatar enriched observably.
*/
object ChessNotification {
suspend fun notify(
context: Context,
account: Account,
event: BaseChessEvent,
@StringRes contentRes: Int,
) {
// NIP-64 chess is a debug-only feature for now; don't notify in release.
if (!isDebug) return
val author = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.notificationsUri(accountNpub, event.id)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author),
notes = emptyList(),
isComplete = { author.metadataOrNull()?.bestName() != null },
) {
nm.postStandard(
category = NotificationCategory.CHESS,
id = event.id,
messageTitle = stringRes(context, R.string.app_notification_chess_channel_name),
messageBody = stringRes(context, contentRes, author.toBestDisplayName()),
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
)
}
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
/**
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
* (1618) and PR updates (1619) on repos you maintain. Rendered as a slate card
* titled by the action ("X opened an issue" …) with the subject as the body.
* Author name + avatar enriched observably.
*/
object CodeNotification {
suspend fun notify(
context: Context,
account: Account,
event: GitIssueEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_issue, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPatchEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_patch, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPullRequestEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPullRequestUpdateEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
private suspend fun post(
context: Context,
account: Account,
id: String,
createdAt: Long,
authorPubkey: String,
titleRes: Int,
subject: String?,
) {
val note = LocalCache.getNoteIfExists(id) ?: return
if (!account.isAcceptable(note)) return
val author = LocalCache.getOrCreateUser(authorPubkey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(note, accountNpub)
val body = NotificationContent.excerpt(subject, 140)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = id,
users = listOf(author),
notes = listOf(note),
isComplete = { author.metadataOrNull()?.bestName() != null },
) {
nm.postStandard(
category = NotificationCategory.CODE,
id = id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = body,
time = createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
)
}
}
}
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
/**
* Direct-message notifications — NIP-17 chat (kind 14), NIP-17 encrypted files
* (kind 15), and legacy NIP-04 DMs (kind 4). Rendered with MessagingStyle so the
* shade shows the sender's avatar + name and the message threads under the
* Conversations section. NIP-17 messages carry an inline Reply action; NIP-04 is
* read-only (matching the historical behavior).
*
* The message body is resolved once up front; only the sender's name + avatar
* are enriched observably, so a cold-push notification fills in the sender's
* metadata as the kind:0 lands.
*/
object DirectMessageNotification {
suspend fun notify(
context: Context,
account: Account,
event: ChatMessageEvent,
) = notifyRoom(context, account, event.id, event.createdAt, event.chatroomKey(account.signer.pubKey), decrypt = false)
suspend fun notify(
context: Context,
account: Account,
event: ChatMessageEncryptedFileHeaderEvent,
) = notifyRoom(context, account, event.id, event.createdAt, event.chatroomKey(account.signer.pubKey), decrypt = false)
suspend fun notify(
context: Context,
account: Account,
event: PrivateDmEvent,
) {
if (account.signer.pubKey != event.verifiedRecipientPubKey()) return
notifyRoom(context, account, event.id, event.createdAt, event.chatroomKey(account.signer.pubKey), decrypt = true)
}
private suspend fun notifyRoom(
context: Context,
account: Account,
eventId: String,
createdAt: Long,
chatRoom: ChatroomKey,
decrypt: Boolean,
) {
val chatNote = LocalCache.getNoteIfExists(eventId) ?: return
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
val followingKeySet = account.followingKeySet()
val isKnownRoom =
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true ||
chatroomList.hasSentMessagesTo(chatRoom)
if (!isKnownRoom) return
val author = chatNote.author ?: return
// Decrypt (NIP-04) or read (NIP-17) the body once — never re-decrypt on
// each enrichment tick, which could hammer a remote signer.
val body =
if (decrypt) {
NotificationContent.decryptContent(chatNote, account.signer) ?: return
} else {
chatNote.event?.content ?: return
}
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(chatNote, accountNpub)
val replyAction =
if (decrypt) {
null // NIP-04 is read-only in the tray
} else {
ReplyAction.Dm(accountNpub = accountNpub, chatroomMembers = chatRoom.users.joinToString(","))
}
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = eventId,
users = listOf(author),
notes = listOf(chatNote),
isComplete = { author.metadataOrNull()?.bestName() != null },
) {
nm.postConversation(
category = NotificationCategory.DIRECT_MESSAGE,
id = eventId,
senderName = author.toBestDisplayName(),
pictureUrl = author.profilePicture(),
messageBody = body,
time = createdAt,
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = replyAction,
)
}
}
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Marmot / MLS group notifications — a kind:445 group message (rendered as a
* MessagingStyle chat with an encrypted inline reply) and a welcome invite
* ("you've been added to …"). These arrive without a `p` tag, so the dispatcher
* hands them here directly once the MLS layer has decrypted the inner event.
*/
object GroupMessageNotification {
suspend fun notifyGroupMessage(
context: Context,
account: Account,
innerEvent: ChatEvent,
nostrGroupId: String,
) {
if (!context.notificationManager().areNotificationsEnabled()) return
if (MainActivity.isResumed) return
if (innerEvent.createdAt < TimeUtils.fifteenMinutesAgo()) return
if (innerEvent.pubKey == account.signer.pubKey) return
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: DEFAULT_GROUP_NAME
val sender = LocalCache.getOrCreateUser(innerEvent.pubKey)
val fallbackBody = innerEvent.content.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.app_notification_new_message)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = innerEvent.id,
users = listOf(sender),
notes = emptyList(),
isComplete = { sender.metadataOrNull()?.bestName() != null },
) {
nm.postConversation(
category = NotificationCategory.DIRECT_MESSAGE,
id = innerEvent.id,
senderName = groupName,
pictureUrl = sender.profilePicture(),
messageBody = "${sender.toBestDisplayName()}: $fallbackBody",
time = innerEvent.createdAt,
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction =
ReplyAction.Marmot(
accountNpub = accountNpub,
nostrGroupId = nostrGroupId,
replyToInnerEventId = innerEvent.id,
replyToInnerAuthor = innerEvent.pubKey,
),
)
}
}
suspend fun notifyWelcome(
context: Context,
account: Account,
event: WelcomeEvent,
) {
if (!context.notificationManager().areNotificationsEnabled()) return
if (MainActivity.isResumed) return
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
if (event.pubKey == account.signer.pubKey) return
val nostrGroupId = event.nostrGroupId() ?: return
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PRIVATE_GROUP
val inviter = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(inviter),
notes = emptyList(),
isComplete = { inviter.metadataOrNull()?.bestName() != null },
) {
nm.postConversation(
category = NotificationCategory.DIRECT_MESSAGE,
id = event.id,
senderName = inviter.toBestDisplayName(),
pictureUrl = inviter.profilePicture(),
messageBody = stringRes(context, R.string.app_notification_added_to_group, groupName),
time = event.createdAt,
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = null,
)
}
}
private const val DEFAULT_GROUP_NAME = "Private group"
private const val DEFAULT_PRIVATE_GROUP = "a private group"
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip71Video.VideoEvent
/**
* Media notifications — a picture (kind 20) or video (kinds 21/22/34235/34236)
* that mentions you. Rendered with BigPictureStyle so the shade shows the actual
* image (video poster frame) inline. The author's name + avatar are enriched
* observably; the media URL comes straight off the (already present) event.
*/
object MediaNotification {
suspend fun notify(
context: Context,
account: Account,
event: Event,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(note)) return
val author = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(note, accountNpub)
val isVideo = event is VideoEvent
val bigPictureUrl = NotificationContent.mediaImageUrl(event)
val citedUsers = NotificationContent.resolveMentions(event.content, 140).citedUsers
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + citedUsers,
notes = listOf(note),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val user = author.toBestDisplayName()
val titleRes =
if (isVideo) {
R.string.app_notification_media_channel_message_video
} else {
R.string.app_notification_media_channel_message_photo
}
nm.postStandard(
category = NotificationCategory.MEDIA,
id = event.id,
messageTitle = stringRes(context, titleRes, user),
messageBody = NotificationContent.resolveMentions(event.content, 140).text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
bigPictureUrl = bigPictureUrl,
)
}
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* Text-mention notifications — someone mentioned, quoted, or cited you in a note
* (kind 1), or asked a poll that tags you. Rendered as an accented BigText card
* titled "X mentioned you" with the post excerpt. The author's name + avatar and
* the post body are enriched observably.
*
* Media (picture/video), articles/highlights, and git events are richer and live
* in their own renderers; this covers plain text mentions and polls.
*/
object MentionNotification {
suspend fun notify(
context: Context,
account: Account,
event: Event,
category: NotificationCategory = NotificationCategory.MENTION,
@StringRes titleRes: Int = R.string.app_notification_mentions_channel_message,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(note)) return
val author = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(note, accountNpub)
// Users cited inline in the text (nostr:npub/nprofile) are observed too, so
// their names fill in as their kind:0 metadata arrives. An inline image link
// is rendered as the big picture instead of shown as a raw URL.
val rendered = NotificationContent.renderNoteText(event.content)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + rendered.citedUsers,
notes = listOf(note),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
rendered.citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val body = NotificationContent.renderNoteText(event.content)
nm.postStandard(
category = category,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
bigPictureUrl = body.imageUrl,
)
}
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
/**
* Reaction (like) notifications — kind 7. Rendered as a heart-accented card
* titled with the reactor's chosen emoji + name; the reacted-post excerpt is the
* body. NIP-30 custom emoji, which can't render as text, are shown as a badge
* overlaid on the reactor's avatar. The reactor's name + avatar and the reacted
* post's content are enriched observably.
*/
object ReactionNotification {
private const val LIKE_EMOJI = "🤙" // 🤙
private const val DISLIKE_EMOJI = "👎" // 👎
suspend fun notify(
context: Context,
account: Account,
event: ReactionEvent,
) {
// NIP-25: the LAST `e` tag is the note actually reacted to.
val reactedPostId = event.originalPost().lastOrNull() ?: return
val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId)
if (reactedNote != null && !account.isAcceptable(reactedNote)) return
val author = LocalCache.getOrCreateUser(event.pubKey)
val reactionContent = event.content
val customEmojiUrl = CustomEmoji.createEmojiMap(event.tags)[reactionContent]
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.notificationsUri(accountNpub, event.id)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author),
notes = listOfNotNull(reactedNote),
isComplete = { author.metadataOrNull()?.bestName() != null },
) {
val user = author.toBestDisplayName()
val title =
if (customEmojiUrl != null) {
user
} else {
"${symbolFor(reactionContent)} $user"
}
val reactedContent = NotificationContent.excerpt(reactedNote?.event?.content, 140)
val body =
if (reactedContent.isNotBlank()) {
stringRes(context, R.string.app_notification_reactions_channel_message_for, reactedContent)
} else {
stringRes(context, R.string.app_notification_reactions_channel_message, user)
}
nm.postStandard(
category = NotificationCategory.REACTION,
id = event.id,
messageTitle = title,
messageBody = body,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
badgeUrl = customEmojiUrl,
)
}
}
private fun symbolFor(content: String): String =
when {
content == ReactionEvent.LIKE || content.isBlank() -> LIKE_EMOJI
content == ReactionEvent.DISLIKE -> DISLIKE_EMOJI
else -> content
}
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ParentMessage
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replyGroupKeyFor
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replySummaryIdFor
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* Reply notifications — someone replied to your note (NIP-10 kind 1, NIP-22
* comment kind 1111, or a NIP-28 public-chat reply into your message). Rendered
* with MessagingStyle: the parent (your) message is shown as prior context, the
* reply as the latest message, and an inline Reply action lets you answer from
* the shade. Grouped per-thread so a busy thread collapses into one bundle.
*
* The replier's name + avatar are enriched observably.
*/
object ReplyNotification {
suspend fun notify(
context: Context,
account: Account,
event: Event,
parentNote: Note?,
threadRootId: String,
) {
val replyNote = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(replyNote)) return
val author = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(replyNote, accountNpub)
val citedUsers = NotificationContent.resolveMentions(event.content).citedUsers
// The parent may be the account's own note (a direct reply) OR a third
// party's — e.g. someone replying to another reply in a thread the account
// started. Attribute it to whoever actually wrote it, and observe that
// author so their name/avatar fill in like the replier's do.
val parentContent = parentNote?.event?.content
val parentAuthor = parentNote?.author
val parentIsFromMe = parentAuthor?.pubkeyHex == account.signer.pubKey
val observedParentAuthor = parentAuthor?.takeUnless { parentIsFromMe }
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + citedUsers + listOfNotNull(observedParentAuthor),
notes = listOf(replyNote),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null } &&
(observedParentAuthor == null || observedParentAuthor.metadataOrNull()?.bestName() != null)
},
) {
val user = author.toBestDisplayName()
val replyExcerpt = NotificationContent.resolveMentions(event.content).text
val parentExcerpt = parentContent?.let { NotificationContent.resolveMentions(it, 140).text }?.takeIf { it.isNotBlank() }
val parent =
parentExcerpt?.let {
ParentMessage(
senderName =
if (parentIsFromMe || parentAuthor == null) {
stringRes(context, R.string.app_notification_me)
} else {
parentAuthor.toBestDisplayName()
},
body = it,
pictureUrl = if (parentIsFromMe) account.userProfile().profilePicture() else parentAuthor?.profilePicture(),
isFromMe = parentIsFromMe,
)
}
nm.postConversation(
category = NotificationCategory.REPLY,
id = event.id,
senderName = user,
pictureUrl = author.profilePicture(),
messageBody = replyExcerpt,
time = event.createdAt,
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
parent = parent,
publicInlineReply = InlineReplyTarget(accountNpub = accountNpub, targetEventId = event.id),
addMarkRead = false,
groupKey = replyGroupKeyFor(threadRootId),
summaryId = replySummaryIdFor(threadRootId),
)
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
/**
* Repost / boost notifications — NIP-18 kind 6 and kind 16. Rendered as a
* green-accented card: "X reposted your post" with the reposted excerpt. The
* booster's name + avatar and the boosted post's content are enriched observably.
*/
object RepostNotification {
suspend fun notify(
context: Context,
account: Account,
event: RepostEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, event.boostedEventId())
suspend fun notify(
context: Context,
account: Account,
event: GenericRepostEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, event.boostedEventId())
private suspend fun post(
context: Context,
account: Account,
id: String,
createdAt: Long,
boosterPubkey: String,
boostedEventId: String?,
) {
val boostedNote = boostedEventId?.let { LocalCache.checkGetOrCreateNote(it) }
if (boostedNote != null && !account.isAcceptable(boostedNote)) return
val booster = LocalCache.getOrCreateUser(boosterPubkey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.notificationsUri(accountNpub, id)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = id,
users = listOf(booster),
notes = listOfNotNull(boostedNote),
isComplete = { booster.metadataOrNull()?.bestName() != null },
) {
nm.postStandard(
category = NotificationCategory.REPOST,
id = id,
messageTitle = stringRes(context, R.string.app_notification_reposts_channel_message, booster.toBestDisplayName()),
messageBody = NotificationContent.excerpt(boostedNote?.event?.content, 140),
time = createdAt,
pictureUrl = booster.profilePicture(),
uri = uri,
applicationContext = context,
)
}
}
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import java.math.BigDecimal
/**
* Zap notifications — Lightning (NIP-57, kind 9735), Cashu nutzaps (NIP-61, kind
* 9321), and onchain zaps (kind 8333). All render on the gold Zaps channel with a
* bolt icon; the title leads with the amount, the body names the sender and the
* zapped-post excerpt. The sender's name + avatar are enriched observably; for
* private Lightning zaps the sender is decrypted once up front.
*/
object ZapNotification {
private val MIN_ZAP_AMOUNT = BigDecimal.TEN
suspend fun notify(
context: Context,
account: Account,
event: LnZapEvent,
) {
LocalCache.getNoteIfExists(event.id) ?: return
val zapRequestNote = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val zappedNote = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
if (!account.isAcceptable(zappedNote)) return
if ((event.amount ?: BigDecimal.ZERO) < MIN_ZAP_AMOUNT) return
val zapRequestEvent = zapRequestNote.event as? LnZapRequestEvent ?: return
// Resolve the (possibly private) zapper once — never re-decrypt per tick.
val decrypted = NotificationContent.decryptZapContentAuthor(zapRequestEvent, account.signer) ?: return
val sender = LocalCache.getOrCreateUser(decrypted.pubKey)
val comment = decrypted.content.ifBlank { null }
val amount = showAmount(event.amount)
post(
context = context,
account = account,
id = event.id,
createdAt = event.createdAt,
sender = sender,
zappedNote = zappedNote,
title = { _ -> zapTitle(context, amount, comment) },
body = { user, excerpt -> fromLine(context, R.string.app_notification_zaps_channel_message_from, user, excerpt) },
)
}
suspend fun notify(
context: Context,
account: Account,
event: NutzapEvent,
) {
val zappedNote = event.linkedEventIds().lastOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
if (zappedNote != null && !account.isAcceptable(zappedNote)) return
val sender = LocalCache.getOrCreateUser(event.pubKey)
post(
context = context,
account = account,
id = event.id,
createdAt = event.createdAt,
sender = sender,
zappedNote = zappedNote,
title = { user -> stringRes(context, R.string.app_notification_nutzap_channel_message_from, user) },
body = { user, excerpt -> excerpt.ifBlank { user } },
)
}
suspend fun notify(
context: Context,
account: Account,
event: OnchainZapEvent,
) {
val zappedNote = event.zappedEvent()?.let { LocalCache.checkGetOrCreateNote(it) }
if (zappedNote != null && !account.isAcceptable(zappedNote)) return
val sender = LocalCache.getOrCreateUser(event.pubKey)
val sats = event.claimedAmountInSats()
post(
context = context,
account = account,
id = event.id,
createdAt = event.createdAt,
sender = sender,
zappedNote = zappedNote,
title = { user ->
if (sats != null) {
stringRes(context, R.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal()))
} else {
stringRes(context, R.string.app_notification_onchain_channel_message_from, user)
}
},
body = { user, excerpt -> fromLine(context, R.string.app_notification_onchain_channel_message_from, user, excerpt) },
)
}
private suspend fun post(
context: Context,
account: Account,
id: String,
createdAt: Long,
sender: User,
zappedNote: Note?,
title: (String) -> String,
body: (String, String) -> String,
) {
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.notificationsUri(accountNpub, id)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = id,
users = listOf(sender),
notes = listOfNotNull(zappedNote),
isComplete = { sender.metadataOrNull()?.bestName() != null },
) {
val user = sender.toBestDisplayName()
val excerpt =
zappedNote?.let { NotificationContent.excerpt(NotificationContent.decryptContent(it, account.signer), 140) } ?: ""
nm.postStandard(
category = NotificationCategory.ZAP,
id = id,
messageTitle = title(user),
messageBody = body(user, excerpt),
time = createdAt,
pictureUrl = sender.profilePicture(),
uri = uri,
applicationContext = context,
)
}
}
private fun zapTitle(
context: Context,
amount: String,
comment: String?,
): String {
val base = stringRes(context, R.string.app_notification_zaps_channel_message, amount)
return if (comment != null) "$base ($comment)" else base
}
private fun fromLine(
context: Context,
fromRes: Int,
user: String,
excerpt: String,
): String {
var content = stringRes(context, fromRes, user)
if (excerpt.isNotBlank()) {
content += " " + stringRes(context, R.string.app_notification_zaps_channel_message_for, excerpt)
}
return content
}
}
@@ -50,6 +50,7 @@ class ExoPlayerBuilder(
val renderersFactory =
object : DefaultRenderersFactory(context) {
@Suppress("DEPRECATION") // media3 DefaultAudioSink.Builder.setEnableAudioTrackPlaybackParams is deprecated.
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
@@ -25,6 +25,7 @@ import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -132,17 +133,21 @@ class ExoPlayerPool(
Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(warm)
warm.release()
liveDecoders.decrementAndGet()
releaseDecoder()
} else {
Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" }
// Already counted against the decoder budget for as long as it sat warm.
Log.d(PLAYBACK_DIAG_TAG) { "DECODERS warm-hit -> ${liveDecoders.get()} / budget $poolSize (unchanged)" }
return warm
}
}
}
ensureDecoderHeadroom()
liveDecoders.incrementAndGet()
return coldPool.poll() ?: builder.build(context)
// Count only once the player exists — incrementing first leaves a phantom decoder on the
// books if builder.build() throws.
val player = coldPool.poll() ?: builder.build(context)
acquireDecoder()
return player
}
/**
@@ -165,6 +170,22 @@ class ExoPlayerPool(
}
}
// acquireDecoder/releaseDecoder are the only two places the shared counter moves, so the
// DECODERS trace always logs the value the mutation itself produced (a re-read could show a
// sibling pool's concurrent move instead).
private fun acquireDecoder() {
val now = liveDecoders.incrementAndGet()
Log.d(PLAYBACK_DIAG_TAG) { "DECODERS acquire -> $now / budget $poolSize" }
}
// Floored at zero. An under-count is the harmful direction: it lets ensureDecoderHeadroom
// hand out more concurrent decoders than the device grants, which surfaces as MediaCodec
// NO_MEMORY and "can't play this video". An over-count only costs the warm cache.
private fun releaseDecoder() {
val now = liveDecoders.updateAndGet { (it - 1).coerceAtLeast(0) }
Log.d(PLAYBACK_DIAG_TAG) { "DECODERS release -> $now / budget $poolSize" }
}
private fun evictOldestWarm(): Boolean {
val oldest = synchronized(warmPoolLock) { warmPool.removeFirstOrNull() } ?: return false
Log.d("PlaybackService") { "ExoPlayerPool decoder-budget evict: ${oldest.mediaId}" }
@@ -193,6 +214,12 @@ class ExoPlayerPool(
null
}
/**
* Queues the return on this pool's single main-thread scope. Contract: returns queued before
* [destroy] is called run before destroy's teardown — both serialize on the same scope and
* [mutex] — so a caller may tear its sessions down synchronously and then destroy the pool
* (see MediaSessionPool.destroy).
*/
fun releasePlayerAsync(player: ExoPlayer) {
scope.launch {
releasePlayer(player)
@@ -213,7 +240,7 @@ class ExoPlayerPool(
Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(player)
player.release()
liveDecoders.decrementAndGet()
releaseDecoder()
return@withLock
}
@@ -261,7 +288,7 @@ class ExoPlayerPool(
// stop() tears the renderers down, which is what actually hands the MediaCodec instance
// back to the system — so this is the point where the player stops costing budget.
player.stop()
liveDecoders.decrementAndGet()
releaseDecoder()
player.clearVideoSurface()
player.clearMediaItems()
@@ -307,7 +334,6 @@ class ExoPlayerPool(
}
fun destroy() {
livePools.remove(this)
scope
.launch {
mutex.withLock {
@@ -320,13 +346,15 @@ class ExoPlayerPool(
warmSnapshot.forEach {
PcmTapRegistry.unregisterPlayer(it.player)
it.player.release()
liveDecoders.decrementAndGet()
releaseDecoder()
}
coldPool.forEach {
PcmTapRegistry.unregisterPlayer(it)
it.release()
}
coldPool.clear()
poolFinishedTeardown(this@ExoPlayerPool)
}
}.invokeOnCompletion {
scope.cancel()
@@ -347,5 +375,20 @@ class ExoPlayerPool(
// Every pool that hasn't been destroy()'d, so a pool starved of headroom can reclaim a
// warm player from a sibling instead of overshooting the shared ceiling.
private val livePools = ConcurrentLinkedQueue<ExoPlayerPool>()
// A pool calls this only after decrementing its own players, so livePools.isEmpty()
// means EVERY pool has finished tearing down — deregistering earlier would let the first
// pool out see a sibling's still-counted decoders as drift. The last pool out resets the
// shared counter; a non-zero value at that point is a genuine accounting leak (a player
// acquired and never returned), worth seeing.
private fun poolFinishedTeardown(pool: ExoPlayerPool) {
livePools.remove(pool)
if (livePools.isEmpty()) {
val stranded = liveDecoders.getAndSet(0)
if (stranded != 0) {
Log.w(PLAYBACK_DIAG_TAG) { "decoder accounting drift at teardown: $stranded" }
}
}
}
}
}
@@ -25,7 +25,6 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.LruCache
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
@@ -46,12 +45,17 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
class SessionListener(
val session: MediaSession,
val playerListener: Player.Listener,
) {
// Set once, by the retire funnel, making retireSession idempotent no matter how many
// paths reach the same entry.
val retired = AtomicBoolean(false)
fun removeListeners() {
session.player.removeListener(playerListener)
}
@@ -123,29 +127,42 @@ class MediaSessionPool(
return if (resolved > 0) resolved else (DEFAULT_METADATA_BITMAP_DP * resources.displayMetrics.density).toInt()
}
// protects from LruCache killing playing sessions
private val playingMap = mutableMapOf<String, SessionListener>()
private val cache =
object : LruCache<String, SessionListener>(maxSessions.coerceIn(1, MAX_CACHED_SESSIONS)) {
override fun entryRemoved(
evicted: Boolean,
key: String?,
oldValue: SessionListener?,
newValue: SessionListener?,
) {
super.entryRemoved(evicted, key, oldValue, newValue)
if (!playingMap.contains(key)) {
oldValue?.let { pair ->
pair.removeListeners()
exoPlayerPool.releasePlayerAsync(pair.session.player as ExoPlayer)
pair.session.release()
}
}
}
private val registry =
SessionRegistry<SessionListener>(maxSessions.coerceIn(1, MAX_CACHED_SESSIONS)) { entry ->
retireSession(entry)
}
/**
* The one place a session's player goes back to the pool — the registry's onDropped and
* newSession's failure unwind both land here.
*
* [SessionRegistry] removes an entry from both of its maps before signaling, so its paths
* already deliver each entry at most once; the [SessionListener.retired] CAS keeps the funnel
* idempotent regardless, because a double retire would double-return the player — an
* under-count, the harmful direction (toward MediaCodec NO_MEMORY).
*
* Both orderings below are load-bearing, not tidiness:
* - removeListeners() before release(), because releasing a session can fire
* onIsPlayingChanged, which would re-enter setPlaying while we are still inside
* the registry's entryRemoved callback.
* - releasePlayerAsync() before release(), so a throw from session teardown cannot
* strand the player. releasePlayerAsync only queues the return, so the listener and
* session are detached before the queued release ever runs.
*/
private fun retireSession(entry: SessionListener) {
if (!entry.retired.compareAndSet(false, true)) return
entry.removeListeners()
exoPlayerPool.releasePlayerAsync(entry.session.player as ExoPlayer)
entry.session.release()
}
internal fun setPlaying(
id: String,
isPlaying: Boolean,
) {
registry.setPlaying(id, isPlaying)
}
@OptIn(UnstableApi::class)
fun newSession(
id: String,
@@ -156,31 +173,55 @@ class MediaSessionPool(
// is reused so the populated buffer survives. Null falls back to a cold acquire.
preferredMediaId: String?,
): MediaSession {
val mediaSession =
MediaSession
.Builder(context, exoPlayerPool.acquirePlayer(context, preferredMediaId))
.apply {
setBitmapLoader(sharedBitmapLoader)
setId(id)
setCallback(globalCallback)
}.build()
val player = exoPlayerPool.acquirePlayer(context, preferredMediaId)
val listener = MediaSessionExoPlayerConnector(mediaSession, this)
// newSession owns the player until registry.register() hands ownership over. Anything
// that throws in between — reset(), or the PendingIntent in bindSessionActivity() —
// would otherwise strand a counted player with no owner. The unwind routes through
// retireSession so there is exactly one teardown sequence; removing a never-added
// listener there is a no-op.
var entry: SessionListener? = null
var handedOff = false
mediaSession.player.addListener(listener)
try {
val mediaSession =
MediaSession
.Builder(context, player)
.apply {
setBitmapLoader(sharedBitmapLoader)
setId(id)
setCallback(globalCallback)
}.build()
reset(mediaSession, keepPlaying)
val listener = MediaSessionExoPlayerConnector(mediaSession, this)
entry = SessionListener(mediaSession, listener)
// Warm-pool fast path acquires a player that still holds its MediaItem, so the
// client side skips setMediaItem (see GetVideoController) and onAddMediaItems
// never fires for this fresh session — leaving the notification's tap target
// unset. Re-bind it from the player's current item so tapping the playback
// notification opens the originating nostr URI.
bindSessionActivity(mediaSession, mediaSession.player.currentMediaItem)
mediaSession.player.addListener(listener)
cache.put(mediaSession.id, SessionListener(mediaSession, listener))
reset(mediaSession, keepPlaying)
return mediaSession
// Warm-pool fast path acquires a player that still holds its MediaItem, so the
// client side skips setMediaItem (see GetVideoController) and onAddMediaItems
// never fires for this fresh session — leaving the notification's tap target
// unset. Re-bind it from the player's current item so tapping the playback
// notification opens the originating nostr URI.
bindSessionActivity(mediaSession, mediaSession.player.currentMediaItem)
// Past this point the registry owns the session. register() inserts the entry before
// it can fire an eviction's onDropped, so even if register() itself throws (a
// displaced entry's retire failing), the new entry is already registered and a later
// sweep retires it — the catch must not also return this player, which would
// double-return it.
handedOff = true
registry.register(mediaSession.id, entry)
return mediaSession
} catch (e: Throwable) {
if (!handedOff) {
entry?.let { retireSession(it) } ?: exoPlayerPool.releasePlayerAsync(player)
}
throw e
}
}
fun bindSessionActivity(
@@ -199,14 +240,14 @@ class MediaSessionPool(
}
fun releaseSession(session: MediaSession) {
val listener = playingMap.get(session.id) ?: cache.get(session.id)
if (listener != null) {
session.player.removeListener(listener.playerListener)
}
playingMap.remove(session.id)
cache.remove(session.id)
session.release()
// The registry removes the entry and then signals; retireSession does the teardown —
// explicitly, whether the session is idle or playing, never by relying on a cache
// removal firing a callback (a no-op once the entry was evicted while playing).
//
// An unknown id drops nothing: not in the registry means already retired (retireSession
// released it) or never owned (newSession's failure path), and releasing such a session
// again would be a double-release.
registry.drop(session.id)
cleanupUnused()
}
@@ -217,8 +258,7 @@ class MediaSessionPool(
// CAS so only one caller actually launches the sweep when many releases fire at once.
if (!lastCleanupNs.compareAndSet(previous, now)) return
scope.launch {
val snap = cache.snapshot()
snap.values.forEach {
registry.idleSnapshot().forEach {
if (it.session.connectedControllers.isEmpty()) {
releaseSession(it.session)
}
@@ -227,18 +267,17 @@ class MediaSessionPool(
}
fun destroy() {
scope.launch {
cache.evictAll()
playingMap.forEach {
it.value.removeListeners()
exoPlayerPool.releasePlayer(it.value.session.player as ExoPlayer)
it.value.session.release()
}
playingMap.clear()
// Synchronous on purpose: retireSession uses the non-suspending releasePlayerAsync, and
// releasePlayerAsync's contract guarantees returns queued here run before the pool's own
// teardown — so every player is back before exoPlayerPool.destroy() sweeps.
try {
registry.dropAll()
} finally {
// The player pool must tear down even if a retire throws mid-sweep; skipping it
// would leak every pooled player, not just the session that failed.
exoPlayerPool.destroy()
scope.cancel()
}
exoPlayerPool.destroy()
scope.cancel()
}
fun getSession(
@@ -247,17 +286,15 @@ class MediaSessionPool(
context: Context,
preferredMediaId: String? = null,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
if (existingSession != null) {
return existingSession.session
}
registry.get(id)?.let { return it.session }
return newSession(id, keepPlaying, context, preferredMediaId)
}
fun playingContent() = playingMap.values
fun playingContent() = registry.playingEntries()
fun getSession(id: String) = cache.get(id)?.session
// Resolves the session whether it is idle or playing.
fun getSession(id: String) = registry.get(id)?.session
class MediaSessionCallback(
val pool: MediaSessionPool,
@@ -288,12 +325,10 @@ class MediaSessionPool(
val pool: MediaSessionPool,
) : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
pool.playingMap.put(mediaSession.id, SessionListener(mediaSession, this))
} else {
pool.cache.put(mediaSession.id, SessionListener(mediaSession, this))
pool.playingMap.remove(mediaSession.id)
}
// Moves the one existing entry between tiers. A session must never have more than
// one live wrapper: dropping one wrapper would hand the player back while another
// still pointed at the live session.
pool.setPlaying(mediaSession.id, isPlaying)
}
}
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.playerPool
import androidx.collection.LruCache
/**
* Owns which sessions are reachable, and is the only place that decides one has been dropped.
*
* Two tiers: an LRU of idle entries and a map of ids that are currently playing. A playing entry
* stays in [idle] as well — [playing] only makes an eviction non-dropping. That is deliberate: it
* matches today's behaviour exactly (a playing session keeps consuming a cache slot until it ages
* out), and it is what keeps the replacement guard in [entryRemoved] load-bearing rather than dead
* code.
*
* Generic over the entry type so the bookkeeping — the part that has actually been wrong — is unit
* testable on the JVM with no Android or media3 objects involved.
*
* Uses `androidx.collection.LruCache` rather than `android.util.LruCache`: the latter is stubbed
* out under `unitTests.isReturnDefaultValues = true`, which would make every test here vacuous.
* The contract is otherwise identical, including `entryRemoved` firing outside the lock.
*
* Not thread safe. Every caller runs on the main thread (see MediaSessionPool).
*/
internal class SessionRegistry<T : Any>(
maxIdle: Int,
private val onDropped: (T) -> Unit,
) {
private val playing = mutableMapOf<String, T>()
// Set while an explicit drop is tearing an entry out of [idle], so the resulting entryRemoved
// callback doesn't signal a second time for the same drop.
//
// Resetting to false in `finally` (rather than restoring a saved value) is sound because the
// guarded region cannot nest: onDropped is never invoked while the flag is set, so nothing
// inside it can re-enter drop()/dropAll(). That non-nesting property is the invariant — not
// single-threadedness — and a future edit that called onDropped inside the guarded region
// would break it.
private var suppressDropSignal = false
private val idle =
object : LruCache<String, T>(maxIdle) {
override fun entryRemoved(
evicted: Boolean,
key: String,
oldValue: T,
newValue: T?,
) {
// A replacement is not a drop: re-putting the same entry (pause -> idle) fires this
// with newValue === oldValue, and retiring then would kill the session we are
// keeping. `newValue == null` needs no separate test — V is non-null by type, so a
// null newValue can never be identical to oldValue.
if (suppressDropSignal) return
if (newValue !== oldValue && !playing.containsKey(key)) onDropped(oldValue)
}
}
fun register(
id: String,
entry: T,
) {
idle.put(id, entry)
}
fun setPlaying(
id: String,
isPlaying: Boolean,
) {
val entry = get(id) ?: return
if (isPlaying) {
playing[id] = entry
} else {
// Re-inserts if it was evicted from idle while playing. Must happen before the playing
// entry is cleared, so the eviction this put may trigger still sees the pin.
idle.put(id, entry)
playing.remove(id)
}
}
fun get(id: String): T? = playing[id] ?: idle.get(id)
// snapshot() already materializes a detached map, so its values need no second copy.
fun idleSnapshot(): Collection<T> = idle.snapshot().values
// Detached, like idleSnapshot(). Handing out playing.values would be a live view, and a caller
// that iterated it while a drop fired would get a ConcurrentModificationException. A type whose
// job is owning reachability should not ship that footgun.
fun playingEntries(): List<T> = playing.values.toList()
/**
* Explicit release. Drops whether or not the session is playing, and does not rely on
* [idle]'s removal firing the callback — that reliance is the leak, because it silently does
* nothing when the entry was already evicted while playing.
*/
fun drop(id: String): T? {
val playingEntry = playing.remove(id)
suppressDropSignal = true
val idleEntry =
try {
idle.remove(id)
} finally {
suppressDropSignal = false
}
val entry = playingEntry ?: idleEntry ?: return null
onDropped(entry)
return entry
}
/** Teardown sweep: every reachable entry is dropped exactly once, playing or idle. */
fun dropAll() {
val all = ArrayList<T>()
playing.values.forEach { entry -> if (all.none { it === entry }) all.add(entry) }
idle.snapshot().values.forEach { entry -> if (all.none { it === entry }) all.add(entry) }
playing.clear()
suppressDropSignal = true
try {
idle.evictAll()
} finally {
suppressDropSignal = false
}
all.forEach(onDropped)
}
}
@@ -25,10 +25,10 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.textNoteModifications
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -418,7 +418,7 @@ fun observeNoteModifications(
.edits
.stateFlow
.sample(500)
.mapLatest { LocalCache.findLatestModificationForNote(note) }
.mapLatest { note.textNoteModifications() }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.collect { value = it }
@@ -75,7 +75,7 @@ class ResourceUsageAccountant(
) {
if (amount <= 0) return
live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount)
if (inHookRun.get()) return
if (inHookRun.get() == true) return
if (flushScheduled.compareAndSet(false, true)) {
scope.launch {
delay(flushDebounceMs)
@@ -89,14 +89,18 @@ class BlossomMirrorQueue(
val isRunning get() = _state.value?.running == true
/** Enqueue a sweep. No-op if one is already running or there's nothing to do. */
/**
* Enqueue a sweep. Returns true when this call actually started one; false (no-op) when a
* sweep is already running or there's nothing to do — the caller can tell the difference so
* it doesn't report an import/sync as started when the work was silently dropped.
*/
fun start(
account: Account,
tasks: List<Task>,
) {
if (isRunning) return
): Boolean {
if (isRunning) return false
val work = tasks.flatMap { t -> t.targets.map { t to it } }
if (work.isEmpty()) return
if (work.isEmpty()) return false
// Publish the active state and start the foreground service synchronously (we're on the
// foreground thread here, which is what dataSync FGS starts require) before the sweep runs.
@@ -122,6 +126,7 @@ class BlossomMirrorQueue(
}.awaitAll()
_state.update { it?.copy(running = false) }
}
return true
}
private suspend fun mirrorOne(
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.content.Context
import android.content.Intent
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
@@ -30,62 +32,88 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
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.service.playback.composable.VideoViewInner
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.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
fun BlossomBlobManagerScreen(
@@ -102,6 +130,11 @@ fun BlossomBlobManagerScreen(
val error by vm.error.collectAsStateWithLifecycle()
val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle()
// The tapped file, if any. We keep only the hash and re-resolve the row from the
// live list each recomposition so the open viewer/sheet stays in sync with
// mirror/delete updates (and closes itself when the last copy of the blob is deleted).
var selectedHash by remember { mutableStateOf<HexKey?>(null) }
pendingPayment?.let { pending ->
BlossomPaymentDialog(
host = pending.targetHost,
@@ -112,6 +145,31 @@ fun BlossomBlobManagerScreen(
)
}
selectedHash?.let { hash ->
val selected = blobs.firstOrNull { it.hash == hash }
when {
selected == null -> selectedHash = null
// Images and videos open in the full-screen zoomable viewer, which carries
// the actions in its own bottom drawer. Everything else (PDFs, arbitrary
// blobs) has nothing to zoom, so it goes straight to the actions sheet.
selected.url != null && selected.isViewable ->
BlossomBlobViewer(
row = selected,
vm = vm,
accountViewModel = accountViewModel,
onDismiss = { selectedHash = null },
)
else ->
BlobDetailSheet(
row = selected,
vm = vm,
onDismiss = { selectedHash = null },
)
}
}
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
@@ -119,12 +177,15 @@ fun BlossomBlobManagerScreen(
showBackButton = nav.canPop(),
popBack = { nav.popBack() },
actions = {
IconButton(onClick = { vm.refresh() }, enabled = !loading) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
} else {
Icon(symbol = MaterialSymbols.Sync, contentDescription = stringRes(R.string.blossom_refresh))
}
if (loading) {
// Keep the spinner as immediate feedback that a refresh is in flight; the
// overflow menu returns once it settles.
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
} else {
BlobManagerOverflowMenu(
onRefresh = { vm.refresh() },
onImport = { nav.nav(Route.ImportBlossomBlobs) },
)
}
},
)
@@ -162,16 +223,20 @@ fun BlossomBlobManagerScreen(
}
else ->
LazyColumn(
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 104.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (blobs.any { it.hasMissing }) {
item { SyncAllBanner(onSyncAll = { vm.syncAll() }) }
item(span = { GridItemSpan(maxLineSpan) }) {
SyncAllBanner(onSyncAll = { vm.syncAll() })
}
}
items(blobs, key = { it.hash }) { row ->
BlobCard(row, vm)
GalleryTile(row, onClick = { selectedHash = row.hash })
}
}
}
@@ -179,6 +244,55 @@ fun BlossomBlobManagerScreen(
}
}
/**
* The top-bar overflow: "Refresh" re-reads the presence matrix; "Import" opens the flow
* that pulls the user's files off other Blossom servers into their own.
*/
@Composable
private fun BlobManagerOverflowMenu(
onRefresh: () -> Unit,
onImport: () -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }) {
Icon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.blossom_more_actions))
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_refresh)) },
leadingIcon = { OverflowMenuIcon(MaterialSymbols.Sync) },
onClick = {
open = false
onRefresh()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_import_menu)) },
leadingIcon = { OverflowMenuIcon(MaterialSymbols.CloudDownload) },
onClick = {
open = false
onImport()
},
)
}
}
}
@Composable
private fun OverflowMenuIcon(symbol: MaterialSymbol) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Whether a blob is an image or a video, i.e. it can be previewed and shown full-screen. */
private val BlobRow.isViewable: Boolean
get() = type?.let { it.startsWith("image/") || it.startsWith("video/") } == true
@Composable
private fun CenteredState(content: @Composable () -> Unit) {
Column(
@@ -227,30 +341,304 @@ private fun SyncAllBanner(onSyncAll: () -> Unit) {
}
}
@OptIn(ExperimentalLayoutApi::class)
/**
* One gallery cell: a square preview — the image itself, or a decoded first frame for a
* video (with a play badge) — plus a corner badge summarizing how many of the user's
* servers hold this blob. Tapping it opens the full-screen viewer / actions.
*/
@Composable
private fun BlobCard(
private fun GalleryTile(
row: BlobRow,
onClick: () -> Unit,
) {
Box(
modifier =
Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable(onClick = onClick),
) {
BlobPreview(row = row, modifier = Modifier.fillMaxSize())
SyncBadge(
row = row,
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
)
}
}
/**
* Renders a blob's visual preview inside [modifier]'s bounds: the image, or a video's
* first frame (decoded by Coil's VideoFrameDecoder) with a centered play glyph. Falls
* back to a type glyph while loading fails or for non-visual blobs (e.g. an HLS playlist
* Coil can't decode).
*/
@Composable
private fun BlobPreview(
row: BlobRow,
modifier: Modifier = Modifier,
glyphSize: Dp = 34.dp,
playIconSize: Dp = 40.dp,
) {
val isVideo = row.type?.startsWith("video/") == true
Box(modifier = modifier, contentAlignment = Alignment.Center) {
if (row.url != null && row.isViewable) {
SubcomposeAsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent()
if (isVideo) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
symbol = MaterialSymbols.PlayCircle,
contentDescription = null,
modifier = Modifier.size(playIconSize),
tint = Color.White,
)
}
}
}
is AsyncImagePainter.State.Error -> BlobGlyph(row, glyphSize)
else -> {}
}
}
} else {
BlobGlyph(row, glyphSize)
}
}
}
@Composable
private fun BlobGlyph(
row: BlobRow,
size: Dp,
) {
Icon(
symbol = glyphFor(row.type),
contentDescription = null,
modifier = Modifier.size(size),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* Corner chip over a gallery tile: a green check when the blob is on every server, an
* amber cloud when some server is still missing it, plus a `present/total` count so the
* spread is legible at a glance without opening the file.
*/
@Composable
private fun SyncBadge(
row: BlobRow,
modifier: Modifier = Modifier,
) {
val synced = !row.hasMissing
val accent = if (synced) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.tertiary
Row(
modifier =
modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.padding(horizontal = 7.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = if (synced) MaterialSymbols.CheckCircle else MaterialSymbols.CloudUpload,
contentDescription =
stringRes(
if (synced) R.string.blossom_on_all_servers else R.string.blossom_not_on_all_servers,
),
modifier = Modifier.size(13.dp),
tint = accent,
)
Text(
text = "${row.presentCount}/${row.servers.size}",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
)
}
}
/**
* Full-screen viewer opened from a gallery tile: the image is zoomable/pannable and a
* video plays inline, matching the app's [com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog].
* The blob's storage matrix and its sync/copy/open/share/report/delete actions live in a
* bottom drawer reached from the top bar, so they don't cover the media until asked for.
*/
@Composable
private fun BlossomBlobViewer(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var drawerOpen by remember { mutableStateOf(false) }
val isVideo = row.type?.startsWith("video/") == true
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
val url = row.url
if (url != null && isVideo) {
val controllerVisible = remember { mutableStateOf(true) }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
VideoViewInner(
videoUri = url,
mimeType = row.type,
contentScale = ContentScale.Fit,
borderModifier = Modifier.fillMaxWidth(),
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
isFullscreen = true,
accountViewModel = accountViewModel,
)
}
} else if (url != null) {
val zoomState = rememberZoomState()
AsyncImage(
model = url,
contentDescription = row.hash,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().zoomable(zoomState),
)
}
// Top bar: back, share, and the drawer toggle.
Row(
modifier =
Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ViewerIconButton(MaterialSymbols.AutoMirrored.ArrowBack, stringRes(R.string.back), onDismiss)
Spacer(Modifier.weight(1f))
if (url != null) {
ViewerIconButton(MaterialSymbols.Share, stringRes(R.string.quick_action_share)) {
shareUrl(context, url)
}
}
ViewerIconButton(MaterialSymbols.Info, stringRes(R.string.blossom_file_details)) {
drawerOpen = true
}
}
// Bottom drawer with the file's storage matrix and actions.
if (drawerOpen) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable(onClick = { drawerOpen = false }),
)
Surface(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
color = MaterialTheme.colorScheme.surface,
) {
BlobActionsContent(
row = row,
vm = vm,
modifier =
Modifier
.fillMaxHeight(0.7f)
.navigationBarsPadding()
// Swallow taps so the scrim behind doesn't dismiss the drawer.
.clickable(enabled = false) {},
)
}
}
}
}
}
@Composable
private fun ViewerIconButton(
symbol: MaterialSymbol,
contentDescription: String,
onClick: () -> Unit,
) {
IconButton(
onClick = onClick,
modifier = Modifier.clip(CircleShape).background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(symbol = symbol, contentDescription = contentDescription, tint = Color.White)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BlobDetailSheet(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
BlobActionsContent(row = row, vm = vm, modifier = Modifier.navigationBarsPadding())
}
}
/**
* The blob's detail + action list, shared by the [BlobDetailSheet] (non-visual blobs)
* and by [BlossomBlobViewer]'s bottom drawer: a preview + hash/size header, the "Stored
* on" per-server matrix, the sync (mirror-to-missing) button, and the
* copy/open/share/report/delete actions.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BlobActionsContent(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
modifier: Modifier = Modifier,
) {
var menuOpen by remember { mutableStateOf(false) }
var reportOpen by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
Column(
modifier =
Modifier
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Header: thumbnail / file glyph + hash + overflow menu.
// Preview + identity.
Row(verticalAlignment = Alignment.CenterVertically) {
BlobThumbnail(row)
Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) {
Box(
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
BlobPreview(row = row, modifier = Modifier.fillMaxSize(), glyphSize = 28.dp, playIconSize = 28.dp)
}
Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) {
Text(
text = row.hash.take(12) + "…" + row.hash.takeLast(6),
style = MaterialTheme.typography.titleSmall,
@@ -264,61 +652,24 @@ private fun BlobCard(
color = MaterialTheme.colorScheme.grayText,
)
}
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (row.url != null) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy)) },
leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) },
onClick = {
menuOpen = false
clipboard.setText(AnnotatedString(row.url))
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_open)) },
leadingIcon = { MenuIcon(MaterialSymbols.AutoMirrored.OpenInNew) },
onClick = {
menuOpen = false
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
},
)
}
if (row.hasPresent) {
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_report)) },
leadingIcon = { MenuIcon(MaterialSymbols.Report) },
onClick = {
menuOpen = false
reportOpen = true
},
)
HorizontalDivider()
row.presentServers.forEach { server ->
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) },
leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
vm.delete(row.hash, server)
},
)
}
}
}
// Where the file lives.
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringRes(R.string.blossom_stored_on),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
row.servers.forEach { ServerPill(it) }
}
}
// Per-server presence pills (green = has it, grey = missing, spinner = working).
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
row.servers.forEach { ServerPill(it) }
}
// Primary CTA: fill the gaps.
// Primary CTA: fill the gaps for this file.
if (row.hasMissing && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
@@ -329,6 +680,34 @@ private fun BlobCard(
Text(stringRes(R.string.blossom_mirror_to_missing))
}
}
// Secondary actions.
if (row.url != null) {
val url = row.url
DetailAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy)) {
scope.launch { clipboard.setText(url) }
}
DetailAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share)) {
shareUrl(context, url)
}
DetailAction(MaterialSymbols.AutoMirrored.OpenInNew, stringRes(R.string.blossom_open)) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
}
}
if (row.hasPresent) {
DetailAction(MaterialSymbols.Report, stringRes(R.string.blossom_report)) { reportOpen = true }
HorizontalDivider()
row.presentServers.forEach { server ->
DetailAction(
symbol = MaterialSymbols.Delete,
label = stringRes(R.string.blossom_delete_from_host, vm.hostOf(server)),
tint = MaterialTheme.colorScheme.error,
) { vm.delete(row.hash, server) }
}
}
}
if (reportOpen) {
@@ -337,31 +716,46 @@ private fun BlobCard(
}
@Composable
private fun BlobThumbnail(row: BlobRow) {
val shape = RoundedCornerShape(12.dp)
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp).clip(shape),
)
} else {
Box(
modifier = Modifier.size(48.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
val glyph = if (row.type?.startsWith("video/") == true) MaterialSymbols.Download else MaterialSymbols.Storage
Icon(
symbol = glyph,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
private fun DetailAction(
symbol: MaterialSymbol,
label: String,
tint: Color = MaterialTheme.colorScheme.onSurface,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(22.dp), tint = tint)
Text(text = label, style = MaterialTheme.typography.bodyLarge, color = tint)
}
}
private fun glyphFor(type: String?): MaterialSymbol =
when {
type?.startsWith("image/") == true -> MaterialSymbols.Image
type?.startsWith("video/") == true -> MaterialSymbols.PlayCircle
else -> MaterialSymbols.Storage
}
private fun shareUrl(
context: Context,
url: String,
) {
val send =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
runCatching { context.startActivity(Intent.createChooser(send, null)) }
}
@Composable
private fun ServerPill(presence: ServerPresence) {
val present = presence.state == PresenceState.PRESENT
@@ -392,14 +786,6 @@ private fun ServerPill(presence: ServerPresence) {
}
}
@Composable
private fun MenuIcon(
symbol: MaterialSymbol,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint)
}
private fun humanBytes(bytes: Long): String =
when {
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
@@ -0,0 +1,423 @@
/*
* 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.actions.mediaServers
import android.widget.Toast
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.PaddingValues
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
/** Vibrant palette for server monograms; picked deterministically from the host name. */
private val ImportMonogramColors =
listOf(
Color(0xFF8B5CF6),
Color(0xFF0EA5A0),
Color(0xFFE07B00),
Color(0xFF4169E1),
Color(0xFFD16D8F),
Color(0xFF4F9D4F),
Color(0xFFB66605),
Color(0xFF7C6FE0),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BlossomImportScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: BlossomImportViewModel = viewModel()
vm.init(accountViewModel)
val sources by vm.sources.collectAsStateWithLifecycle()
val candidates by vm.candidates.collectAsStateWithLifecycle()
val scanning by vm.isScanning.collectAsStateWithLifecycle()
val scanned by vm.scanned.collectAsStateWithLifecycle()
val error by vm.error.collectAsStateWithLifecycle()
// Collect the user's own server list so the empty-state ↔ picker switch recomposes if the
// kind-10063 list arrives from a relay just after the screen opens (common on cold start).
val targetServers by accountViewModel.account.blossomServers.flow
.collectAsStateWithLifecycle()
val context = LocalContext.current
Scaffold(
topBar = {
TopBarWithBackButton(
caption = stringRes(R.string.blossom_import_title),
nav = nav,
)
},
) { padding ->
if (targetServers.isEmpty()) {
NoTargetsState(
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
),
onManageServers = { nav.nav(Route.EditMediaServers) },
)
return@Scaffold
}
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
text = stringRes(R.string.blossom_import_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.blossom_import_sources_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
val anyDisabled = sources.any { !it.enabled }
TextButton(onClick = { vm.setAll(anyDisabled) }, enabled = sources.isNotEmpty()) {
Text(
stringRes(
if (anyDisabled) R.string.blossom_import_enable_all else R.string.blossom_import_disable_all,
),
)
}
}
}
items(sources, key = { it.baseUrl }) { source ->
ImportSourceRow(
source = source,
onToggle = { vm.toggle(source.baseUrl) },
onRemove = { vm.remove(source.baseUrl) },
)
}
item {
Text(
text = stringRes(R.string.blossom_import_add_url_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
MediaServerEditField(R.string.add_a_blossom_server) { vm.addCustom(it) }
}
item {
val enabledCount = sources.count { it.enabled }
FilledTonalButton(
onClick = { vm.scan() },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
enabled = !scanning && enabledCount > 0,
) {
if (scanning) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_import_scanning))
} else {
Icon(symbol = MaterialSymbols.CloudSync, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_import_scan))
}
}
}
error?.let {
item {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
if (scanned && !scanning) {
if (candidates.isEmpty()) {
item {
Text(
text = stringRes(R.string.blossom_import_none_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 8.dp),
)
}
} else {
item {
ImportResultCard(
count = candidates.size,
onImport = {
when (vm.importSelected()) {
is ImportStart.Started -> nav.popBack()
ImportStart.Busy ->
Toast
.makeText(context, stringRes(context, R.string.blossom_import_busy), Toast.LENGTH_LONG)
.show()
ImportStart.Empty -> {}
}
},
)
}
}
}
}
}
}
@Composable
private fun NoTargetsState(
modifier: Modifier,
onManageServers: () -> Unit,
) {
Column(
modifier = modifier.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier =
Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Storage,
contentDescription = null,
modifier = Modifier.size(34.dp),
tint = MaterialTheme.colorScheme.grayText,
)
}
Spacer(Modifier.height(12.dp))
Text(
text = stringRes(R.string.blossom_import_no_targets),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onManageServers) {
Text(stringRes(R.string.blossom_import_manage_servers))
}
}
}
@Composable
private fun ImportSourceRow(
source: ImportSource,
onToggle: () -> Unit,
onRemove: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable(onClick = onToggle)
.padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ServerMonogramTile(name = source.name, size = 36.dp)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(
text = source.name.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ScanStatusLabel(source.scan, source.host)
}
if (source.custom) {
IconButton(onClick = onRemove) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(R.string.delete_media_server),
tint = MaterialTheme.colorScheme.grayText,
modifier = Modifier.size(20.dp),
)
}
}
Switch(checked = source.enabled, onCheckedChange = { onToggle() })
}
}
@Composable
private fun ScanStatusLabel(
scan: SourceScanState,
host: String,
) {
when (scan) {
is SourceScanState.Idle ->
Text(
text = host,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
is SourceScanState.Scanning ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CircularProgressIndicator(modifier = Modifier.size(11.dp), strokeWidth = 1.5.dp)
Text(
text = stringRes(R.string.blossom_import_scanning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
is SourceScanState.Found -> {
val count = scan.count
Text(
text = pluralStringResource(R.plurals.blossom_import_files_found, count, count),
style = MaterialTheme.typography.bodySmall,
color = if (count > 0) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText,
)
}
is SourceScanState.Failed ->
Text(
text = stringRes(R.string.blossom_import_source_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ImportResultCard(
count: Int,
onImport: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = pluralStringResource(R.plurals.blossom_import_found_files, count, count),
style = MaterialTheme.typography.bodyMedium,
)
FilledTonalButton(onClick = onImport, modifier = Modifier.fillMaxWidth()) {
Icon(symbol = MaterialSymbols.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(pluralStringResource(R.plurals.blossom_import_start_button, count, count))
}
}
}
/** A colored letter tile identifying a server, derived from its host name. */
@Composable
private fun ServerMonogramTile(
name: String,
size: Dp,
) {
val letter = name.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "?"
val color = ImportMonogramColors[((name.hashCode() % ImportMonogramColors.size) + ImportMonogramColors.size) % ImportMonogramColors.size]
Box(
modifier =
Modifier
.size(size)
.clip(RoundedCornerShape(size / 3))
.background(color),
contentAlignment = Alignment.Center,
) {
Text(
text = letter,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
@@ -0,0 +1,428 @@
/*
* 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.actions.mediaServers
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlin.coroutines.cancellation.CancellationException
/** Per-source outcome of the last scan, so each row can report what it found. */
@Immutable
sealed interface SourceScanState {
/** Never scanned, or reset after the source list changed. */
data object Idle : SourceScanState
/** A `/list` request is in flight for this source. */
data object Scanning : SourceScanState
/** The source answered; [count] is how many of the user's blobs it holds. */
data class Found(
val count: Int,
) : SourceScanState
/** The source could not be listed (unreachable, no `/list`, error). */
data class Failed(
val reason: String,
) : SourceScanState
}
/**
* One server the user can pull their files FROM. Seeded from [DEFAULT_MEDIA_SERVERS]
* (minus the servers already in the user's own kind-10063 list) plus any address the
* user typed in. [custom] rows are removable; recommended rows are not.
*/
@Immutable
data class ImportSource(
val baseUrl: String,
val host: String,
val name: String,
val enabled: Boolean,
val custom: Boolean,
val scan: SourceScanState = SourceScanState.Idle,
)
/** Outcome of tapping "import": either the sweep started, the queue was busy, or nothing to do. */
sealed interface ImportStart {
data class Started(
val count: Int,
) : ImportStart
data object Busy : ImportStart
data object Empty : ImportStart
}
/**
* A blob found on a source server that at least one of the user's own servers is
* missing — i.e. something worth importing. [sourceUrl] is the absolute URL the
* user's servers will mirror (BUD-04) from.
*/
@Immutable
data class ImportCandidate(
val hash: HexKey,
val sourceUrl: String,
val sourceHost: String,
val url: String?,
val size: Long?,
val type: String?,
val missingTargets: List<String>,
)
/**
* Backs the "import files from other Blossom servers" screen. The user picks a set of
* source servers (recommended or hand-typed); [scan] fans a `GET /list/<pubkey>`
* (BUD-02) across the enabled ones, works out which of those blobs are absent from the
* user's own kind-10063 servers, and [importSelected] hands the gaps to the app-level
* [BlossomMirrorQueue] so the user's servers fetch them (BUD-04) with the same floating
* progress banner the "sync all" sweep uses.
*/
@Stable
class BlossomImportViewModel : ViewModel() {
private lateinit var account: Account
private var seeded = false
private val _sources = MutableStateFlow<List<ImportSource>>(emptyList())
val sources = _sources.asStateFlow()
private val _candidates = MutableStateFlow<List<ImportCandidate>>(emptyList())
val candidates = _candidates.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning = _isScanning.asStateFlow()
/** True once a scan has completed at least once, so the UI can tell "not scanned yet" from "found nothing". */
private val _scanned = MutableStateFlow(false)
val scanned = _scanned.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
fun init(accountViewModel: AccountViewModel) {
// Re-point at the current account every call (matches the sibling BlobManager VM), but
// seed the source list only once so we don't clobber the user's toggles on recomposition.
this.account = accountViewModel.account
if (seeded) return
seeded = true
seedSources()
}
/** The user's own servers — where imported blobs land. */
private fun targets(): List<String> =
account.blossomServers.flow.value
.distinct()
private fun seedSources() {
val ownHosts = targets().mapTo(HashSet()) { BlossomServerUrl.domain(it) }
_sources.value =
DEFAULT_MEDIA_SERVERS
.filter { it.type == ServerType.Blossom }
// Importing from a server that's already yours is pointless — that's what "sync" covers.
.filter { BlossomServerUrl.domain(it.baseUrl) !in ownHosts }
.map {
ImportSource(
baseUrl = it.baseUrl,
host = BlossomServerUrl.domain(it.baseUrl),
name = it.name,
enabled = false,
custom = false,
)
}
}
fun toggle(baseUrl: String) {
_sources.update { list -> list.map { if (it.baseUrl == baseUrl) it.copy(enabled = !it.enabled) else it } }
invalidateResults()
}
fun setAll(enabled: Boolean) {
_sources.update { list -> list.map { it.copy(enabled = enabled) } }
invalidateResults()
}
/** Add a hand-typed server. Ignores blanks and duplicates (matched by host). */
fun addCustom(rawUrl: String) {
val normalized =
try {
Rfc3986.normalize(rawUrl.trim())
} catch (e: Exception) {
rawUrl.trim()
}
if (normalized.isBlank()) return
val host =
try {
BlossomServerUrl.domain(normalized)
} catch (e: Exception) {
normalized
}
_sources.update { list ->
if (list.any { it.host == host }) {
list
} else {
list + ImportSource(normalized, host, host, enabled = true, custom = true)
}
}
invalidateResults()
}
fun remove(baseUrl: String) {
_sources.update { list -> list.filterNot { it.baseUrl == baseUrl } }
invalidateResults()
}
/**
* Drop the previous scan's results whenever the source selection changes — otherwise the
* "Import N files" button could mirror blobs sourced from a server the user just disabled
* or removed. Forces a fresh scan against the current selection.
*/
private fun invalidateResults() {
// Cancel an in-flight scan too, so its results (for the old selection) can't land after the change.
scanJob?.cancel()
_isScanning.value = false
_candidates.value = emptyList()
_scanned.value = false
_error.value = null
_sources.update { list -> list.map { if (it.scan == SourceScanState.Idle) it else it.copy(scan = SourceScanState.Idle) } }
}
private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server))
private var scanJob: Job? = null
fun scan() {
val targets = targets()
if (targets.isEmpty()) {
_error.value = null
return
}
val enabled = _sources.value.filter { it.enabled }
if (enabled.isEmpty()) return
scanJob?.cancel()
scanJob =
viewModelScope.launch(Dispatchers.IO) {
_isScanning.value = true
_error.value = null
_candidates.value = emptyList()
setScanStates(enabled.map { it.baseUrl }, SourceScanState.Scanning)
try {
scanSources(enabled.map { it.baseUrl }, targets)
_scanned.value = true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("BlossomImport", "scan failed", e)
_error.value = e.message?.ifBlank { null } ?: e.javaClass.simpleName
} finally {
_isScanning.value = false
}
}
}
private suspend fun scanSources(
sources: List<String>,
targets: List<String>,
) {
val pubkey = account.signer.pubKey
// A BUD-02 `t=list` token with no `server` tag is generic, so one signature covers
// every source AND target list call. Signing once (instead of per server) avoids a
// round-trip storm with remote NIP-46 signers.
val listAuth = account.createBlossomListAuth("List blobs").toAuthorizationHeader()
// Phase 1 — /list each enabled source. Collect the user's blobs and remember the
// first source that can serve each hash (its descriptor URL is the mirror source).
val meta = HashMap<HexKey, CandidateMeta>()
coroutineScope {
sources
.map { source ->
async {
val listed =
try {
clientFor(source).list(source, pubkey, listAuth)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("BlossomImport", "list failed on $source", e)
setScanState(source, SourceScanState.Failed(e.shortReason()))
return@async
}
setScanState(source, SourceScanState.Found(listed.count { it.sha256 != null }))
synchronized(meta) {
listed.forEach { d ->
val hash = d.sha256 ?: return@forEach
meta.putIfAbsent(hash, CandidateMeta(sourceUrlFor(source, d, hash), BlossomServerUrl.domain(source), d.url, d.size, d.type))
}
}
}
}.awaitAll()
}
val allHashes = meta.keys.toList()
if (allHashes.isEmpty()) {
_candidates.value = emptyList()
return
}
// Phase 2 — which of the user's own servers already hold each hash? /list where the
// server supports it, HEAD-probe (bounded) the rest, so we only offer the true gaps.
val holders = targetHolders(allHashes, targets, listAuth)
_candidates.value =
allHashes
.mapNotNull { hash ->
val missing = targets.filter { hash !in holders.getOrElse(it) { emptySet() } }
if (missing.isEmpty()) return@mapNotNull null
val m = meta.getValue(hash)
ImportCandidate(hash, m.sourceUrl, m.sourceHost, m.url, m.size, m.type, missing)
}.sortedByDescending { it.missingTargets.size }
}
/** For each target server, the set of hashes it already holds. */
private suspend fun targetHolders(
hashes: List<HexKey>,
targets: List<String>,
listAuth: String,
): Map<String, Set<HexKey>> {
val pubkey = account.signer.pubKey
val listed: List<Pair<String, Set<HexKey>?>> =
coroutineScope {
targets
.map { target ->
async {
target to
try {
clientFor(target).list(target, pubkey, listAuth).mapNotNull { it.sha256 }.toSet()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
}
}.awaitAll()
}
val holders = HashMap<String, MutableSet<HexKey>>()
val nonListTargets = ArrayList<String>()
listed.forEach { (target, set) ->
if (set != null) holders[target] = set.toMutableSet() else nonListTargets.add(target)
}
// Servers without /list: HEAD-probe each hash, bounded so a big library doesn't fan out unbounded.
if (nonListTargets.isNotEmpty()) {
val limiter = Semaphore(MAX_HEAD_PROBES)
val probes =
coroutineScope {
nonListTargets
.flatMap { target ->
hashes.map { hash ->
async { limiter.withPermit { Triple(target, hash, clientFor(target).has(hash, target)) } }
}
}.awaitAll()
}
probes.forEach { (target, hash, present) ->
if (present) holders.getOrPut(target) { HashSet() }.add(hash)
}
}
return holders
}
/**
* Hand every discovered gap to the app-level mirror queue, which asks each of the
* user's servers to fetch the blob from its source. Reuses the same queue (and
* floating progress banner) as the on-screen "sync all".
*
* There is a single global queue, so if a sweep (or another import) is already in
* flight the queue would silently drop this one — [ImportStart.Busy] lets the caller
* keep the screen up and tell the user instead of navigating away to nothing.
*/
fun importSelected(): ImportStart {
val candidates = _candidates.value
val tasks = candidates.map { BlossomMirrorQueue.Task(it.hash, it.sourceUrl, it.size, it.missingTargets) }
if (tasks.isEmpty()) return ImportStart.Empty
// start() itself atomically no-ops if a sweep is already running, so key off its return
// rather than a separate isRunning check that could race with a sweep starting.
return if (Amethyst.instance.blossomMirrorQueue.start(account, tasks)) {
ImportStart.Started(candidates.size)
} else {
ImportStart.Busy
}
}
private fun setScanState(
server: String,
state: SourceScanState,
) {
_sources.update { list -> list.map { if (it.baseUrl == server) it.copy(scan = state) else it } }
}
private fun setScanStates(
servers: List<String>,
state: SourceScanState,
) {
val set = servers.toHashSet()
_sources.update { list -> list.map { if (it.baseUrl in set) it.copy(scan = state) else it } }
}
fun hostOf(serverBaseUrl: String): String = BlossomServerUrl.domain(serverBaseUrl)
private fun sourceUrlFor(
server: String,
descriptor: BlossomUploadResult,
hash: HexKey,
): String = descriptor.url?.takeIf { it.isNotBlank() } ?: BlossomServerUrl.blob(server, hash)
private fun Exception.shortReason(): String = message?.ifBlank { null } ?: javaClass.simpleName
private data class CandidateMeta(
val sourceUrl: String,
val sourceHost: String,
val url: String?,
val size: Long?,
val type: String?,
)
companion object {
/** Cap on concurrent HEAD probes when checking non-/list target servers. */
private const val MAX_HEAD_PROBES = 8
}
}
@@ -59,6 +59,10 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import kotlinx.coroutines.delay
/** How long the finished "Sync complete" banner lingers before it auto-dismisses. */
private const val AUTO_DISMISS_DELAY_MS = 4000L
/**
* App-wide floating banner for the BUD-04 "sync all" sweep, mounted at the navigation
@@ -75,6 +79,17 @@ fun DisplayBlossomSyncProgress() {
var lastShown by remember { mutableStateOf<BlossomSyncState?>(null) }
LaunchedEffect(state) { state?.let { lastShown = it } }
// Once the sweep finishes, auto-dismiss the "Sync complete" banner after a short pause so
// the user doesn't have to tap X. Keyed on `running`: a new sweep flips it back to true and
// cancels the pending dismiss; tapping X clears state to null and re-keys this to a no-op.
val running = state?.running
LaunchedEffect(running) {
if (running == false) {
delay(AUTO_DISMISS_DELAY_MS)
queue.dismiss()
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
AnimatedVisibility(
visible = state != null,
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.bolt12Offers.Bolt12OffersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomImportScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
@@ -586,6 +587,7 @@ fun BuildNavigation(
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ManageBlossomBlobs> { BlossomBlobManagerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportBlossomBlobs> { BlossomImportScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditNestsServers> {
com.vitorpamplona.amethyst.ui.actions.nestsServers
.NestsServersScreen(accountViewModel, nav)
@@ -467,6 +467,8 @@ sealed class Route {
@Serializable object ManageBlossomBlobs : Route()
@Serializable object ImportBlossomBlobs : Route()
@Serializable object EditNestsServers : Route()
@Serializable
@@ -37,8 +37,8 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip03Timestamp.earliestOtsVerifiedTime
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -119,12 +119,10 @@ fun LoadOts(
val noteStatus by observeNoteOts(note, accountViewModel)
LaunchedEffect(key1 = noteStatus) {
val target = noteStatus?.note ?: note
val newOts =
withContext(Dispatchers.IO) {
LocalCache.findEarliestOtsForNote(
note = noteStatus?.note ?: note,
otsVerifCacheBuilder = { Amethyst.instance.otsVerifCache },
)
target.earliestOtsVerifiedTime(Amethyst.instance.otsResolverBuilder.build())
}
earliestDate =
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.textNoteModifications
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
@@ -2066,18 +2067,16 @@ fun observeEdits(
val editState =
remember(baseNote.idHex) {
val cached = accountViewModel.cachedModificationEventsForNote(baseNote)
// Edits are anchored on the note (Note.edits), so the current set is readable synchronously
// (no cache scan) — start Empty or Loaded, never Loading.
val cached = baseNote.textNoteModifications()
mutableStateOf(
if (cached != null) {
if (cached.isEmpty()) {
GenericLoadable.Empty()
} else {
val state = EditState()
state.updateModifications(cached)
GenericLoadable.Loaded(state)
}
if (cached.isEmpty()) {
GenericLoadable.Empty()
} else {
GenericLoadable.Loading()
val state = EditState()
state.updateModifications(cached)
GenericLoadable.Loaded(state)
},
)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -34,6 +35,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -89,7 +91,7 @@ fun CalendarRsvpRow(
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RsvpButton(
label = stringResource(Res.string.calendar_rsvp_going),
@@ -128,24 +130,39 @@ private fun RsvpButton(
FilledTonalButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = colorFor(status),
contentColor = Color.White,
),
) {
Text(text = label)
RsvpButtonLabel(label)
}
} else {
OutlinedButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
) {
Text(text = label)
RsvpButtonLabel(label)
}
}
}
private val RsvpButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
@Composable
private fun RsvpButtonLabel(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun colorFor(status: RSVPStatusTag.STATUS) =
when (status) {
@@ -2065,8 +2065,6 @@ class AccountViewModel(
fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key)
fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key)
fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key)
@@ -18,6 +18,8 @@
* 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.
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser
import android.content.ComponentName
@@ -39,8 +39,8 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -125,7 +125,7 @@ fun AgentConsoleScreen(
},
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
TabRow(selectedTabIndex = selectedTab) {
SecondaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
@@ -108,7 +108,7 @@ fun BuzzCanvasScreen(
}
},
) { padding ->
if (content.isNullOrBlank() || canvas == null) {
if (content.isNullOrBlank()) {
Box(
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
@@ -38,16 +38,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
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.service.buzz.BuzzInviteMinter
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -73,7 +72,7 @@ fun BuzzWorkspaceOverflowMenu(
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val clipboard = LocalClipboard.current
IconButton(onClick = { menuOpen = true }) {
Icon(
@@ -145,13 +144,13 @@ fun BuzzWorkspaceOverflowMenu(
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, minted.url)
}
ContextCompat.startActivity(context, Intent.createChooser(send, null), null)
context.startActivity(Intent.createChooser(send, null))
result = null
}) { Text(stringRes(R.string.buzz_invite_share)) }
},
dismissButton = {
TextButton(onClick = {
clipboard.setText(AnnotatedString(minted.url))
scope.launch { clipboard.setText(minted.url) }
result = null
}) { Text(stringRes(R.string.buzz_invite_copy)) }
},
@@ -80,7 +80,7 @@ fun RefreshingChatroomFeedView(
// callers with no external jump affordance.
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -98,7 +98,7 @@ fun RefreshingChatroomFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
onWantsToEditChatMessage,
)
}
}
@@ -118,7 +118,7 @@ fun RenderChatFeedView(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -151,7 +151,7 @@ fun RenderChatFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
onWantsToEditChatMessage,
)
}
}
@@ -173,7 +173,7 @@ fun ChatFeedLoaded(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -268,7 +268,7 @@ fun ChatFeedLoaded(
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
onWantsToEditBuzz = onWantsToEditBuzz,
onWantsToEditChatMessage = onWantsToEditChatMessage,
)
}
}
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -94,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@@ -120,7 +122,7 @@ fun ChatMessageActionSheet(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
) {
var showShareSheet by remember { mutableStateOf(false) }
var wantsToEditPost by remember { mutableStateOf(false) }
@@ -267,18 +269,25 @@ fun ChatMessageActionSheet(
// Stage one: the primary chat action (reply / edit draft) is always shown.
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
// Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit).
// A 40002 event is inherently a Buzz message, so the type alone is the gate;
// authorship restricts it to my own messages.
val canEditBuzz =
onWantsToEditBuzz != null &&
note.event is StreamMessageV2Event &&
note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
if (canEditBuzz) {
// Editing my own chat message. Two surfaces publish an edit today, gated by type:
// - Buzz: kind-40002 stream message → a kind-40003 edit.
// - Concord: kind-9 channel message (carries a ConcordChannel gatherer) → a
// kind-1010 edit wrapped on the channel plane.
// Both restrict to my own messages; a note is only ever one of the two, so at
// most one tile shows and both route through the same edit callback.
val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine
val canEditConcord =
onWantsToEditChatMessage != null &&
note.event is ChatEvent &&
isMine &&
note.inGatherers?.any { it is ConcordChannel } == true
if (canEditBuzz || canEditConcord) {
SectionDivider()
TileRow {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) {
onWantsToEditBuzz!!(note)
val label = if (canEditBuzz) R.string.buzz_edit_message else R.string.edit_message
ActionTile(MaterialSymbols.Edit, stringRes(label)) {
onWantsToEditChatMessage!!(note)
onDismiss()
}
}
@@ -46,6 +46,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.latestBuzzEdit
import com.vitorpamplona.amethyst.model.latestConcordEdit
import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -65,18 +67,20 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChan
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderConcordEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
@@ -114,9 +118,10 @@ fun ChatroomMessageCompose(
// reply quotes inside a DM, where the target is simply older than the loaded window (see
// LoadingReplyNote). Null keeps the default blank for every other caller.
onBlank: (@Composable () -> Unit)? = null,
// Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for
// every non-Buzz chat surface, which hides the action.
onWantsToEditBuzz: ((Note) -> Unit)? = null,
// Edit my own chat message on surfaces that support it (Buzz kind-40002 → 40003,
// Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides
// the action.
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
) {
// Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted
// chat message renders with the chat reply design instead of the quoted-note card.
@@ -171,7 +176,7 @@ fun ChatroomMessageCompose(
onHighlightFinished,
groupPosition,
previousNoteId,
onWantsToEditBuzz,
onWantsToEditChatMessage,
)
}
}
@@ -202,7 +207,7 @@ fun NormalChatNote(
onHighlightFinished: (() -> Unit)? = null,
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
previousNoteId: HexKey? = null,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
) {
// A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as
// self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment,
@@ -333,7 +338,7 @@ fun NormalChatNote(
onDismiss = onDismiss,
accountViewModel = accountViewModel,
nav = nav,
onWantsToEditBuzz = onWantsToEditBuzz,
onWantsToEditChatMessage = onWantsToEditChatMessage,
)
},
reactionsRow =
@@ -612,16 +617,33 @@ fun NoteRow(
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
else -> {
// Buzz channels overlay kind-40003 edits on their messages: when one
// exists, render the newest edit's content instead of the stale
// original. Null for every non-Buzz chat surface.
val buzzEdit = observeBuzzEdit(note)
if (buzzEdit != null) {
RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav)
} else {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
// Concord and Buzz channels overlay edits on their messages (kind-3302 and
// kind-40003): when one exists, render the newest edit's content instead of the
// stale original. One observer for both — a message is only ever one kind, so a
// single edits-flow collector per row covers both (and is null for other surfaces).
val edit = observeChatEdit(note)
when (edit?.event) {
is ConcordChatEditEvent -> RenderConcordEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
is StreamMessageEditEvent -> RenderBuzzEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
}
}
}
}
}
/**
* The newest edit overlaying a chat message [note] (Concord kind-3302 or Buzz kind-40003), or null
* when unedited. A message is only ever one kind, so both resolve off the same [Note.edits] and one
* collector on the note's edits flow serves both — recomposing whenever an edit is added or removed.
*/
@Composable
fun observeChatEdit(note: Note): Note? {
val latest by
produceState<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value = note.latestConcordEdit() ?: note.latestBuzzEdit()
}
}
return latest
}
@@ -30,8 +30,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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -41,7 +39,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -63,27 +60,6 @@ import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
/**
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
* arrive. Returns null when the message is unedited or has no channel scope.
*
* Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id
* (a Buzz UUID) rather than any channel object: the state exists independently of
* when — or whether — the channel materialized, so a row composed before the first
* edit arrived still starts rendering overlays the moment one lands.
*/
@Composable
fun observeBuzzEdit(note: Note): Note? {
// Key on note.event, not note: LocalCache mutates a Note in place, so keying on the Note instance
// would cache a null groupId taken before the event populated and never recompute for that row.
val channelId = remember(note.event) { note.event?.groupId() } ?: return null
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Subscribing to the version counter is what re-runs editFor on new arrivals.
val version by state.editUpdates.collectAsState()
return remember(note, version) { state.editFor(note.idHex) }
}
/**
* A Buzz stream message whose content has been superseded by a kind-40003 edit:
@@ -228,7 +204,7 @@ fun RenderBuzzDiff(note: Note) {
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
it.prNumber?.let { pr -> append(" · PR #$pr") }
it.commitSha
?.takeIf { sha -> sha.isNotBlank() }
.takeIf { sha -> sha.isNotBlank() }
?.let { sha -> append(" · ${sha.take(8)}") }
}.ifBlank { null }
}
@@ -0,0 +1,87 @@
/*
* 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.chats.feed.types
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* A Concord chat message whose content has been superseded by a kind-3302 edit:
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
* marker, matching the Concord reference client's last-write-wins presentation.
*/
@Composable
fun RenderConcordEditedNote(
note: Note,
editNote: Note,
canPreview: Boolean,
innerQuote: Boolean,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
// The edit note may still be loading; fall back to the original rendering rather
// than committing to an edited branch that would show a blank row.
val content = editNote.event?.content
if (content == null) {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
return
}
// Custom emoji + mentions live on the edit's own tags, so render against those.
val tags = remember(editNote.event) { editNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column {
TranslatableRichTextViewer(
content = content,
canPreview = canPreview,
quotesLeft = if (innerQuote) 0 else 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
authorPubKey = note.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
Text(
text = stringRes(R.string.message_edited),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
@@ -24,6 +24,7 @@ import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -48,6 +49,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -56,16 +58,22 @@ import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toPlainImetas
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
@@ -136,6 +144,13 @@ fun MinichatScreen(
val scope = rememberCoroutineScope()
val context = LocalContext.current
val canPost by remember { derivedStateOf { composer.text.isNotBlank() } }
val uploadState =
remember {
ChatFileUploadState(
accountViewModel.account.settings.defaultFileServer,
accountViewModel.account.settings.stripLocationOnUpload,
)
}
Scaffold(
topBar = {
@@ -188,6 +203,43 @@ fun MinichatScreen(
}
}
// Picture attachments: the picked media opens this dialog, which uploads via the shared
// pipeline and sends the result as a thread reply. Concord minichats always encrypt (the
// channel is end-to-end); public-chat (NIP-28/NIP-29) minichats upload in plaintext and carry
// the URL + `imeta` on the kind-1111 comment. [Account.sendMinichatReply] routes by backend.
uploadState.multiOrchestrator?.let {
ChatFileUploadDialog(
state = uploadState,
title = { Text(stringRes(R.string.chat_send_image_title)) },
upload = {
scope.launch(Dispatchers.IO) {
ChatFileUploader(accountViewModel.account).justUploadNIP17(
viewState = uploadState,
onError = { title, message ->
launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
},
onEncryptedUploadError = { title, message ->
launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
},
context = context,
onceUploaded = { uploads ->
val caption = uploadState.caption
val imetas = if (isConcord) uploads.mapNotNull { it.toConcordImeta() } else uploads.toPlainImetas()
if (imetas.isNotEmpty()) {
accountViewModel.account.sendMinichatReply(rootNote, caption, imetas)
}
},
)
accountViewModel.account.settings.changeDefaultFileServer(uploadState.selectedServer)
accountViewModel.account.settings.changeStripLocationOnUpload(uploadState.stripMetadata)
}
},
onCancel = uploadState::reset,
accountViewModel = accountViewModel,
nav = nav,
)
}
Column(modifier = EditFieldModifier) {
ThinPaddingTextField(
state = composer,
@@ -199,6 +251,19 @@ fun MinichatScreen(
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
SelectFromGallery(
isUploading = uploadState.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.height(32.dp).padding(start = 2.dp),
onImageChosen = { media ->
uploadState.load(media)
// Encrypt only where the backend is end-to-end (Concord); public chats
// send plaintext blobs, matching how their main composer uploads.
uploadState.encryptFiles = isConcord
},
)
},
trailingIcon = {
ThinSendButton(
isActive = canPost,
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
@@ -99,6 +100,8 @@ fun ConcordChannelListScreen(
nav: INav,
) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
// Warm every channel's last-message preview so the list fills in without opening each channel.
ConcordChannelPreviewLoader(communityId, accountViewModel)
val account = accountViewModel.account
// Re-resolve on each revision so a deep link that lands before the session exists picks it up.
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -88,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
@@ -95,11 +97,9 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -194,6 +194,7 @@ fun ConcordChannelScreen(
routeForLastRead = concordChannelLastReadRoute(communityId, channelId),
onWantsToReply = { newMessageModel.reply(it) },
onWantsToEditDraft = {},
onWantsToEditChatMessage = { newMessageModel.editConcordMessage(it) },
// A status card at the oldest end: shows what it's reaching for while it pages and
// crossfades to "All caught up" when every relay runs dry.
olderBoundary = {
@@ -411,6 +412,35 @@ private fun ConcordMessageComposer(
)
}
// Edit mode: a banner reminding the user the next send replaces this message (a kind-1010
// edit on the channel plane), with an X to abandon the edit and clear the field.
newMessageModel.editingMessage.value?.let {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SymbolIcon(
symbol = MaterialSymbols.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(com.vitorpamplona.amethyst.R.string.concord_editing_banner),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
IconButton(onClick = { newMessageModel.cancelEdit() }) {
SymbolIcon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.cancel),
modifier = Modifier.size(16.dp),
)
}
}
}
Column(modifier = EditFieldModifier) {
newMessageModel.userSuggestions?.let {
ShowUserSuggestionList(
@@ -551,21 +581,3 @@ private fun ConcordFileUploadDialog(
nav = nav,
)
}
/**
* Turns an encrypted upload into the Armada-shaped `imeta` (via [ChannelChat.encryptedImageImeta]).
* Returns null when the upload carried no cipher — so a non-encrypted blob is never sent as a Concord
* image (fails closed, protecting the community's end-to-end guarantee).
*/
private fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
val cipher = cipher ?: return null
return ChannelChat.encryptedImageImeta(
url = result.url,
mimeType = result.mimeTypeBeforeEncryption,
dim = result.fileHeader.dim?.toString(),
blurhash = result.fileHeader.blurHash?.blurhash,
cipher = cipher,
originalHash = result.hashBeforeEncryption,
thumbhash = result.fileHeader.thumbHash?.thumbhash,
)
}
@@ -0,0 +1,77 @@
/*
* 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.chats.publicChannels.concord.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.delay
/**
* One-shot warm of the last-message preview of **every channel in one community** when its screen is
* open, so the channel list fills in without the user tapping into each channel.
*
* Not a live subscription: it drains one `limit:1` REQ per channel once (the wraps ingest through the
* normal cache path and the always-on plane subscription keeps them fresh afterward — see
* [com.vitorpamplona.amethyst.model.Account.warmConcordChannelPreviews]). It re-runs when a fold
* changes the channel set, debounced so the cold-boot burst of fold revisions coalesces into one warm.
*/
@Composable
fun ConcordChannelPreviewLoader(
communityId: String,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
LaunchedEffect(communityId, revision) {
// Let a burst of folds settle before draining, and coalesce repeated revisions into one warm.
delay(800)
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return@LaunchedEffect
account.warmConcordChannelPreviews(listOf(entry))
}
}
/**
* Always-on account-level warm of channel previews for **every subscribed community**, mounted once
* high in the logged-in tree from `ConcordChannelPreload`. This is why the Messages inbox shows a last
* message for channels the user never opened — one drain covers all joined communities at once.
*
* A single [com.vitorpamplona.amethyst.model.Account.warmConcordChannelPreviews] call groups every
* community's per-channel `limit:1` filters by relay, so there is one REQ per relay (not one live
* subscription per community). Debounced on the fold revision so the cold-boot burst warms once.
*/
@Composable
fun ConcordChannelPreviewAccountPreload(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle()
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
LaunchedEffect(communities, revision) {
// Debounce the cold-boot burst of fold revisions (and any join/leave churn) into one drain.
delay(1500)
account.warmConcordChannelPreviews(communities)
}
}
@@ -100,6 +100,10 @@ fun ConcordChannelPreload(accountViewModel: AccountViewModel) {
bootstrapPinnedCommunities(accountViewModel)
// Warm the last-message preview of every channel of every joined community (one drain per relay),
// so the Messages inbox shows a preview for channels the user has never opened.
ConcordChannelPreviewAccountPreload(accountViewModel)
KeyDataSourceSubscription(state, dataSource)
}
@@ -63,6 +63,10 @@ open class ConcordNewMessageViewModel : ViewModel() {
val message = TextFieldState()
val replyTo = mutableStateOf<Note?>(null)
// The message currently being edited (my own kind-9), or null for a fresh post/reply. When set,
// [sendPost] publishes a kind-1010 edit on the channel plane instead of a new message.
val editingMessage = mutableStateOf<Note?>(null)
// How the pending reply is delivered: INLINE stays in the timeline (kind-9 quote),
// MINICHAT pulls it into a thread (kind-1111). Only meaningful while replyTo is set.
val replyMode = mutableStateOf(ReplyMode.INLINE)
@@ -121,12 +125,26 @@ open class ConcordNewMessageViewModel : ViewModel() {
this.channelId = channelId
this.message.clearText()
this.replyTo.value = null
this.editingMessage.value = null
}
}
fun reply(note: Note) {
replyTo.value = note
replyMode.value = ReplyMode.INLINE
editingMessage.value = null
}
/** Enter edit mode for my own [note]: prefills the field with its current text; sending publishes a kind-1010 edit. */
fun editConcordMessage(note: Note) {
replyTo.value = null
editingMessage.value = note
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
}
fun cancelEdit() {
editingMessage.value = null
message.clearText()
}
/** Reply to [note] directly in a minichat thread (used from the minichat screen / long-press). */
@@ -184,8 +202,14 @@ open class ConcordNewMessageViewModel : ViewModel() {
val text = message.text.toString().trim()
if (text.isEmpty()) return
val parent = replyTo.value
account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
val editing = editingMessage.value
if (editing != null) {
account.editConcordChannelMessage(editing, text)
editingMessage.value = null
} else {
val parent = replyTo.value
account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
}
message.clearText()
clearReply()
@@ -217,7 +217,7 @@ private fun ChannelView(
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
onWantsToEditBuzz = newPostModel::editBuzzMessage,
onWantsToEditChatMessage = newPostModel::editBuzzMessage,
jumpToNoteId = jumpToNoteId,
onJumpHandled = { jumpToNoteId.value = null },
// A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry.
@@ -0,0 +1,57 @@
/*
* 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.chats.utils
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
/**
* Turns an encrypted upload into the Armada-shaped encrypted `imeta` (via
* [ChannelChat.encryptedImageImeta]). Returns null when the upload carried no cipher — so a
* non-encrypted blob is never sent as an encrypted image (fails closed, protecting the end-to-end
* guarantee of Concord channels and their minichat threads).
*/
fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
val cipher = cipher ?: return null
return ChannelChat.encryptedImageImeta(
url = result.url,
mimeType = result.mimeTypeBeforeEncryption,
dim = result.fileHeader.dim?.toString(),
blurhash = result.fileHeader.blurHash?.blurhash,
cipher = cipher,
originalHash = result.hashBeforeEncryption,
thumbhash = result.fileHeader.thumbHash?.thumbhash,
)
}
/**
* Turns a list of plaintext (unencrypted) uploads into their NIP-92 `imeta` tags — the shape a
* public-chat (NIP-28/NIP-29) message carries. Reuses [IMetaAttachments.add] so the tag content
* (hash, size, mime, dims, blurhash, thumbhash, magnet, alt, content-warning) matches every other
* plaintext composer.
*/
fun List<SuccessfulUploads>.toPlainImetas(): List<IMetaTag> {
val attachments = IMetaAttachments()
forEach { attachments.add(it.result, it.caption, it.contentWarningReason) }
return attachments.iMetaAttachments
}
@@ -18,6 +18,8 @@
* 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.
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.os.Build
@@ -18,6 +18,8 @@
* 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.
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.content.ClipData
@@ -153,6 +153,7 @@ class RemoteImeView(
fun selectAllText(): Boolean = onTextContextMenuItem(android.R.id.selectAll)
/** A page field focused: configure the keyboard, seed the buffer, and raise the IME. */
@Suppress("DEPRECATION") // InputMethodManager.SHOW_IMPLICIT is deprecated; no equivalent flag on the newer API.
fun onPageFocus(focus: ImeEvent.Focus) {
configureFor(focus)
// Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it
@@ -18,6 +18,8 @@
* 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.
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites
import android.content.ComponentName
@@ -72,11 +72,10 @@ 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.LocalClipboardManager
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -87,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ActivityEntry
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
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.topbars.TopBarWithBackButton
@@ -107,7 +107,7 @@ fun Nip46SignerScreen(
val signer = account.nip46Signer
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val clipboard = LocalClipboard.current
val enabled by account.settings.nip46SignerEnabled.collectAsStateWithLifecycle()
val secret by account.settings.nip46BunkerSecret.collectAsStateWithLifecycle()
@@ -187,7 +187,7 @@ fun Nip46SignerScreen(
QrHeroCard(
uri = uri,
onCopy = {
clipboard.setText(AnnotatedString(uri))
scope.launch { clipboard.setText(uri) }
Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show()
},
onRegenerate = { confirmRotate = true },
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M19,3H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM14,17H7v-2h7v2zM17,13H7v-2h10v2zM17,9H7V7h10v2z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M19,22H5v-2h14v2zM17.16,8.26C18.22,9.63 18.7,11.4 18.5,13.16C18.15,15.98 15.67,18 12.83,18H11.5C8.66,18 6.35,15.98 6,13.16c-0.2,-1.79 0.29,-3.56 1.34,-4.93C6.5,7.68 6,6.65 6,5.5 6,3.57 7.57,2 9.5,2c1.03,0 1.95,0.45 2.59,1.15C12.71,2.44 13.58,2 14.5,2 16.43,2 18,3.57 18,5.5c0,1.15 -0.5,2.18 -0.84,2.76z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.87,0 3.63,-0.52 5.13,-1.42l-0.9,-1.55C15,19.65 13.55,20 12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8v0.72c0,0.79 -0.71,1.78 -1.5,1.78s-1.5,-0.99 -1.5,-1.78V12c0,-2.76 -2.24,-5 -5,-5S7,9.24 7,12s2.24,5 5,5c1.38,0 2.64,-0.56 3.54,-1.47 0.65,0.89 1.77,1.47 2.96,1.47 1.97,0 3.5,-1.6 3.5,-3.78V12c0,-5.52 -4.48,-10 -10,-10zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
</vector>
@@ -312,6 +312,7 @@
<string name="chats_reply_not_found">تعذّر العثور على هذه الرسالة</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">تم البحث في كل relay · اضغط للعرض</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"خطأ في تحميل الردود:"</string>
<string name="try_again">المحاولة مرة اخرى</string>
<string name="notification_feed_is_empty">لا توجد إشعارات حتى الآن.</string>
@@ -1263,6 +1264,14 @@
<string name="app_notification_mentions_channel_description">يُعلمك عندما يذكرك شخص ما أو يستشهد بك في منشور</string>
<string name="app_notification_mentions_channel_message">أشار إليك %1$s</string>
<string name="app_notification_mentions_summary">إشارات جديدة</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">المكالمات الواردة</string>
<string name="app_notification_calls_channel_description">إشعارات للمكالمات الصوتية والمرئية الواردة</string>
@@ -302,6 +302,7 @@
<string name="chats_reply_not_found">এই বার্তাটি খুঁজে পাওয়া যায়নি</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">প্রতিটি relay অনুসন্ধান করা হয়েছে · দেখতে ট্যাপ করুন</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"প্রতিউত্তরগুলি লোড করা যাচ্ছে না: "</string>
<string name="try_again">আবার চেষ্টা করুন</string>
<string name="notification_feed_is_empty">এখনও কোনো বিজ্ঞপ্তি নেই।</string>
@@ -1214,6 +1215,14 @@
<string name="app_notification_mentions_channel_description">কেউ পোস্টে আপনাকে উল্লেখ বা উদ্ধৃত করলে আপনাকে জানায়</string>
<string name="app_notification_mentions_channel_message">%1$s আপনাকে উল্লেখ করেছে</string>
<string name="app_notification_mentions_summary">নতুন উল্লেখ</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">আসা কল</string>
<string name="app_notification_calls_channel_description">ইনকামিং ভয়েস ও ভিডিও কলের বিজ্ঞপ্তি</string>
@@ -26,6 +26,12 @@
<item quantity="many">+%1$d\nodpovědi</item>
<item quantity="other">+%1$d\nodpovědí</item>
</plurals>
<plurals name="dm_sender_reported">
<item quantity="one">Nahlásil %1$d člověk, kterého sledujete</item>
<item quantity="few">Nahlásili %1$d lidé, které sledujete</item>
<item quantity="many">Nahlásilo %1$d lidí, které sledujete</item>
<item quantity="other">Nahlásilo %1$d lidí, které sledujete</item>
</plurals>
<string name="post_not_found">Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Obrázek kanálu</string>
@@ -420,6 +426,12 @@
<string name="chats_reply_not_found">Tuto zprávu se nepodařilo najít</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Prohledány všechny relaye · klepnutím zobrazíte</string>
<string name="dm_sender_reported_who">Kdo to nahlásil?</string>
<string name="dm_sender_reported_dismiss">Zavřít</string>
<string name="dm_sender_reported_hide">Skrýt</string>
<string name="dm_sender_reported_subtitle">Někdo, koho sledujete, nahlásil tento účet</string>
<string name="dm_sender_reported_icon">Nahlášeno</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Chyba při načítání odpovědí: "</string>
<string name="try_again">Zkusit znovu</string>
<string name="notification_feed_is_empty">Zatím žádná oznámení.</string>
@@ -1799,6 +1811,9 @@
<string name="app_notification_zaps_channel_message_for">pro %1$s</string>
<string name="app_notification_reply_label">Odpovědět</string>
<string name="app_notification_mark_read_label">Označit jako přečtené</string>
<string name="app_notification_me">Já</string>
<string name="app_notification_new_message">Nová zpráva</string>
<string name="app_notification_added_to_group">Byli jste přidáni do %1$s</string>
<string name="app_notification_dms_summary">Nové zprávy</string>
<string name="app_notification_zaps_summary">Nové zapsy</string>
<string name="app_notification_reactions_channel_name">Reakce</string>
@@ -1820,6 +1835,49 @@
<string name="app_notification_mentions_channel_description">Upozorní vás, když vás někdo zmíní nebo cituje v příspěvku</string>
<string name="app_notification_mentions_channel_message">%1$s vás zmínil(a)</string>
<string name="app_notification_mentions_summary">Nové zmínky</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<string name="app_notification_group_messages">Zprávy</string>
<string name="app_notification_group_social">Sociální</string>
<string name="app_notification_group_payments">Platby</string>
<string name="app_notification_group_content">Obsah</string>
<string name="app_notification_group_developer">Vývojář</string>
<string name="app_notification_group_games">Hry</string>
<!-- Reposts -->
<string name="app_notification_reposts_channel_name">Přeposlání</string>
<string name="app_notification_reposts_channel_description">Upozorní vás, když někdo přepošle váš příspěvek</string>
<string name="app_notification_reposts_channel_message">%1$s přeposlal váš příspěvek</string>
<string name="app_notification_reposts_summary">Nová přeposlání</string>
<!-- Media (picture &amp; video posts that mention you) -->
<string name="app_notification_media_channel_name">Média</string>
<string name="app_notification_media_channel_description">Upozorní vás, když vás někdo zmíní ve fotce nebo videu</string>
<string name="app_notification_media_channel_message_photo">%1$s sdílel fotku</string>
<string name="app_notification_media_channel_message_video">%1$s sdílel video</string>
<string name="app_notification_media_summary">Nová média</string>
<!-- Articles &amp; highlights -->
<string name="app_notification_articles_channel_name">Články a zvýraznění</string>
<string name="app_notification_articles_channel_description">Upozorní vás, když vás někdo zmíní nebo zvýrazní v článku</string>
<string name="app_notification_articles_channel_message">%1$s vás zmínil v článku</string>
<string name="app_notification_articles_channel_message_highlight">%1$s zvýraznil váš článek</string>
<string name="app_notification_articles_summary">Nové články</string>
<!-- Code &amp; git -->
<string name="app_notification_code_channel_name">Kód a Git</string>
<string name="app_notification_code_channel_description">Upozorní vás na issues, patche a pull requesty</string>
<string name="app_notification_code_channel_message_issue">%1$s otevřel issue</string>
<string name="app_notification_code_channel_message_patch">%1$s odeslal patch</string>
<string name="app_notification_code_channel_message_pr">%1$s otevřel pull request</string>
<string name="app_notification_code_channel_message_pr_update">%1$s aktualizoval pull request</string>
<string name="app_notification_code_summary">Nová aktivita v kódu</string>
<!-- Badges -->
<string name="app_notification_badges_channel_name">Odznaky</string>
<string name="app_notification_badges_channel_description">Upozorní vás, když získáte odznak</string>
<string name="app_notification_badges_channel_message">Získali jste odznak</string>
<string name="app_notification_badges_channel_message_from">Uděleno od %1$s</string>
<string name="app_notification_badges_summary">Nové odznaky</string>
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<string name="app_notification_nutzap_channel_message_from">Nutzap od %1$s</string>
<string name="app_notification_onchain_channel_message_from">Onchain zap od %1$s</string>
<!-- Poll mentions (rendered on the Mentions channel) -->
<string name="app_notification_poll_channel_message">%1$s položil otázku</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Příchozí hovory</string>
<string name="app_notification_calls_channel_description">Oznámení pro příchozí hlasové a videohovory</string>
@@ -2074,6 +2132,11 @@
<string name="settings_section_appearance">Vzhled</string>
<string name="settings_section_media">Média a data</string>
<string name="settings_section_general">Obecné</string>
<string name="settings_section_tor_engine">Připojení</string>
<string name="settings_section_tor_routing">Směrovat přes Tor</string>
<string name="settings_section_profile_sections">Sekce a kanály</string>
<string name="settings_section_home_tabs">Viditelné karty</string>
<string name="settings_section_reminders">Připomenutí</string>
<string name="wallet_connect">Připojení peněženky</string>
<string name="language">Jazyk</string>
<string name="theme">Motiv</string>
@@ -2373,6 +2436,9 @@
<string name="relay_group_browse_description">Procházejte skupiny hostované na relay. Vložte adresu relay nebo vyberte některou níže.</string>
<string name="relay_group_browse_relay_label">Adresa relay</string>
<string name="relay_group_browse_go">Procházet</string>
<string name="relay_tor_clearnet_title">Nelze dosáhnout tohoto relaye přes Tor</string>
<string name="relay_tor_clearnet_body">%1$s neodpovídá přes Tor — jeho hostitel může blokovat výstupní uzly Toru. Připojit se k němu místo toho přes clearnet?</string>
<string name="relay_tor_clearnet_action">Použít clearnet</string>
<string name="relay_group_browse_your_relays">Relaye, na kterých jste</string>
<string name="relay_group_browse_popular">Oblíbené relaye</string>
<string name="relay_group_no_messages_yet">Zatím žádné zprávy</string>
@@ -3106,6 +3172,9 @@
<string name="chat_system_created_channel">%1$s vytvořil(a) kanál %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s vytvořil(a) kanál</string>
<string name="chat_system_updated_channel">%1$s aktualizoval(a) profil kanálu</string>
<string name="relay_group_section_channels">Kanály</string>
<string name="relay_group_section_forums">Fóra</string>
<string name="relay_group_add_member">Přidat člena</string>
<string name="chat_delivery_details_title">Doručení zprávy</string>
<string name="close">Zavřít</string>
<string name="chat_delivery_pending">Čekání, až relay přijme tuto zprávu</string>
@@ -4157,6 +4226,11 @@
<string name="pow_notification_channel_name">Těžení proof of work</string>
<string name="pow_notification_channel_description">Zobrazuje příspěvky, které stále těží svůj NIP-13 proof of work, aby mohly dokončit těžbu i po opuštění aplikace.</string>
<string name="pow_notification_cancel_all">Zrušit</string>
<string name="pow_notification_send_without_pow">Odeslat nyní</string>
<string name="pow_cancel_dialog_title">Zastavit těžení?</string>
<string name="pow_cancel_dialog_message">Tento příspěvek stále těží svůj proof of work. Můžete jej odeslat hned bez proof of work, nebo jej zahodit.</string>
<string name="pow_cancel_dialog_send_without_pow">Odeslat bez PoW</string>
<string name="pow_cancel_dialog_discard">Zahodit příspěvek</string>
<string name="pow_difficulty_estimate">≈ %1$s na příspěvek na tomto zařízení</string>
<string name="pow_estimate_instant">&lt;1 s</string>
<plurals name="pow_estimate_seconds">
@@ -20,6 +20,10 @@
<item quantity="one">+%1$d\nAntwort</item>
<item quantity="other">+%1$d\nAntworten</item>
</plurals>
<plurals name="dm_sender_reported">
<item quantity="one">Von %1$d Person gemeldet, der du folgst</item>
<item quantity="other">Von %1$d Personen gemeldet, denen du folgst</item>
</plurals>
<string name="post_not_found">Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanalbild</string>
@@ -406,6 +410,12 @@
<string name="chats_reply_not_found">Diese Nachricht konnte nicht gefunden werden</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Alle Relays durchsucht · zum Anzeigen tippen</string>
<string name="dm_sender_reported_who">Wer hat das gemeldet?</string>
<string name="dm_sender_reported_dismiss">Schließen</string>
<string name="dm_sender_reported_hide">Ausblenden</string>
<string name="dm_sender_reported_subtitle">Jemand, dem du folgst, hat dieses Konto gemeldet</string>
<string name="dm_sender_reported_icon">Gemeldet</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Fehler beim Laden der Antworten: "</string>
<string name="try_again">Erneut versuchen</string>
<string name="notification_feed_is_empty">Noch keine Benachrichtigungen.</string>
@@ -1735,6 +1745,9 @@
<string name="app_notification_zaps_channel_message_for">für %1$s</string>
<string name="app_notification_reply_label">Antworten</string>
<string name="app_notification_mark_read_label">Als gelesen markieren</string>
<string name="app_notification_me">Ich</string>
<string name="app_notification_new_message">Neue Nachricht</string>
<string name="app_notification_added_to_group">Du wurdest zu %1$s hinzugefügt</string>
<string name="app_notification_dms_summary">Neue Nachrichten</string>
<string name="app_notification_zaps_summary">Neue Zaps</string>
<string name="app_notification_reactions_channel_name">Reaktionen</string>
@@ -1756,6 +1769,47 @@
<string name="app_notification_mentions_channel_description">Benachrichtigt dich, wenn dich jemand in einem Beitrag erwähnt oder zitiert</string>
<string name="app_notification_mentions_channel_message">%1$s hat dich erwähnt</string>
<string name="app_notification_mentions_summary">Neue Erwähnungen</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<string name="app_notification_group_messages">Nachrichten</string>
<string name="app_notification_group_social">Soziales</string>
<string name="app_notification_group_payments">Zahlungen</string>
<string name="app_notification_group_content">Inhalt</string>
<string name="app_notification_group_developer">Entwickler</string>
<string name="app_notification_group_games">Spiele</string>
<!-- Reposts -->
<string name="app_notification_reposts_channel_description">Benachrichtigt dich, wenn jemand deinen Beitrag repostet</string>
<string name="app_notification_reposts_channel_message">%1$s hat deinen Beitrag repostet</string>
<string name="app_notification_reposts_summary">Neue Reposts</string>
<!-- Media (picture &amp; video posts that mention you) -->
<string name="app_notification_media_channel_name">Medien</string>
<string name="app_notification_media_channel_description">Benachrichtigt dich, wenn dich jemand in einem Foto oder Video erwähnt</string>
<string name="app_notification_media_channel_message_photo">%1$s hat ein Foto geteilt</string>
<string name="app_notification_media_channel_message_video">%1$s hat ein Video geteilt</string>
<string name="app_notification_media_summary">Neue Medien</string>
<!-- Articles &amp; highlights -->
<string name="app_notification_articles_channel_name">Artikel &amp; Hervorhebungen</string>
<string name="app_notification_articles_channel_description">Benachrichtigt dich, wenn dich jemand in einem Artikel erwähnt oder hervorhebt</string>
<string name="app_notification_articles_channel_message">%1$s hat dich in einem Artikel erwähnt</string>
<string name="app_notification_articles_channel_message_highlight">%1$s hat deinen Artikel hervorgehoben</string>
<string name="app_notification_articles_summary">Neue Artikel</string>
<!-- Code &amp; git -->
<string name="app_notification_code_channel_description">Benachrichtigt dich über Issues, Patches und Pull Requests</string>
<string name="app_notification_code_channel_message_issue">%1$s hat ein Issue eröffnet</string>
<string name="app_notification_code_channel_message_patch">%1$s hat einen Patch gesendet</string>
<string name="app_notification_code_channel_message_pr">%1$s hat einen Pull Request eröffnet</string>
<string name="app_notification_code_channel_message_pr_update">%1$s hat einen Pull Request aktualisiert</string>
<string name="app_notification_code_summary">Neue Code-Aktivität</string>
<!-- Badges -->
<string name="app_notification_badges_channel_name">Abzeichen</string>
<string name="app_notification_badges_channel_description">Benachrichtigt dich, wenn du ein Abzeichen erhältst</string>
<string name="app_notification_badges_channel_message">Du hast ein Abzeichen erhalten</string>
<string name="app_notification_badges_channel_message_from">Verliehen von %1$s</string>
<string name="app_notification_badges_summary">Neue Abzeichen</string>
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<string name="app_notification_nutzap_channel_message_from">Nutzap von %1$s</string>
<string name="app_notification_onchain_channel_message_from">Onchain-Zap von %1$s</string>
<!-- Poll mentions (rendered on the Mentions channel) -->
<string name="app_notification_poll_channel_message">%1$s hat eine Frage gestellt</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Eingehende Anrufe</string>
<string name="app_notification_calls_channel_description">Benachrichtigungen für eingehende Sprach- und Videoanrufe</string>
@@ -2006,6 +2060,11 @@
<string name="settings_section_appearance">Darstellung</string>
<string name="settings_section_media">Medien &amp; Daten</string>
<string name="settings_section_general">Allgemein</string>
<string name="settings_section_tor_engine">Verbindung</string>
<string name="settings_section_tor_routing">Über Tor leiten</string>
<string name="settings_section_profile_sections">Abschnitte &amp; Feeds</string>
<string name="settings_section_home_tabs">Sichtbare Tabs</string>
<string name="settings_section_reminders">Erinnerungen</string>
<string name="wallet_connect">Wallet Verbindung</string>
<string name="language">Sprache</string>
<string name="theme">Design</string>
@@ -2301,6 +2360,9 @@
<string name="relay_group_browse_description">Durchsuche die auf einem Relay gehosteten Gruppen. Füge eine Relay-Adresse ein oder wähle unten eine aus.</string>
<string name="relay_group_browse_relay_label">Relay-Adresse</string>
<string name="relay_group_browse_go">Durchsuchen</string>
<string name="relay_tor_clearnet_title">Dieses Relay ist über Tor nicht erreichbar</string>
<string name="relay_tor_clearnet_body">%1$s antwortet nicht über Tor — sein Host blockiert möglicherweise Tor-Exit-Nodes. Stattdessen über Clearnet verbinden?</string>
<string name="relay_tor_clearnet_action">Clearnet verwenden</string>
<string name="relay_group_browse_your_relays">Relays, auf denen du bist</string>
<string name="relay_group_browse_popular">Beliebte Relays</string>
<string name="relay_group_no_messages_yet">Noch keine Nachrichten</string>
@@ -3006,6 +3068,9 @@
<string name="chat_system_created_channel">%1$s hat den Kanal %2$s erstellt</string>
<string name="chat_system_created_channel_unnamed">%1$s hat den Kanal erstellt</string>
<string name="chat_system_updated_channel">%1$s hat das Kanalprofil aktualisiert</string>
<string name="relay_group_section_channels">Kanäle</string>
<string name="relay_group_section_forums">Foren</string>
<string name="relay_group_add_member">Mitglied hinzufügen</string>
<string name="chat_delivery_details_title">Nachrichtenzustellung</string>
<string name="close">Schließen</string>
<string name="chat_delivery_pending">Warte darauf, dass ein Relay diese Nachricht akzeptiert</string>
@@ -4021,6 +4086,11 @@
<string name="pow_notification_channel_name">Arbeitsnachweis-Schürfen</string>
<string name="pow_notification_channel_description">Zeigt Beiträge an, die noch ihren NIP-13-Arbeitsnachweis schürfen, damit sie fertig werden können, nachdem du die App verlässt.</string>
<string name="pow_notification_cancel_all">Abbrechen</string>
<string name="pow_notification_send_without_pow">Jetzt senden</string>
<string name="pow_cancel_dialog_title">Mining stoppen?</string>
<string name="pow_cancel_dialog_message">Dieser Beitrag berechnet noch seinen Proof of Work. Du kannst ihn jetzt ohne Proof of Work senden oder verwerfen.</string>
<string name="pow_cancel_dialog_send_without_pow">Ohne PoW senden</string>
<string name="pow_cancel_dialog_discard">Beitrag verwerfen</string>
<string name="pow_difficulty_estimate">≈ %1$s pro Beitrag auf diesem Gerät</string>
<string name="pow_estimate_instant">&lt;1 s</string>
<plurals name="pow_estimate_seconds">
@@ -297,6 +297,7 @@
<string name="chats_reply_not_found">Δεν βρέθηκε αυτό το μήνυμα</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Έγινε αναζήτηση σε κάθε relay · πάτα για να δεις</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Σφάλμα κατά τη φόρτωση απαντήσεων: "</string>
<string name="try_again">Προσπαθήστε ξανά</string>
<string name="notification_feed_is_empty">Δεν υπάρχουν ακόμα ειδοποιήσεις.</string>
@@ -1201,6 +1202,14 @@
<string name="app_notification_mentions_channel_description">Σας ειδοποιεί όταν κάποιος σας αναφέρει ή σας παραθέτει σε ανάρτηση</string>
<string name="app_notification_mentions_channel_message">Ο %1$s σας ανέφερε</string>
<string name="app_notification_mentions_summary">Νέες αναφορές</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Εισερχόμενες κλήσεις</string>
<string name="app_notification_calls_channel_description">Ειδοποιήσεις για εισερχόμενες φωνητικές και βιντεοκλήσεις</string>
@@ -13,6 +13,7 @@
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
<!-- A reply whose target was searched for across all reachable history and never found. -->
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
<!-- Napplet capability names -->
<!-- Napplet consent dialog -->
@@ -40,6 +41,14 @@
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<!-- Settings search -->
<!-- Settings search keywords: an English concept/protocol index that supplements the
@@ -300,6 +300,7 @@
<string name="chats_reply_not_found">Ne povis trovi tiun ĉi mesaĝon</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Serĉis ĉiun relay · tuŝetu por vidi</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Eraro ŝargante respondojn: "</string>
<string name="try_again">Provi denove</string>
<string name="notification_feed_is_empty">Ankoraŭ neniuj sciigoj.</string>
@@ -1217,6 +1218,14 @@
<string name="app_notification_mentions_channel_description">Sciigos vin kiam iu menciigas aŭ citas vin en afiŝo</string>
<string name="app_notification_mentions_channel_message">%1$s menciis vin</string>
<string name="app_notification_mentions_summary">Novaj mencioj</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Alvenantaj alvokoj</string>
<string name="app_notification_calls_channel_description">Sciigoj por alvenantaj voĉaj kaj vidaj alvokoj</string>
@@ -305,6 +305,7 @@
<string name="chats_reply_not_found">No se pudo encontrar este mensaje</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Se buscó en todos los relés (tocar para ver)</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Error al cargar las respuestas: "</string>
<string name="try_again">Intentar otra vez</string>
<string name="notification_feed_is_empty">Aún no hay notificaciones.</string>
@@ -1239,6 +1240,14 @@
<string name="app_notification_mentions_channel_description">Te notifica cuando alguien te menciona o te cita en una publicación</string>
<string name="app_notification_mentions_channel_message">%1$s te mencionó</string>
<string name="app_notification_mentions_summary">Nuevas menciones</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Llamadas entrantes</string>
<string name="app_notification_calls_channel_description">Notificaciones de llamadas de voz y vídeo entrantes</string>
@@ -305,6 +305,7 @@
<string name="chats_reply_not_found">No se pudo encontrar este mensaje</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Se buscó en todos los relés (tocar para ver)</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Error al cargar las respuestas: "</string>
<string name="try_again">Intentar de nuevo</string>
<string name="notification_feed_is_empty">Aún no hay notificaciones.</string>
@@ -1231,6 +1232,14 @@
<string name="app_notification_mentions_channel_description">Te notifica cuando alguien te menciona o te cita en una publicación</string>
<string name="app_notification_mentions_channel_message">%1$s te mencionó</string>
<string name="app_notification_mentions_summary">Nuevas menciones</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Llamadas entrantes</string>
<string name="app_notification_calls_channel_description">Notificaciones de llamadas de voz y vídeo entrantes</string>
@@ -305,6 +305,7 @@
<string name="chats_reply_not_found">No se pudo encontrar este mensaje</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Se buscó en todos los relés (tocar para ver)</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Error al cargar las respuestas: "</string>
<string name="try_again">Intentar de nuevo</string>
<string name="notification_feed_is_empty">Aún no hay notificaciones.</string>
@@ -1231,6 +1232,14 @@
<string name="app_notification_mentions_channel_description">Te notifica cuando alguien te menciona o te cita en una publicación</string>
<string name="app_notification_mentions_channel_message">%1$s te mencionó</string>
<string name="app_notification_mentions_summary">Nuevas menciones</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Llamadas entrantes</string>
<string name="app_notification_calls_channel_description">Notificaciones de llamadas de voz y vídeo entrantes</string>
@@ -303,6 +303,7 @@
<string name="chats_reply_not_found">این پیام پیدا نشد</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">همه relay ها جستجو شد · برای مشاهده ضربه بزنید</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">" :خطا در بارگیری پاسخ ها"</string>
<string name="try_again">دوباره تلاش کنید</string>
<string name="notification_feed_is_empty">هنوز هیچ اعلانی نیست.</string>
@@ -1219,6 +1220,14 @@
<string name="app_notification_mentions_channel_description">وقتی کسی شما را در پستی اشاره یا نقل‌قول می‌کند اطلاع می‌دهد</string>
<string name="app_notification_mentions_channel_message">%1$s شما را اشاره کرد</string>
<string name="app_notification_mentions_summary">اشاره‌های جدید</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">تماس‌های ورودی</string>
<string name="app_notification_calls_channel_description">اعلان‌های تماس‌های صوتی و تصویری ورودی</string>
@@ -295,6 +295,7 @@
<string name="chats_reply_not_found">Tätä viestiä ei löydy</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Haettu kaikista relay-palvelimista · napauta nähdäksesi</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Virhe ladattaessa vastauksia: "</string>
<string name="try_again">Yritä uudelleen</string>
<string name="notification_feed_is_empty">Ei ilmoituksia vielä.</string>
@@ -1202,6 +1203,14 @@
<string name="app_notification_mentions_channel_description">Ilmoittaa kun joku mainitsee tai siteeraa sinua julkaisussa</string>
<string name="app_notification_mentions_channel_message">%1$s mainitsi sinut</string>
<string name="app_notification_mentions_summary">Uusia mainintoja</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Saapuvat puhelut</string>
<string name="app_notification_calls_channel_description">Ilmoitukset saapuvista ääni- ja videopuheluista</string>
@@ -289,6 +289,7 @@
<string name="chats_reply_not_found">Impossible de trouver ce message</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Tous les relais consultés · appuyer pour voir</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Erreur lors du chargement des réponses : "</string>
<string name="try_again">Essayer à nouveau</string>
<string name="notification_feed_is_empty">Aucune notification pour le moment.</string>
@@ -1172,6 +1173,14 @@
<string name="app_notification_mentions_channel_description">Vous notifie lorsque quelqu\'un vous mentionne ou vous cite dans une publication</string>
<string name="app_notification_mentions_channel_message">%1$s vous a mentionné</string>
<string name="app_notification_mentions_summary">Nouvelles mentions</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Appels entrants</string>
<string name="app_notification_calls_channel_description">Notifications pour les appels vocaux et vidéos entrants</string>
@@ -305,6 +305,7 @@
<string name="chats_reply_not_found">Impossible de trouver ce message</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Tous les relais consultés · appuyer pour voir</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="error_loading_replies">"Erreur lors du chargement des réponses : "</string>
<string name="try_again">Essayer à nouveau</string>
<string name="notification_feed_is_empty">Aucune notification pour le moment.</string>
@@ -1364,6 +1365,14 @@
<string name="app_notification_mentions_channel_description">Vous notifie lorsque quelqu\'un vous mentionne ou vous cite dans une publication</string>
<string name="app_notification_mentions_channel_message">%1$s vous a mentionné</string>
<string name="app_notification_mentions_summary">Nouvelles mentions</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<!-- Reposts -->
<!-- Media (picture &amp; video posts that mention you) -->
<!-- Articles &amp; highlights -->
<!-- Code &amp; git -->
<!-- Badges -->
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<!-- Poll mentions (rendered on the Mentions channel) -->
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Appels entrants</string>
<string name="app_notification_calls_channel_description">Notifications pour les appels vocaux et vidéos entrants</string>
@@ -20,6 +20,10 @@
<item quantity="one">सं॰%1$d\n उत्तर</item>
<item quantity="other">सं॰%1$d\n उत्तर</item>
</plurals>
<plurals name="dm_sender_reported">
<item quantity="one">वृत्तान्त आपके द्वारा अनुचरित %1$d व्यक्ति से</item>
<item quantity="other">वृत्तान्त आपके द्वारा अनुचरित %1$d व्यक्तियों से</item>
</plurals>
<string name="post_not_found">घटना उपलब्ध की जा रही है अथवा आपके पुनःप्रसारक सूची में से प्राप्त नहीं</string>
<string name="post_not_found_short">देखें</string>
<string name="channel_image">प्रणाली चित्र</string>
@@ -339,6 +343,14 @@
<item quantity="one">%1$d प्रणाली</item>
<item quantity="other">%1$d प्रणालियाँ</item>
</plurals>
<plurals name="buzz_workspaces_count">
<item quantity="one">%1$d कार्यशाला</item>
<item quantity="other">%1$d कार्यशालाएँ</item>
</plurals>
<plurals name="buzz_workspaces_channel_count">
<item quantity="one">%1$d प्रणाली</item>
<item quantity="other">%1$d प्रणालियाँ</item>
</plurals>
<plurals name="concord_member_count">
<item quantity="one">%1$d सदस्य</item>
<item quantity="other">%1$d सदस्य</item>
@@ -406,6 +418,13 @@
<string name="chats_reply_not_found">यह सन्देश अप्राप्त</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">प्रत्येक पुनःप्रसारक में खोज लिया। देखने के लिए दबाएँ</string>
<string name="dm_sender_reported_who">किसने भेजा यह वृत्तान्त।</string>
<string name="dm_sender_reported_dismiss">हटाएँ</string>
<string name="dm_sender_reported_hide">छिपाएँ</string>
<string name="dm_sender_reported_subtitle">आपके द्वारा अनुचरित किसी ने इस लेखा पर संशयचिह्न लगाया</string>
<string name="dm_sender_reported_icon">वृत्तान्त भेज दिया</string>
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
<string name="dm_sender_reported_more_count">%1$dसं॰</string>
<string name="error_loading_replies">"प्रतिवचनों को प्राप्त करने में अपक्रम : "</string>
<string name="try_again">पुनः प्रयास करें</string>
<string name="notification_feed_is_empty">अभी तक कोई सूचनाएँ नहीं।</string>
@@ -1240,6 +1259,7 @@
<string name="nest_create_submit">स्थान आरम्भण</string>
<string name="nest_create_schedule_toggle">भविष्य समयनिर्धारण</string>
<string name="nest_create_when">आरम्भ समय का चयन करें</string>
<string name="agent_console_title">घटक कोष्ठक</string>
<string name="nests_servers_title">निलय सेवासंगणक</string>
<string name="nests_servers_explainer">चयन करें कि किन एमओक्यू॰ जालावास सेवासंगणकों पर अमेथिस्ट आपके निलयों का प्रकाशन करता है। प्रथम विकल्प का प्रयोग होगा मूलविकल्पतः जब आप एक नया स्थान आरम्भ करेंगे।</string>
<string name="nests_servers_my_section">आपके सेवासंगणक</string>
@@ -1735,6 +1755,9 @@
<string name="app_notification_zaps_channel_message_for">%1$s के लिए</string>
<string name="app_notification_reply_label">उत्तर</string>
<string name="app_notification_mark_read_label">पढा हुआ चिह्नित करें</string>
<string name="app_notification_me">मैं</string>
<string name="app_notification_new_message">नया सन्देश</string>
<string name="app_notification_added_to_group">आप %1$s में जोडे गए हैं</string>
<string name="app_notification_dms_summary">नए सन्देश</string>
<string name="app_notification_zaps_summary">नए ज्साप</string>
<string name="app_notification_reactions_channel_name">प्रतिक्रियाएँ</string>
@@ -1756,6 +1779,49 @@
<string name="app_notification_mentions_channel_description">आपको सूचित करता है जब किसी ने आपका उल्लेख अथवा उद्धरण किया हो</string>
<string name="app_notification_mentions_channel_message">%1$s ने आपका उल्लेख किया</string>
<string name="app_notification_mentions_summary">नए उल्लेख</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<string name="app_notification_group_messages">सन्देश</string>
<string name="app_notification_group_social">सामूहिक</string>
<string name="app_notification_group_payments">भुगतान</string>
<string name="app_notification_group_content">विषयवस्तु</string>
<string name="app_notification_group_developer">विकासकर्ता</string>
<string name="app_notification_group_games">खेल</string>
<!-- Reposts -->
<string name="app_notification_reposts_channel_name">पुनःप्रकाशन</string>
<string name="app_notification_reposts_channel_description">आपको सूचित करता है जब कोई आपके पत्र को पुनःप्रकाशित करते हैं</string>
<string name="app_notification_reposts_channel_message">%1$s ने आपके पत्र को पुनःप्रकाशित किया</string>
<string name="app_notification_reposts_summary">नए पुनःप्रकाशन</string>
<!-- Media (picture &amp; video posts that mention you) -->
<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>
<!-- Articles &amp; highlights -->
<string name="app_notification_articles_channel_name">निबन्ध तथा उद्दीप्तव्य</string>
<string name="app_notification_articles_channel_description">आपको सूचित करता है जब कोई आपका उल्लेख अथवा आपको उद्दीप्त करते हैं एक निबन्ध में</string>
<string name="app_notification_articles_channel_message">%1$s ने आपका उल्लेख किया एक निबन्ध में</string>
<string name="app_notification_articles_channel_message_highlight">%1$s ने आपके निबन्ध को उद्दीप्त किया</string>
<string name="app_notification_articles_summary">नए निबन्ध</string>
<!-- Code &amp; git -->
<string name="app_notification_code_channel_name">क्रमलेख तथा गिट॰</string>
<string name="app_notification_code_channel_description">आपको सूचित करता है विषय तथा सुधारलेख तथा कर्षण अनुरोधों पर</string>
<string name="app_notification_code_channel_message_issue">%1$s ने एक विषय खोला</string>
<string name="app_notification_code_channel_message_patch">%1$s ने एक सुधारलेख भेजा</string>
<string name="app_notification_code_channel_message_pr">%1$s ने एक कर्षण अनुरोध खोला</string>
<string name="app_notification_code_channel_message_pr_update">%1$s ने एक कर्षण अनुरोध का अद्यतन किया</string>
<string name="app_notification_code_summary">नई क्रमलेख गतिविधि</string>
<!-- Badges -->
<string name="app_notification_badges_channel_name">पदक</string>
<string name="app_notification_badges_channel_description">अपको सूचित करता है जब आप एक नया पदक प्राप्त करते हैं</string>
<string name="app_notification_badges_channel_message">आपने एक पदक प्राप्त किया</string>
<string name="app_notification_badges_channel_message_from">पुरस्कृत %1$s द्वारा</string>
<string name="app_notification_badges_summary">नए पदक</string>
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<string name="app_notification_nutzap_channel_message_from">नटज्साप॰ %1$s से</string>
<string name="app_notification_onchain_channel_message_from">खण्डश्रृंखलाबद्ध ज्साप %1$s से</string>
<!-- Poll mentions (rendered on the Mentions channel) -->
<string name="app_notification_poll_channel_message">%1$s ने प्रश्न पूछा</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">आगामी आह्वान</string>
<string name="app_notification_calls_channel_description">सूचनाएँ आगामी ध्वनि तथा दृश्य आह्वानों के लिए</string>
@@ -2306,6 +2372,9 @@
<string name="relay_group_browse_description">पुनःप्रसारक में उपलब्ध समूहों का वीक्षण करें। पुनःप्रसारक पता चिपकाएँ अथवा नीचे चयन करें।</string>
<string name="relay_group_browse_relay_label">पुनःप्रसारक पता</string>
<string name="relay_group_browse_go">वीक्षण</string>
<string name="relay_tor_clearnet_title">यह पुनःप्रसारक अभिगम्य नहीं टोर॰ पर</string>
<string name="relay_tor_clearnet_body">%1$s से कोई प्रत्युत्तर नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन स्थानों को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें।</string>
<string name="relay_tor_clearnet_action">स्पष्टजाल का उपयोग करें</string>
<string name="relay_group_browse_your_relays">पुनःप्रसारक जिन पर आप हैं</string>
<string name="relay_group_browse_popular">लोकप्रिय पुनःप्रसारक</string>
<string name="relay_group_no_messages_yet">कोई सन्देश नहीं अब तक</string>
@@ -3012,6 +3081,86 @@
<string name="chat_system_created_channel">%1$s ने %2$s प्रणाली बनाया</string>
<string name="chat_system_created_channel_unnamed">%1$s ने प्रणाली बनाया</string>
<string name="chat_system_updated_channel">%1$s ने प्रणाली परिचय का अद्यतन किया</string>
<string name="buzz_message_edited">(सम्पादित)</string>
<string name="buzz_diff_truncated">(अन्तर ह्रस्वकृत)</string>
<string name="buzz_canvas_title">चित्रपट</string>
<string name="buzz_canvas_empty">कोई चित्रपट बाँटा नहीं गया इस कार्यशाला में अब तक।</string>
<string name="buzz_canvas_edit">चित्रपट सम्पादन</string>
<string name="buzz_canvas_save">चित्रपट अभिलेखन</string>
<string name="buzz_canvas_body_label">चित्रपट (मार्कडौन॰)</string>
<string name="buzz_edit_message">सम्पादन</string>
<string name="buzz_editing_banner">सन्देश सम्पादन</string>
<string name="buzz_typing_one">%1$s टंकण मध्य…</string>
<string name="buzz_typing_two">%1$s तथा %2$s टंकण मध्य…</string>
<string name="buzz_typing_many">अनेक लोग टंकण मध्य…</string>
<string name="buzz_workspaces_title">बज्स्स कार्यशालाएँ</string>
<string name="buzz_workspaces_hero_tagline">जहाँ मनुष्य तथा घटक एक साथ निर्माण करते हैं</string>
<string name="buzz_workspaces_leave">कार्यशाला निर्गमन</string>
<string name="buzz_workspaces_section">आपके कार्यशालाएँ</string>
<string name="buzz_workspaces_empty_title">कोई कार्यशालाएँ नहीं अब तक</string>
<string name="buzz_workspaces_empty_body">बज्स्स कार्यशालाएँ पुनःप्रसारक हैं जहाँ मनुष्य तथा एआई॰ घटक एक साथ निर्माण करते हैं। एक के साथ जुडें उसे यहाँ देखने के लिए।</string>
<string name="buzz_workspaces_browse">समूहों का वीक्षण</string>
<string name="buzz_console_card_title">घटक कोष्ठक</string>
<string name="buzz_console_card_subtitle">परिव्यय। व्यक्तिशैलियाँ। वर्तमान अवलोकनकर्ता</string>
<string name="buzz_forum_new_title">नया मंच विषय</string>
<string name="buzz_forum_body_label">आप किस विषय पर चर्चा करना चाहते हैं।</string>
<string name="buzz_forum_post_action">विषय प्रकाशन</string>
<string name="buzz_forum_thread_title">मंच सूत्र</string>
<string name="buzz_forum_no_replies">कोई प्रत्युत्तर नहीं अब तक। सबसे पहले उत्तर देनेवाले बनें।</string>
<string name="buzz_dm_title">सीधे सन्देश</string>
<string name="buzz_dm_nav_label">बज्स्स सीधेसन्देश</string>
<string name="buzz_dm_card_subtitle">निजी वार्तालाप आपके कार्यशालाओं में</string>
<string name="buzz_dm_new">नया सन्देश</string>
<string name="buzz_dm_empty_title">कोई सीधेसन्देश नहीं अब तक</string>
<string name="buzz_dm_empty_body">आरम्भ करें निजी वार्तालप किसी से भी एक बज्स्स कार्यशाला में।</string>
<string name="buzz_dm_more">और अधिक</string>
<string name="buzz_dm_hide">संवाद छिपाएँ</string>
<string name="buzz_dm_add_member">सदस्य जोडें</string>
<string name="buzz_dm_add_member_title">किसी को जोडें इस सीधेसन्देश में</string>
<string name="buzz_dm_add_member_invalid">मान्य एनपुब॰ अथवा षोडषांक कुंचिका नहीं</string>
<string name="buzz_dm_just_you">केवल आप</string>
<string name="buzz_dm_workspace">कार्यशाला</string>
<string name="buzz_dm_recipients">इनको</string>
<string name="buzz_dm_add_hint">किसी को जोडें (एनपुब॰ अथवा षोडषांक)</string>
<string name="buzz_dm_start">वार्तालाप आरम्भ करें</string>
<string name="buzz_dm_opening">खोला जा रहा है…</string>
<string name="buzz_dm_remove">हटाएँ</string>
<string name="buzz_invite_title">कार्यशाला आमन्त्रण</string>
<string name="buzz_invite_heading">इस कार्यशाला के साथ जुडें</string>
<string name="buzz_invite_workspace">कार्यशाला</string>
<string name="buzz_invite_role">भूमिका</string>
<string name="buzz_invite_body">आप कार्यशाला को खोलेंगे एक सुरक्षित क्रमकाभ्यन्तर वीक्षक में इसके नियमों का पुनरीक्षण करने के लिए तथा जुडने की प्रक्रिया सम्पन्न करेंगे। यह आपकी अमेथिस्ट कुंचिका से प्रवेशांकन करेगा। कोई पारणशब्द नहीं।</string>
<string name="buzz_invite_continue">जालवीक्षक में आगे बढें</string>
<string name="buzz_invite_invalid">यह एक मान्य बज्स्स आमन्त्रण योजक जैसे नहीं दिखता।</string>
<string name="buzz_invite_expired">यह आमन्त्रण पुराना हो चुका है। एक नए के लिए पूछें।</string>
<string name="buzz_invite_after_browser">वीक्षक में नियमों को स्वीकार करना सम्पन्न करें। तत्पश्चात यहाँ लौट आएँ। आपकी कार्यशाला प्रतीक्षा कर रही होगी।</string>
<string name="buzz_invite_open_workspace">मेरे कार्यशालाओं को खोलें</string>
<string name="buzz_import_title">कार्यशाला प्रणालियों का आयात</string>
<string name="buzz_import_loading">आपके प्रणालियों का खोज…</string>
<string name="buzz_import_your_channels">आपके प्रणालियाँ इस कार्यशाला में</string>
<string name="buzz_import_add">जोडें</string>
<string name="buzz_import_add_all">सब को जोडें</string>
<string name="buzz_import_added">जोडा गया</string>
<string name="buzz_import_empty_title">कोई प्रणालियाँ प्राप्त नहीं</string>
<string name="buzz_import_empty_body">आप इस पुनःप्रसारक के किसी भी प्रणाली के सदस्य प्रतीत नहीं होते अब तक। पहले वीक्षक में कार्यशाला आमन्त्रण स्वीकार करें। तत्पश्चात पुनःप्रयास करें।</string>
<string name="relay_group_section_channels">प्रणालियाँ</string>
<string name="relay_group_section_forums">मंच</string>
<string name="buzz_agent_working">कार्य मध्य…</string>
<string name="relay_group_add_member">सदस्य जोडें</string>
<string name="buzz_community_add_people">इस कार्यशाला में लोगों को जोडें</string>
<string name="buzz_invite_create">आमन्त्रण योजक बनाएँ</string>
<string name="buzz_invite_link_title">आमन्त्रण योजक</string>
<string name="buzz_invite_share">बाँटें</string>
<string name="buzz_invite_copy">अनुकृति</string>
<string name="buzz_invite_error_title">आमन्त्रण बनाने में असफल</string>
<string name="buzz_invite_dismiss">ठीक</string>
<string name="buzz_star">प्रणाली ताराचिह्नित करें</string>
<string name="buzz_unstar">प्रणाली ताराचिह्न हटाएँ</string>
<string name="buzz_dm_section_empty">कोई संवाद नहीं अब तक</string>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">देखें %1$d और संवाद</item>
<item quantity="other">देखें सभी %1$d संवाद</item>
</plurals>
<string name="chat_delivery_details_title">सन्देश वितरण</string>
<string name="close">आवृत</string>
<string name="chat_delivery_pending">किसी एक पुनःप्रसारक द्वारा इस सन्देश की स्वीकृति की प्रतीक्षा</string>
@@ -4027,6 +4176,11 @@
<string name="pow_notification_channel_name">श्रमप्रमाण खनन</string>
<string name="pow_notification_channel_description">उन पत्रों को दिखाता है जो अपने निप॰१३ श्रमप्रमाण का खनन कर रहे हैं जिससे कि वे सम्पन्न हो सके आप क्रमक छोडने के पश्चात।</string>
<string name="pow_notification_cancel_all">निरस्त</string>
<string name="pow_notification_send_without_pow">अभी भेजें</string>
<string name="pow_cancel_dialog_title">क्या खनन रोक दें।</string>
<string name="pow_cancel_dialog_message">यह पत्र अभी भी अपना श्रमप्रमाण का खनन कर रहा है। आप इसे अभी भेज सकते हैं श्रमप्रमाण के बिना अथवा इसे हटा सकते हैं।</string>
<string name="pow_cancel_dialog_send_without_pow">श्रमप्रमाण बिना भेज दें</string>
<string name="pow_cancel_dialog_discard">पत्र हटाएँ</string>
<string name="pow_difficulty_estimate">लगभग %1$s प्रति पत्र इस यन्त्र पर</string>
<string name="pow_estimate_instant">१ से॰ से न्यून</string>
<plurals name="pow_estimate_seconds">

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