diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..9dce94ec97 --- /dev/null +++ b/.dockerignore @@ -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. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbb3d82f62..4a0da455f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/bump-homebrew-geode-formula.yml b/.github/workflows/bump-homebrew-geode-formula.yml new file mode 100644 index 0000000000..b55d926245 --- /dev/null +++ b/.github/workflows/bump-homebrew-geode-formula.yml @@ -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--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= --sha256=` 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'] + }); diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4236fa6955..3a10ca3e3c 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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--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---.. 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--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//geode: (+ :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. diff --git a/amethyst/plans/2026-07-23-push-notification-redesign.md b/amethyst/plans/2026-07-23-push-notification-redesign.md new file mode 100644 index 0000000000..2e0acac758 --- /dev/null +++ b/amethyst/plans/2026-07-23-push-notification-redesign.md @@ -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,// 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 + `` (`"%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. diff --git a/amethyst/plans/README.md b/amethyst/plans/README.md index ce70e7b511..86bf081302 100644 --- a/amethyst/plans/README.md +++ b/amethyst/plans/README.md @@ -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) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 284846799d..de4b90e7ef 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 707b98219d..2af6467ef4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 52f3045b5d..324abea1e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6a4b25e98a..0670af15fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = 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 = 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, + ): 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) { + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 01b4a46a1a..f90763b71d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 = 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>(20) - - fun cachedModificationEventsForNote(note: Note): List? = modificationCache[note.idHex] - - fun findLatestModificationForNote(note: Note): List { - 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) { 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt new file mode 100644 index 0000000000..a460743c5b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt @@ -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 { + 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 })) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt index f082fa1086..ab553c660f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt @@ -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()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt new file mode 100644 index 0000000000..cb7e3218d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 123a03e162..e01e7acdf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -20,12 +20,10 @@ */ package com.vitorpamplona.amethyst.service.notifications -import android.app.NotificationManager import android.content.Context import android.graphics.drawable.BitmapDrawable import android.os.PowerManager import android.os.SystemClock -import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.asDrawable import coil3.request.ImageRequest @@ -38,47 +36,49 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.call.notification.CallNotifier -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendMentionNotification -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReplyNotification -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ArticleNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.BadgeNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.BuzzDmNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ChessNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.CodeNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.DirectMessageNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.GroupMessageNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.MediaNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.MentionNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ReactionNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ReplyNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.RepostNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ZapNotification 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.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter -import com.vitorpamplona.amethyst.ui.stringRes +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 -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser 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.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji 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.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent +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 @@ -89,24 +89,31 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException private const val TAG = "EventNotificationConsumer" -private const val ACCOUNT_QUERY_PARAM = "?account=" -private const val SCROLL_TO_QUERY_PARAM = "&scrollTo=" +/** + * Turns a notification-relevant [Event] into a tray notification. Owns only the + * *policy* — account matching, the shared suppress-when-foreground / don't-notify- + * myself / muted-thread gates, and the routing of each kind to its renderer. + * The *presentation* (title, body, style, accent, observability) lives in the + * per-kind files under `renderers/`. + */ class EventNotificationConsumer( private val applicationContext: Context, /** Reports how long each notification-processing wakelock was held (resource-usage ledger). */ @@ -144,32 +151,15 @@ class EventNotificationConsumer( /** * Entry point for notification-relevant events arriving into [LocalCache] * from any source (FCM push, UnifiedPush, Pokey, active relay subscriptions, - * NotificationRelayService). The [NotificationDispatcher] only invokes this - * after [Account.newNotesPreProcessor] has fully unwrapped wraps and seals, - * so this method receives the final inner payload directly. - * - * Matches the event to a logged-in account by its `p` tags and dispatches - * to [dispatchForAccount]. + * NotificationRelayService). Matches the event to a logged-in account by its + * `p` tags and dispatches to [dispatchForAccount]. */ suspend fun consumeFromCache(event: Event) = withWakeLock { Log.d(TAG) { "New Notification from cache: kind=${event.kind} id=${event.id}" } - if (!notificationManager().areNotificationsEnabled()) return@withWakeLock + if (!applicationContext.notificationManager().areNotificationsEnabled()) return@withWakeLock - // Match the in-app Notifications feed exactly: the event must - // p-tag the account AND pass NotificationFeedFilter.tagsAnEventByUser - // — the latter is the per-kind "is this actually for me" rule - // (reply-to-me, citation of my post, fork of my content, community - // moderation, reaction targeting my note, etc.). WakeUpEvent is the - // one exception: its [Event.notifies] is `true` unconditionally so - // every signed-in account processes it (the dispatcher bypasses - // the same way). - // - // One LocalCache lookup per event regardless of account count — - // the note is the same for every saved account. Use the Event - // overload so AddressableEvent kinds resolve to their replaceable - // note (id-keyed version has empty replyTo after insertion). val matchingNote: Note? = if (event is WakeUpEvent) { null @@ -182,9 +172,6 @@ class EventNotificationConsumer( val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach if (matchingNote != null) { - // Public chat replies into my messages often omit the `p` - // tag; relax the gate for them (tagsAnEventByUser keeps it - // scoped to messages actually replying to me). val taggedOrPublicChatReply = event.isTaggedUser(accountHex) || NotificationFeedFilter.isNotifiablePublicChatReply(matchingNote, accountHex) @@ -213,9 +200,6 @@ class EventNotificationConsumer( account: Account, ) { // Calls and wake-ups are high-priority and always notify, even when MainActivity is visible. - // They have their own freshness rules (CallManager.MAX_EVENT_AGE_SECONDS = 20s) and - // author-identity semantics (caller pubkey is the other party), so they bypass the - // shared gates below. when (event) { is CallOfferEvent -> { notifyIncomingCall(event, account) @@ -231,80 +215,150 @@ class EventNotificationConsumer( // Everything else is suppressed while the user is actively on the home screen. if (MainActivity.isResumed) return - // Shared per-account gate: don't push-notify events this account authored. - // Applied here (not at the observer) because in a multi-account session - // account A's outgoing event legitimately becomes account B's incoming - // notification on the same device. The observer already enforces the - // 15-min rolling age window, so individual notify() methods don't need - // to repeat either check. + // Don't push-notify events this account authored. if (event.pubKey == account.signer.pubKey) return - // Mirror NotificationFeedFilter: reactions and zaps target a note via - // `replyTo`, not via thread-root tags on the wrapper event, so checking - // the wrapper's own thread misses them. Resolve the target and drop if - // its thread is muted. (The feed extends this to reposts too, but - // RepostEvent / GenericRepostEvent aren't routed below — push doesn't - // notify on reposts at all today.) - if (event is ReactionEvent || event is LnZapEvent) { + // Drop reactions/zaps/reposts whose target note lives on a muted thread + // (matches the in-app feed, which mutes all four). + if (event is ReactionEvent || event is LnZapEvent || event is RepostEvent || event is GenericRepostEvent) { val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull() if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return } when (event) { - is PrivateDmEvent -> notify(event, account) + is PrivateDmEvent -> DirectMessageNotification.notify(applicationContext, account, event) + is ChatMessageEvent -> DirectMessageNotification.notify(applicationContext, account, event) + is ChatMessageEncryptedFileHeaderEvent -> DirectMessageNotification.notify(applicationContext, account, event) - is LnZapEvent -> notify(event, account) + // Buzz DM messages (participant-routed; the predicate already scoped + // these kinds to Buzz DMs addressed to me). + is StreamMessageV2Event -> BuzzDmNotification.notify(applicationContext, account, event) + is ChatEvent -> BuzzDmNotification.notify(applicationContext, account, event) - is ChatMessageEvent -> notify(event, account) + is LnZapEvent -> ZapNotification.notify(applicationContext, account, event) + is NutzapEvent -> ZapNotification.notify(applicationContext, account, event) + is OnchainZapEvent -> ZapNotification.notify(applicationContext, account, event) - is ChatMessageEncryptedFileHeaderEvent -> notify(event, account) + is ReactionEvent -> ReactionNotification.notify(applicationContext, account, event) - is ReactionEvent -> notify(event, account) + is RepostEvent -> RepostNotification.notify(applicationContext, account, event) + is GenericRepostEvent -> RepostNotification.notify(applicationContext, account, event) - is TextNoteEvent -> notify(event, account) + is BadgeAwardEvent -> BadgeNotification.notify(applicationContext, account, event) - is CommentEvent -> notify(event, account) - - is ChannelMessageEvent -> notify(event, account) + is TextNoteEvent -> notifyTextNote(event, account) + is CommentEvent -> notifyComment(event, account) + is ChannelMessageEvent -> notifyChannelMessage(event, account) is PictureEvent, is VideoNormalEvent, is VideoShortEvent, is VideoHorizontalEvent, is VideoVerticalEvent, - is PollEvent, - is GitPatchEvent, - is GitIssueEvent, - is GitPullRequestEvent, - is GitPullRequestUpdateEvent, + -> MediaNotification.notify(applicationContext, account, event) + + is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = R.string.app_notification_poll_channel_message) + is HighlightEvent, is LongTextNoteEvent, is WikiNoteEvent, - -> notifyMention(event, account) + -> ArticleNotification.notify(applicationContext, account, event) - is LiveChessGameAcceptEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_challenge_accepted) + is GitIssueEvent -> CodeNotification.notify(applicationContext, account, event) + is GitPatchEvent -> CodeNotification.notify(applicationContext, account, event) + is GitPullRequestEvent -> CodeNotification.notify(applicationContext, account, event) + is GitPullRequestUpdateEvent -> CodeNotification.notify(applicationContext, account, event) - is LiveChessMoveEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_your_turn) - // WelcomeEvent is dispatched directly from processMarmotWelcomeFlow - // (no `p` tag, so tag-based matching doesn't work). + is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted) + is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn) } } - suspend fun wakeUpFor( + // Reply-vs-mention decisions are source-kind-specific, so they stay in the + // dispatcher; the actual rendering is in ReplyNotification / MentionNotification. + + private suspend fun notifyTextNote( + event: TextNoteEvent, + account: Account, + ) { + val replyTargetId = event.replyingTo() + if (replyTargetId != null) { + val repliedNote = LocalCache.getNoteIfExists(replyTargetId) + if (repliedNote?.author?.pubkeyHex == account.signer.pubKey) { + val threadRoot = event.markedRoot()?.eventId ?: event.unmarkedRoot()?.eventId ?: replyTargetId + ReplyNotification.notify(applicationContext, account, event, repliedNote, threadRoot) + return + } + } + MentionNotification.notify(applicationContext, account, event) + } + + private suspend fun notifyComment( + event: CommentEvent, + account: Account, + ) { + val pubKey = account.signer.pubKey + val isTarget = event.replyAuthorKeys().contains(pubKey) || event.rootAuthorKeys().contains(pubKey) + if (!isTarget) return + + val parentNote = event.replyingTo()?.let { LocalCache.getNoteIfExists(it) } + val threadRoot = + event.rootEventIds().firstOrNull() + ?: event.rootAddressIds().firstOrNull() + ?: event.replyingToAddressOrEvent() + ?: event.id + + ReplyNotification.notify(applicationContext, account, event, parentNote, threadRoot) + } + + private suspend fun notifyChannelMessage( + event: ChannelMessageEvent, + account: Account, + ) { + val note = LocalCache.getNoteIfExists(event.id) ?: return + + if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) { + val parentNote = note.replyTo?.lastOrNull() + val threadRoot = event.channelId() ?: event.id + ReplyNotification.notify(applicationContext, account, event, parentNote, threadRoot) + } else { + MentionNotification.notify(applicationContext, account, event) + } + } + + // --------------------------------------------------------------------- + // Directly-dispatched Marmot events (no `p` tag → routed by the caller) + // --------------------------------------------------------------------- + + suspend fun notifyWelcome( + event: WelcomeEvent, + account: Account, + ) = withWakeLock { + GroupMessageNotification.notifyWelcome(applicationContext, account, event) + } + + suspend fun notifyGroupMessage( + innerEvent: ChatEvent, + nostrGroupId: String, + account: Account, + ) = withWakeLock { + GroupMessageNotification.notifyGroupMessage(applicationContext, account, innerEvent, nostrGroupId) + } + + // --------------------------------------------------------------------- + // Calls & wake-ups keep their bespoke, high-priority handling here. + // --------------------------------------------------------------------- + + private suspend fun wakeUpFor( event: WakeUpEvent, account: Account, ) { - // A WakeUp's whole purpose is the events it references. If it carries - // none, there's nothing to fetch — skip the 30s subscription window. val referencedTags = event.events().distinctBy { it.eventId } if (referencedTags.isEmpty()) { Log.d(TAG) { "WakeUp ${event.id} has no referenced events — skipping" } return } - // Per spec, p-tags on a WakeUp are the authors of the referenced - // events; those are whose metadata we need to render the notification. - // Fall back to e-tag author hints and finally to the WakeUp signer. val referencedNotes = referencedTags.map { LocalCache.getOrCreateNote(it.eventId) } val authorCandidates = (event.authorKeys() + referencedTags.mapNotNull { it.author }) @@ -313,7 +367,6 @@ class EventNotificationConsumer( .map { LocalCache.getOrCreateUser(it) } coroutineScope { - // keeps the relay connection active for 30 seconds. launch { try { withTimeout(WAKEUP_WINDOW_MS) { @@ -325,9 +378,6 @@ class EventNotificationConsumer( } } - // keeps subscriptions active for 30 seconds so EventFinder can pull - // the referenced events from relays and UserFinder can resolve the - // referenced authors' metadata. launch { val accountState = ScreenAuthAccount(account) val eventStates = referencedNotes.map { EventFinderQueryState(it, account) } @@ -359,717 +409,15 @@ class EventNotificationConsumer( } } - private suspend fun notify( - event: ChatMessageEncryptedFileHeaderEvent, - account: Account, - ) { - Log.d(TAG, "New ChatMessage File to Notify") - // Age + self-author gates run centrally in dispatchForAccount. - val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey) - val chatNote = LocalCache.getNoteIfExists(event.id) ?: return - val chatRoom = event.chatroomKey(account.signer.pubKey) - - val followingKeySet = account.followingKeySet() - - val isKnownRoom = - chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || - chatroomList.hasSentMessagesTo(chatRoom) - - if (!isKnownRoom) return - - val content = chatNote.event?.content ?: "" - val user = chatNote.author?.toBestDisplayName() ?: "" - val userPicture = chatNote.author?.profilePicture() - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - val chatroomMembers = chatRoom.users.joinToString(",") - val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - - notificationManager() - .sendDMNotification( - event.id, - content, - user, - event.createdAt, - userPicture, - noteUri, - applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = chatroomMembers, - ) - } - - private suspend fun notify( - event: ChatMessageEvent, - account: Account, - ) { - Log.d(TAG, "New ChatMessage to Notify") - // Age + self-author gates run centrally in dispatchForAccount. - val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey) - val chatNote = LocalCache.getNoteIfExists(event.id) ?: return - val chatRoom = event.chatroomKey(account.signer.pubKey) - - val followingKeySet = account.followingKeySet() - - val isKnownRoom = - chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || - chatroomList.hasSentMessagesTo(chatRoom) - - if (!isKnownRoom) return - - val content = chatNote.event?.content ?: "" - val user = chatNote.author?.toBestDisplayName() ?: "" - val userPicture = chatNote.author?.profilePicture() - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - val chatroomMembers = chatRoom.users.joinToString(",") - val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - - notificationManager() - .sendDMNotification( - id = event.id, - messageBody = content, - senderName = user, - time = event.createdAt, - pictureUrl = userPicture, - uri = noteUri, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = chatroomMembers, - ) - } - - private suspend fun notify( - event: PrivateDmEvent, - account: Account, - ) { - Log.d(TAG, "New Nip-04 DM to Notify") - // Age + self-author gates run centrally in dispatchForAccount. The - // dispatchForAccount self-check (event.pubKey != account.signer.pubKey) - // also covers the "don't notify myself about DMs I sent" case that - // was previously implicit via the recipient match below. - if (account.signer.pubKey != event.verifiedRecipientPubKey()) return - - val note = LocalCache.getNoteIfExists(event.id) ?: return - val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey) - - val followingKeySet = account.followingKeySet() - - val chatRoom = event.chatroomKey(account.signer.pubKey) - - val isKnownRoom = - chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || - chatroomList.hasSentMessagesTo(chatRoom) - - if (!isKnownRoom) return - - val author = note.author ?: return - val content = decryptContent(note, account.signer) ?: return - val user = author.toBestDisplayName() - val userPicture = author.profilePicture() - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - - notificationManager() - .sendDMNotification( - id = event.id, - messageBody = content, - senderName = user, - time = event.createdAt, - pictureUrl = userPicture, - uri = noteUri, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = null, - ) - } - - /** - * Welcomes have no `p` tag, so [consumeFromCache]'s tag-based account match - * can't route them. They are instead dispatched here directly by - * [com.vitorpamplona.amethyst.ui.screen.loggedIn.processMarmotWelcomeFlow] - * after [MarmotManager.processWelcome] joins the group — which is also the - * only place we reliably know which account the invite was for. - */ - suspend fun notifyWelcome( - event: WelcomeEvent, - account: Account, - ) = withWakeLock { - Log.d(TAG, "New Marmot Welcome to Notify") - - if (!notificationManager().areNotificationsEnabled()) return@withWakeLock - if (MainActivity.isResumed) return@withWakeLock - - // old event being re-broadcast - if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return@withWakeLock - // a welcome we ourselves emitted - if (event.pubKey == account.signer.pubKey) return@withWakeLock - - val nostrGroupId = event.nostrGroupId() ?: return@withWakeLock - - val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) - val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: "a private group" - val inviter = LocalCache.getOrCreateUser(event.pubKey) - val inviterName = inviter.toBestDisplayName() - val inviterPicture = inviter.profilePicture() - - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - // marmot:?account= — parsed by uriToRoute below. - val noteUri = "marmot:$nostrGroupId$ACCOUNT_QUERY_PARAM$accountNpub" - - notificationManager() - .sendDMNotification( - id = event.id, - messageBody = "You've been added to $groupName", - senderName = inviterName, - time = event.createdAt, - pictureUrl = inviterPicture, - uri = noteUri, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = null, - ) - } - - /** - * Marmot kind:445 group messages have no `p` tag (recipients are routed - * by the `h` tag carrying the nostr_group_id), so the cache-observer path - * in [NotificationDispatcher] can't match them to an account. They're - * dispatched here directly from [com.vitorpamplona.amethyst.ui.screen.loggedIn.GroupEventHandler] - * once [com.vitorpamplona.quartz.marmot.MarmotInboundProcessor] has - * decrypted the outer ChaCha20-Poly1305 layer and verified the inner - * MLS-signed payload. - * - * Typed to [ChatEvent] so the caller has to narrow first — reactions, - * control messages, and deletions stay silent at the type level, - * mirroring how NIP-17 (kind:14) is the only DM kind we notify. - */ - suspend fun notifyGroupMessage( - innerEvent: ChatEvent, - nostrGroupId: String, - account: Account, - ) = withWakeLock { - Log.d(TAG, "New Marmot Group Message to Notify") - - if (!notificationManager().areNotificationsEnabled()) return@withWakeLock - if (MainActivity.isResumed) return@withWakeLock - - // old event being re-broadcast - if (innerEvent.createdAt < TimeUtils.fifteenMinutesAgo()) return@withWakeLock - // a message we ourselves sent - if (innerEvent.pubKey == account.signer.pubKey) return@withWakeLock - - val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) - val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: "Private group" - val sender = LocalCache.getOrCreateUser(innerEvent.pubKey) - val senderName = sender.toBestDisplayName() - val senderPicture = sender.profilePicture() - // Defensive fallback for the rare empty-content ChatEvent so the - // popup is still actionable. Non-chat inner kinds were filtered - // out at the call site by the ChatEvent type narrowing. - val body = innerEvent.content.takeIf { it.isNotBlank() } ?: "New message" - - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - // marmot:?account= — same scheme as notifyWelcome, - // taps deep-link straight to the group's chatroom. - val noteUri = "marmot:$nostrGroupId$ACCOUNT_QUERY_PARAM$accountNpub" - - notificationManager() - .sendDMNotification( - id = innerEvent.id, - messageBody = "$senderName: $body", - senderName = groupName, - time = innerEvent.createdAt, - pictureUrl = senderPicture, - uri = noteUri, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = null, - marmotNostrGroupId = nostrGroupId, - marmotReplyToInnerEventId = innerEvent.id, - marmotReplyToInnerAuthor = innerEvent.pubKey, - ) - } - - 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? { - val event = note.event - return when (event) { - is PrivateDmEvent -> { - event.decryptContent(signer) - } - - is LnZapRequestEvent -> { - decryptZapContentAuthor(event, signer)?.content - } - - else -> { - event?.content - } - } - } - - private suspend fun notify( - event: LnZapEvent, - account: Account, - ) { - Log.d(TAG, "New Zap to Notify") - Log.d(TAG) { "Notify Start ${event.toNostrUri()}" } - LocalCache.getNoteIfExists(event.id) ?: return - - // Age + self-author gates run centrally in dispatchForAccount. For zaps - // the self-check is effectively a no-op (receipts are signed by the LN - // service, not the zapper) but the uniform rule is cheap and keeps the - // downstream invariants simple. - val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - - Log.d(TAG) { "Notify ZapRequest $noteZapRequest zapped $noteZapped" } - - // Drop zaps on muted threads, hidden authors, etc. - if (!account.isAcceptable(noteZapped)) return - - if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return - - Log.d(TAG, "Notify Amount Bigger than 10") - - // Zap routing (recipient == account) is enforced by the dispatcher - // predicate + consumeFromCache via Event.notifies; no re-check here. - val amount = showAmount(event.amount) - - Log.d(TAG) { "Notify Amount $amount" } - - (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> - decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent -> - Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" } - - val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey) - val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null }) - - if (noteZapped.event?.content != null) { - decryptContent(noteZapped, account.signer)?.let { decrypted -> - Log.d(TAG, "Notify Decrypted if Private Note") - - val zappedContent = decrypted.split("\n")[0] - - val user = senderInfo.first.toBestDisplayName() - var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount) - senderInfo.second?.ifBlank { null }?.let { title += " ($it)" } - - var content = - stringRes( - applicationContext, - R.string.app_notification_zaps_channel_message_from, - user, - ) - zappedContent.let { - content += - " " + - stringRes( - applicationContext, - R.string.app_notification_zaps_channel_message_for, - zappedContent, - ) - } - val userPicture = senderInfo.first.profilePicture() - val noteUri = - "notifications$ACCOUNT_QUERY_PARAM" + - account.signer.pubKey - .hexToByteArray() - .toNpub() + - SCROLL_TO_QUERY_PARAM + event.id - - Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" } - - notificationManager() - .sendZapNotification( - event.id, - content, - title, - event.createdAt, - userPicture, - noteUri, - applicationContext, - ) - } - } else { - // doesn't have a base note to refer to. - Log.d(TAG, "Notify Zapped note not available") - - val user = senderInfo.first.toBestDisplayName() - var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount) - senderInfo.second?.ifBlank { null }?.let { title += " ($it)" } - - val content = - stringRes( - applicationContext, - R.string.app_notification_zaps_channel_message_from, - user, - ) - - val userPicture = senderInfo.first.profilePicture() - val noteUri = - "notifications$ACCOUNT_QUERY_PARAM" + - account.signer.pubKey - .hexToByteArray() - .toNpub() + - SCROLL_TO_QUERY_PARAM + event.id - - Log.d(TAG) { "Notify ${event.id} $title $noteUri" } - - notificationManager() - .sendZapNotification( - event.id, - content, - title, - event.createdAt, - userPicture, - noteUri, - applicationContext, - ) - } - } - } - } - - private suspend fun notify( - event: ReactionEvent, - account: Account, - ) { - Log.d(TAG, "New Reaction to Notify") - - // Age + self-author gates run centrally in dispatchForAccount. - // p-tag match already enforced by consumeFromCache; no redundant - // isTaggedUser re-check needed. - - // NIP-25: when a reaction carries multiple `e` tags (e.g. both the - // thread root and the replied-to note), the LAST one is the event - // actually being reacted to. Using the first tag would surface the - // root in the tray when the like was really on a reply. This matches - // the in-app card, which resolves the target via replyTo.lastOrNull(). - val reactedPostId = event.originalPost().lastOrNull() ?: return - val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) - - // Drop reactions on muted threads, hidden authors, etc. - if (reactedNote != null && !account.isAcceptable(reactedNote)) return - - val author = LocalCache.getOrCreateUser(event.pubKey) - val user = author.toBestDisplayName() - val userPicture = author.profilePicture() - - val reactionContent = event.content - - // NIP-30 custom emoji: content is ":shortcode:" backed by an ["emoji", shortcode, url] - // tag. The image can't be rendered as text, so it is surfaced as a badge on the author's - // avatar (see sendReactionNotification) and the title keeps just the author's name. - val customEmojiUrl = CustomEmoji.createEmojiMap(event.tags)[reactionContent] - - val title = - if (customEmojiUrl != null) { - user - } else { - val reactionSymbol = - when { - reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19" - reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E" - else -> reactionContent - } - "$reactionSymbol $user" - } - - val reactedContent = - reactedNote - ?.event - ?.content - ?.split("\n") - ?.firstOrNull() ?: "" - - val content = - if (reactedContent.isNotBlank()) { - stringRes( - applicationContext, - R.string.app_notification_reactions_channel_message_for, - reactedContent, - ) - } else { - stringRes( - applicationContext, - R.string.app_notification_reactions_channel_message, - user, - ) - } - - val noteUri = - "notifications$ACCOUNT_QUERY_PARAM" + - account.signer.pubKey - .hexToByteArray() - .toNpub() + - SCROLL_TO_QUERY_PARAM + event.id - - notificationManager() - .sendReactionNotification( - event.id, - content, - title, - event.createdAt, - userPicture, - noteUri, - applicationContext, - emojiUrl = customEmojiUrl, - ) - } - - private suspend fun notify( - event: TextNoteEvent, - account: Account, - ) { - Log.d(TAG, "New TextNote to Notify") - // Age + self-author gates run centrally in dispatchForAccount. - - val replyTargetId = event.replyingTo() - - if (replyTargetId != null) { - val repliedNote = LocalCache.getNoteIfExists(replyTargetId) - if (repliedNote?.author?.pubkeyHex == account.signer.pubKey) { - val threadRoot = event.markedRoot()?.eventId ?: event.unmarkedRoot()?.eventId ?: replyTargetId - notifyReply(event, account, repliedNote.event?.content, threadRoot) - return - } - } - - // Not a reply to us but we're p-tagged — a mention or citation. - notifyMention(event, account) - } - - private suspend fun notify( - event: CommentEvent, - account: Account, - ) { - Log.d(TAG, "New NIP-22 Comment to Notify") - // Age + self-author gates run centrally in dispatchForAccount. - - // NIP-22 marks direct-reply and root authors. Notify when the current - // account is either (someone commenting on our post, or replying to our comment). - val pubKey = account.signer.pubKey - val isTarget = event.replyAuthorKeys().contains(pubKey) || event.rootAuthorKeys().contains(pubKey) - if (!isTarget) return - - val parentContent = - event - .replyingTo() - ?.let { LocalCache.getNoteIfExists(it)?.event?.content } - - val threadRoot = - event.rootEventIds().firstOrNull() - ?: event.rootAddressIds().firstOrNull() - ?: event.replyingToAddressOrEvent() - ?: event.id - - notifyReply(event, account, parentContent, threadRoot) - } - - private suspend fun notify( - event: ChannelMessageEvent, - account: Account, - ) { - Log.d(TAG, "New Public Chat Message to Notify") - // Age + self-author gates run centrally in dispatchForAccount. - val note = LocalCache.getNoteIfExists(event.id) ?: return - - // A reply into one of my messages in this channel — even when the - // sender didn't p-tag me. Render it as a reply (threaded + inline - // reply action) grouped by channel so a busy room collapses into one - // notification instead of spamming. Falls back to a plain mention when - // I'm only p-tagged (a citation, not a reply to my message). - if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) { - val parentContent = - note.replyTo - ?.lastOrNull() - ?.event - ?.content - val threadRoot = event.channelId() ?: event.id - notifyReply(event, account, parentContent, threadRoot) - } else { - notifyMention(event, account) - } - } - - private suspend fun notifyReply( - event: Event, - account: Account, - parentContent: String?, - threadRootId: String, - ) { - val replyNote = LocalCache.getNoteIfExists(event.id) ?: return - - // Drop events from muted threads, hidden authors, etc. - if (!account.isAcceptable(replyNote)) return - - val author = LocalCache.getOrCreateUser(event.pubKey) - val user = author.toBestDisplayName() - val userPicture = author.profilePicture() - - val title = stringRes(applicationContext, R.string.app_notification_replies_channel_message, user) - - val replyExcerpt = - event.content - .split("\n") - .firstOrNull { it.isNotBlank() } - ?.take(280) - ?: "" - - val parentExcerpt = - parentContent - ?.split("\n") - ?.firstOrNull { it.isNotBlank() } - ?.take(140) - - val content = - if (!parentExcerpt.isNullOrBlank()) { - replyExcerpt + "\n\n" + - stringRes( - applicationContext, - R.string.app_notification_replies_channel_message_for, - parentExcerpt, - ) - } else { - replyExcerpt - } - - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - val noteUri = replyNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - - notificationManager() - .sendReplyNotification( - id = event.id, - messageBody = content, - messageTitle = title, - time = event.createdAt, - pictureUrl = userPicture, - uri = noteUri, - applicationContext = applicationContext, - threadRootId = threadRootId, - inlineReply = InlineReplyTarget(accountNpub = accountNpub, targetEventId = event.id), - ) - } - - private suspend fun notifyMention( - event: Event, - account: Account, - ) { - // Age + self-author gates run centrally in dispatchForAccount. - val note = LocalCache.getNoteIfExists(event.id) ?: return - - // Drop events from muted threads, hidden authors, etc. - if (!account.isAcceptable(note)) return - - val author = LocalCache.getOrCreateUser(event.pubKey) - val user = author.toBestDisplayName() - val userPicture = author.profilePicture() - - val title = stringRes(applicationContext, R.string.app_notification_mentions_channel_message, user) - - val content = - event.content - .split("\n") - .firstOrNull { it.isNotBlank() } - ?.take(280) - ?: "" - - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - - notificationManager() - .sendMentionNotification( - id = event.id, - messageBody = content, - messageTitle = title, - time = event.createdAt, - pictureUrl = userPicture, - uri = noteUri, - applicationContext = applicationContext, - ) - } - - private suspend fun notifyChessEvent( - event: BaseChessEvent, - account: Account, - contentStringRes: Int, - ) { - // Age + self-author gates run centrally in dispatchForAccount. - val author = LocalCache.getOrCreateUser(event.pubKey) - val user = author.toBestDisplayName() - val userPicture = author.profilePicture() - val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name) - val content = stringRes(applicationContext, contentStringRes, user) - val noteUri = - "notifications$ACCOUNT_QUERY_PARAM" + - account.signer.pubKey - .hexToByteArray() - .toNpub() + - SCROLL_TO_QUERY_PARAM + event.id - - notificationManager() - .sendChessNotification( - event.id, - content, - title, - event.createdAt, - userPicture, - noteUri, - applicationContext, - ) - } - private suspend fun notifyIncomingCall( event: CallOfferEvent, account: Account, ) { if (!account.isFollowing(event.pubKey)) return - if (TimeUtils.now() - event.createdAt > CallManager.MAX_EVENT_AGE_SECONDS) return val callerUser = LocalCache.getOrCreateUser(event.pubKey) - // If the caller's metadata hasn't been loaded yet (e.g. fresh process from - // a push notification), briefly subscribe to the user finder so we can - // resolve the user's display name instead of showing the raw pubkey. if (callerUser.metadataOrNull()?.bestName() == null) { val authorState = UserFinderQueryState(callerUser, account) try { @@ -1091,7 +439,7 @@ class EventNotificationConsumer( val callerBitmap = callerUser.profilePicture()?.let { pictureUrl -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + withContext(Dispatchers.IO) { try { val request = ImageRequest @@ -1113,8 +461,4 @@ class EventNotificationConsumer( applicationContext = applicationContext, ) } - - fun notificationManager(): NotificationManager = - ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) - as NotificationManager } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt new file mode 100644 index 0000000000..83c09331d3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt index 945481bc13..7838e8e11d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt @@ -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 = - 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt new file mode 100644 index 0000000000..131bf60f0e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt @@ -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, + ) + + /** + * Like [excerpt], but rewrites inline user mentions to readable names: each + * `nostr:npub1…` / `nostr:nprofile1…` (optionally `@`-prefixed) becomes + * `@` 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() + 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, + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 47dd5258d4..9deda637b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationEnricher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationEnricher.kt new file mode 100644 index 0000000000..94485ecfea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationEnricher.kt @@ -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, + notes: Collection, + 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, + notes: Collection, + 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 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() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt new file mode 100644 index 0000000000..acf98d2cd0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 1f5eb63239..67fed14b67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -21,20 +21,23 @@ package com.vitorpamplona.amethyst.service.notifications import android.app.Notification -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.BitmapShader import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF +import android.graphics.Shader import android.graphics.drawable.BitmapDrawable +import android.os.SystemClock import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.RemoteInput +import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import coil3.SingletonImageLoader @@ -47,22 +50,22 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min +/** + * Low-level tray-notification builder. Every per-kind renderer funnels through + * [postStandard] (BigText / BigPicture) or [postConversation] (MessagingStyle), + * both driven by a [NotificationCategory] that supplies the channel, accent + * color, status-bar icon, group, and summary. + * + * Notifications are keyed by `id.hashCode()` (the triggering event id) so that + * (a) reading the underlying post in-app clears the tray entry via + * [dismissNotificationForEvent], and (b) re-posting the same id from the + * enrichment path replaces the notification in place. `setOnlyAlertOnce(true)` + * keeps those replacements silent. + */ object NotificationUtils { - private var dmChannel: NotificationChannel? = null - private var zapChannel: NotificationChannel? = null - private var reactionChannel: NotificationChannel? = null - private var chessChannel: NotificationChannel? = null - private var replyChannel: NotificationChannel? = null - private var mentionChannel: NotificationChannel? = null - - private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" - private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" - private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION" - private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION" - const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" - private const val MENTION_GROUP_KEY = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION" - const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION" const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION" @@ -76,12 +79,35 @@ object NotificationUtils { const val KEY_MARMOT_REPLY_TO_INNER_ID = "key_marmot_reply_to_inner_id" const val KEY_MARMOT_REPLY_TO_INNER_AUTHOR = "key_marmot_reply_to_inner_author" - private const val DM_SUMMARY_ID = 0x10000 - private const val ZAP_SUMMARY_ID = 0x20000 - private const val REACTION_SUMMARY_ID = 0x40000 - private const val CHESS_SUMMARY_ID = 0x30000 + const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" private const val REPLY_SUMMARY_ID_BASE = 0x50000 - private const val MENTION_SUMMARY_ID = 0x60000 + + // Event ids the user has just read/dismissed in-app. The enrichment path + // re-posts a notification as metadata arrives; without this guard a + // notification the user already dismissed would be resurrected seconds later + // when its author's kind:0 lands. Keyed by the event id string (not the + // hashCode) so distinct events can't collide. Entries self-expire after a + // window comfortably longer than the 25s enrichment window. + private const val DISMISS_GUARD_MS = 90_000L + private val recentlyDismissed = ConcurrentHashMap() + + private fun markDismissed(eventId: String) { + val now = SystemClock.elapsedRealtime() + recentlyDismissed[eventId] = now + DISMISS_GUARD_MS + if (recentlyDismissed.size > 256) { + recentlyDismissed.entries.removeAll { it.value < now } + } + } + + /** True if [eventId] was read/dismissed in-app within the guard window. */ + fun wasDismissed(eventId: String): Boolean { + val expiry = recentlyDismissed[eventId] ?: return false + if (SystemClock.elapsedRealtime() > expiry) { + recentlyDismissed.remove(eventId) + return false + } + return true + } /** * Derives a stable summary notification id for a per-thread reply group. @@ -92,249 +118,6 @@ object NotificationUtils { fun replyGroupKeyFor(threadRootId: String): String = "$REPLY_GROUP_KEY_PREFIX:$threadRootId" - fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { - if (dmChannel != null) return dmChannel!! - - dmChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_dms_channel_id), - stringRes(applicationContext, R.string.app_notification_dms_channel_name), - NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_dms_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(dmChannel!!) - - return dmChannel!! - } - - fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel { - if (zapChannel != null) return zapChannel!! - - zapChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_zaps_channel_id), - stringRes(applicationContext, R.string.app_notification_zaps_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_zaps_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(zapChannel!!) - - return zapChannel!! - } - - fun getOrCreateReactionChannel(applicationContext: Context): NotificationChannel { - if (reactionChannel != null) return reactionChannel!! - - reactionChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_reactions_channel_id), - stringRes(applicationContext, R.string.app_notification_reactions_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_reactions_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(reactionChannel!!) - - return reactionChannel!! - } - - fun getOrCreateChessChannel(applicationContext: Context): NotificationChannel { - if (chessChannel != null) return chessChannel!! - - chessChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_chess_channel_id), - stringRes(applicationContext, R.string.app_notification_chess_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_chess_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(chessChannel!!) - - return chessChannel!! - } - - fun getOrCreateReplyChannel(applicationContext: Context): NotificationChannel { - if (replyChannel != null) return replyChannel!! - - replyChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_replies_channel_id), - stringRes(applicationContext, R.string.app_notification_replies_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_replies_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(replyChannel!!) - - return replyChannel!! - } - - fun getOrCreateMentionChannel(applicationContext: Context): NotificationChannel { - if (mentionChannel != null) return mentionChannel!! - - mentionChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_mentions_channel_id), - stringRes(applicationContext, R.string.app_notification_mentions_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_mentions_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(mentionChannel!!) - - return mentionChannel!! - } - - suspend fun NotificationManager.sendReactionNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - emojiUrl: String? = null, - ) { - getOrCreateReactionChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - badgeUrl = emojiUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = REACTION_GROUP_KEY, - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = REACTION_SUMMARY_ID, - summaryText = stringRes(applicationContext, R.string.app_notification_reactions_summary), - applicationContext = applicationContext, - ) - } - - suspend fun NotificationManager.sendChessNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - ) { - getOrCreateChessChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_chess_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = CHESS_GROUP_KEY, - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = CHESS_SUMMARY_ID, - summaryText = stringRes(applicationContext, R.string.app_notification_chess_summary), - applicationContext = applicationContext, - ) - } - - suspend fun NotificationManager.sendReplyNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - threadRootId: String, - inlineReply: InlineReplyTarget? = null, - ) { - getOrCreateReplyChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_replies_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = replyGroupKeyFor(threadRootId), - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = replySummaryIdFor(threadRootId), - summaryText = stringRes(applicationContext, R.string.app_notification_replies_summary), - applicationContext = applicationContext, - inlineReply = inlineReply, - ) - } - - suspend fun NotificationManager.sendMentionNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - ) { - getOrCreateMentionChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_mentions_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = MENTION_GROUP_KEY, - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = MENTION_SUMMARY_ID, - summaryText = stringRes(applicationContext, R.string.app_notification_mentions_summary), - applicationContext = applicationContext, - ) - } - /** * Payload for wiring a RemoteInput-powered inline reply action onto a public * note notification. The receiver resolves the target event from LocalCache @@ -346,70 +129,346 @@ object NotificationUtils { val targetEventId: String, ) - suspend fun NotificationManager.sendZapNotification( + /** + * Wiring for a direct-message / group inline reply action. One of the three + * shapes routes to the matching action in [NotificationReplyReceiver]. + */ + sealed interface ReplyAction { + data class Dm( + val accountNpub: String, + val chatroomMembers: String, + ) : ReplyAction + + data class Marmot( + val accountNpub: String, + val nostrGroupId: String, + val replyToInnerEventId: String?, + val replyToInnerAuthor: String?, + ) : ReplyAction + } + + /** A prior message rendered above the main one in a MessagingStyle notification (thread context). */ + data class ParentMessage( + val senderName: String, + val body: String, + val pictureUrl: String?, + /** + * True when the parent was authored by the logged-in account, so it is + * attributed to the MessagingStyle `me` Person (avatar + "you") rather than + * shown as a separate participant. False for a third party's note — e.g. a + * reply to someone else's reply in a thread the account started. + */ + val isFromMe: Boolean = false, + ) + + // --------------------------------------------------------------------- + // Standard notification (BigText, or BigPicture when [bigPictureUrl] set) + // --------------------------------------------------------------------- + + suspend fun NotificationManager.postStandard( + category: NotificationCategory, id: String, - messageBody: String, messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - ) { - getOrCreateZapChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = ZAP_GROUP_KEY, - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = ZAP_SUMMARY_ID, - summaryText = stringRes(applicationContext, R.string.app_notification_zaps_summary), - applicationContext = applicationContext, - ) - } - - suspend fun NotificationManager.sendDMNotification( - id: String, messageBody: String, - senderName: String, time: Long, pictureUrl: String?, uri: String, applicationContext: Context, - accountNpub: String? = null, - accountPictureUrl: String? = null, - chatroomMembers: String? = null, - marmotNostrGroupId: String? = null, - marmotReplyToInnerEventId: String? = null, - marmotReplyToInnerAuthor: String? = null, + bigPictureUrl: String? = null, + badgeUrl: String? = null, + inlineReply: InlineReplyTarget? = null, + groupKey: String = category.group, + summaryId: Int = category.summaryId, ) { - getOrCreateDMChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id) + // The user read/dismissed this event in-app while enrichment was still + // running — don't resurrect it (and skip the bitmap work). + if (wasDismissed(id)) return - sendDMNotificationStyled( - id = id, - messageBody = messageBody, - senderName = senderName, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = accountPictureUrl, - chatroomMembers = chatroomMembers, - marmotNostrGroupId = marmotNostrGroupId, - marmotReplyToInnerEventId = marmotReplyToInnerEventId, - marmotReplyToInnerAuthor = marmotReplyToInnerAuthor, + val channelId = category.ensureChannel(applicationContext) + val notId = id.hashCode() + + val avatar = pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + val largeIcon = badgeUrl?.let { overlayBadge(avatar, it, applicationContext) } ?: avatar + val bigPicture = bigPictureUrl?.let { loadBitmap(it, applicationContext) } + + val contentPendingIntent = contentIntent(applicationContext, notId, uri) + + val builderPublic = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setContentTitle(messageTitle) + .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) + .setContentIntent(contentPendingIntent) + .setPriority(category.priority()) + .setAutoCancel(true) + .setWhen(time * 1000) + + val builder = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setLargeIcon(largeIcon) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(category.priority()) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setGroup(groupKey) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setWhen(time * 1000) + + if (category.colorized) builder.setColorized(true) + + if (bigPicture != null) { + builder.setStyle( + NotificationCompat + .BigPictureStyle() + .bigPicture(bigPicture) + .bigLargeIcon(null as Bitmap?), + ) + } else { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(messageBody)) + } + + if (inlineReply != null) { + builder.addAction(publicReplyAction(applicationContext, notId, inlineReply)) + } + + notify(notId, builder.build()) + sendGroupSummary(category, groupKey, summaryId, applicationContext) + } + + // --------------------------------------------------------------------- + // Conversation notification (MessagingStyle: DMs, replies, group chat) + // --------------------------------------------------------------------- + + suspend fun NotificationManager.postConversation( + category: NotificationCategory, + id: String, + senderName: String, + pictureUrl: String?, + messageBody: String, + time: Long, + uri: String, + applicationContext: Context, + accountPictureUrl: String? = null, + parent: ParentMessage? = null, + replyAction: ReplyAction? = null, + publicInlineReply: InlineReplyTarget? = null, + addMarkRead: Boolean = true, + groupKey: String = category.group, + summaryId: Int = category.summaryId, + ) { + // The user read/dismissed this event in-app while enrichment was still + // running — don't resurrect it (and skip the bitmap work). + if (wasDismissed(id)) return + + val channelId = category.ensureChannel(applicationContext) + val notId = id.hashCode() + + val avatar = pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + val accountAvatar = accountPictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + + val sender = + Person + .Builder() + .setName(senderName) + .apply { avatar?.let { setIcon(IconCompat.createWithBitmap(it)) } } + .build() + + val me = + Person + .Builder() + .setName(stringRes(applicationContext, R.string.app_notification_me)) + .apply { accountAvatar?.let { setIcon(IconCompat.createWithBitmap(it)) } } + .build() + + val messagingStyle = NotificationCompat.MessagingStyle(me) + + if (parent != null) { + val parentSender = + if (parent.isFromMe) { + // The account authored the parent — reuse the `me` Person so it + // renders as self with the account's avatar, not a stranger. + me + } else { + val parentAvatar = parent.pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + Person + .Builder() + .setName(parent.senderName) + .apply { parentAvatar?.let { setIcon(IconCompat.createWithBitmap(it)) } } + .build() + } + messagingStyle.addMessage(parent.body, (time - 1) * 1000, parentSender) + } + messagingStyle.addMessage(messageBody, time * 1000, sender) + + val contentPendingIntent = contentIntent(applicationContext, notId, uri) + + val builderPublic = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setContentTitle(senderName) + .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) + .setLargeIcon(avatar) + .setContentIntent(contentPendingIntent) + .setPriority(category.priority()) + .setAutoCancel(true) + .setWhen(time * 1000) + + val builder = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setLargeIcon(avatar) + .setStyle(messagingStyle) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(category.priority()) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(groupKey) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setWhen(time * 1000) + + when (replyAction) { + is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction)) + is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction)) + null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) } + } + + if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId)) + + notify(notId, builder.build()) + sendGroupSummary(category, groupKey, summaryId, applicationContext) + } + + // --------------------------------------------------------------------- + // Intents & actions + // --------------------------------------------------------------------- + + private fun contentIntent( + applicationContext: Context, + notId: Int, + uri: String, + ): PendingIntent { + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } + return PendingIntent.getActivity( + applicationContext, + notId, + contentIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) } + private fun replyRemoteInput(applicationContext: Context): RemoteInput = + RemoteInput + .Builder(KEY_REPLY_TEXT) + .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label)) + .build() + + private fun buildReplyAction( + applicationContext: Context, + notId: Int, + intent: Intent, + ): NotificationCompat.Action { + val replyPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + notId, + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Action + .Builder(R.drawable.ic_notif_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent) + .addRemoteInput(replyRemoteInput(applicationContext)) + .setAllowGeneratedReplies(true) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .build() + } + + private fun dmReplyAction( + applicationContext: Context, + notId: Int, + action: ReplyAction.Dm, + ): NotificationCompat.Action { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + this.action = REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) + putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers) + } + return buildReplyAction(applicationContext, notId, intent) + } + + private fun marmotReplyAction( + applicationContext: Context, + notId: Int, + action: ReplyAction.Marmot, + ): NotificationCompat.Action { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + this.action = MARMOT_REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) + putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId) + action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) } + action.replyToInnerAuthor?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, it) } + } + return buildReplyAction(applicationContext, notId, intent) + } + + private fun publicReplyAction( + applicationContext: Context, + notId: Int, + target: InlineReplyTarget, + ): NotificationCompat.Action { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = PUBLIC_REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, target.accountNpub) + putExtra(KEY_TARGET_EVENT_ID, target.targetEventId) + } + return buildReplyAction(applicationContext, notId, intent) + } + + private fun markReadAction( + applicationContext: Context, + notId: Int, + ): NotificationCompat.Action { + val markReadIntent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = MARK_READ_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + } + val markReadPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + notId + 1, + markReadIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Action + .Builder(R.drawable.ic_notif_message, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .build() + } + + // --------------------------------------------------------------------- + // Bitmap helpers + // --------------------------------------------------------------------- + private suspend fun loadBitmap( pictureUrl: String, applicationContext: Context, @@ -430,6 +489,32 @@ object NotificationUtils { } } + /** Crops [src] to a centered circle so avatars render round in the tray. */ + private suspend fun circleCrop(src: Bitmap): Bitmap = + withContext(Dispatchers.Default) { + try { + val size = min(src.width, src.height) + val squared = + if (src.width != src.height) { + Bitmap.createBitmap(src, (src.width - size) / 2, (src.height - size) / 2, size, size) + } else { + src + } + val output = createBitmap(size, size) + val canvas = Canvas(output) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + isFilterBitmap = true + shader = BitmapShader(squared, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + } + val r = size / 2f + canvas.drawCircle(r, r, r, paint) + output + } catch (_: Exception) { + src + } + } + /** * Draws the badge image (e.g. a NIP-30 custom-emoji reaction) onto the bottom-right corner * of the base avatar. Returns the base unchanged if the badge can't be loaded, or the badge @@ -446,7 +531,7 @@ object NotificationUtils { return withContext(Dispatchers.Default) { val result = base.copy(base.config ?: Bitmap.Config.ARGB_8888, true) val canvas = Canvas(result) - val badgeSize = minOf(result.width, result.height) * 0.45f + val badgeSize = min(result.width, result.height) * 0.45f val dest = RectF( result.width - badgeSize, @@ -460,303 +545,14 @@ object NotificationUtils { } } - private suspend fun NotificationManager.sendDMNotificationStyled( - id: String, - messageBody: String, - senderName: String, - time: Long, - pictureUrl: String?, - uri: String, - channelId: String, - applicationContext: Context, - accountNpub: String?, - accountPictureUrl: String?, - chatroomMembers: String?, - marmotNostrGroupId: String? = null, - marmotReplyToInnerEventId: String? = null, - marmotReplyToInnerAuthor: String? = null, - ) { - val notId = id.hashCode() - - if (isDuplicate(notId)) return - - val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) } - val accountBitmap = accountPictureUrl?.let { loadBitmap(it, applicationContext) } - - val senderIcon = bitmap?.let { IconCompat.createWithBitmap(it) } - val accountIcon = accountBitmap?.let { IconCompat.createWithBitmap(it) } - - val sender = - Person - .Builder() - .setName(senderName) - .apply { senderIcon?.let { setIcon(it) } } - .build() - - val messagingStyle = - NotificationCompat - .MessagingStyle( - Person - .Builder() - .setName("Me") - .setIcon(accountIcon) - .build(), - ).addMessage(messageBody, time * 1000, sender) - - val contentIntent = - Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } - - val contentPendingIntent = - PendingIntent.getActivity( - applicationContext, - notId, - contentIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val builderPublic = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(senderName) - .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) - .setLargeIcon(bitmap) - .setContentIntent(contentPendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setWhen(time * 1000) - - val builder = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setLargeIcon(bitmap) - .setStyle(messagingStyle) - .setContentIntent(contentPendingIntent) - .setPublicVersion(builderPublic.build()) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setGroup(DM_GROUP_KEY) - .setAutoCancel(true) - .setWhen(time * 1000) - - // Direct Reply action - if (accountNpub != null && chatroomMembers != null) { - val remoteInput = - RemoteInput - .Builder(KEY_REPLY_TEXT) - .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label)) - .build() - - val replyIntent = - Intent(applicationContext, NotificationReplyReceiver::class.java).apply { - action = REPLY_ACTION - putExtra(KEY_NOTIFICATION_ID, notId) - putExtra(KEY_ACCOUNT_NPUB, accountNpub) - putExtra(KEY_CHATROOM_MEMBERS, chatroomMembers) - } - - val replyPendingIntent = - PendingIntent.getBroadcast( - applicationContext, - notId, - replyIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val replyAction = - NotificationCompat.Action - .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent) - .addRemoteInput(remoteInput) - .setAllowGeneratedReplies(true) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .build() - - builder.addAction(replyAction) - } else if (accountNpub != null && marmotNostrGroupId != null) { - // Marmot/MLS Reply action: sends the user's text as an encrypted - // kind:9 inside the Marmot group, replying to the inner event - // that triggered this notification. Mirrors the NIP-17 path - // above but routes through NotificationReplyReceiver's - // MARMOT_REPLY_ACTION branch so we never publish a plaintext - // public reply for an encrypted group message. - val remoteInput = - RemoteInput - .Builder(KEY_REPLY_TEXT) - .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label)) - .build() - - val replyIntent = - Intent(applicationContext, NotificationReplyReceiver::class.java).apply { - action = MARMOT_REPLY_ACTION - putExtra(KEY_NOTIFICATION_ID, notId) - putExtra(KEY_ACCOUNT_NPUB, accountNpub) - putExtra(KEY_MARMOT_GROUP_ID, marmotNostrGroupId) - if (marmotReplyToInnerEventId != null) { - putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, marmotReplyToInnerEventId) - } - if (marmotReplyToInnerAuthor != null) { - putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, marmotReplyToInnerAuthor) - } - } - - val replyPendingIntent = - PendingIntent.getBroadcast( - applicationContext, - notId, - replyIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val replyAction = - NotificationCompat.Action - .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent) - .addRemoteInput(remoteInput) - .setAllowGeneratedReplies(true) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .build() - - builder.addAction(replyAction) - } - - // Mark as Read action - val markReadIntent = - Intent(applicationContext, NotificationReplyReceiver::class.java).apply { - action = MARK_READ_ACTION - putExtra(KEY_NOTIFICATION_ID, notId) - } - - val markReadPendingIntent = - PendingIntent.getBroadcast( - applicationContext, - notId + 1, - markReadIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val markReadAction = - NotificationCompat.Action - .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) - .build() - - builder.addAction(markReadAction) - - notify(notId, builder.build()) - - // Group summary notification - sendGroupSummary(channelId, DM_GROUP_KEY, DM_SUMMARY_ID, stringRes(applicationContext, R.string.app_notification_dms_summary), applicationContext) - } - - private suspend fun NotificationManager.sendNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - channelId: String, - notificationGroupKey: String, - category: String, - summaryId: Int, - summaryText: String, - applicationContext: Context, - inlineReply: InlineReplyTarget? = null, - badgeUrl: String? = null, - ) { - val notId = id.hashCode() - - if (isDuplicate(notId)) return - - val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) } - - // For custom-emoji (NIP-30) reactions, the emoji is an image URL that can't render - // as text in the notification, so overlay it as a badge on the author's avatar. - val largeIcon = badgeUrl?.let { overlayBadge(bitmap, it, applicationContext) } ?: bitmap - - val contentIntent = - Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } - - val contentPendingIntent = - PendingIntent.getActivity( - applicationContext, - notId, - contentIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val builderPublic = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) - .setLargeIcon(bitmap) - .setContentIntent(contentPendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setWhen(time * 1000) - - val builder = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(messageBody) - .setStyle(NotificationCompat.BigTextStyle().bigText(messageBody)) - .setLargeIcon(largeIcon) - .setContentIntent(contentPendingIntent) - .setPublicVersion(builderPublic.build()) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(category) - .setGroup(notificationGroupKey) - .setAutoCancel(true) - .setWhen(time * 1000) - - if (inlineReply != null) { - val remoteInput = - RemoteInput - .Builder(KEY_REPLY_TEXT) - .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label)) - .build() - - val replyIntent = - Intent(applicationContext, NotificationReplyReceiver::class.java).apply { - action = PUBLIC_REPLY_ACTION - putExtra(KEY_NOTIFICATION_ID, notId) - putExtra(KEY_ACCOUNT_NPUB, inlineReply.accountNpub) - putExtra(KEY_TARGET_EVENT_ID, inlineReply.targetEventId) - } - - val replyPendingIntent = - PendingIntent.getBroadcast( - applicationContext, - notId, - replyIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val replyAction = - NotificationCompat.Action - .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent) - .addRemoteInput(remoteInput) - .setAllowGeneratedReplies(true) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .build() - - builder.addAction(replyAction) - } - - notify(notId, builder.build()) - - sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext) - } + // --------------------------------------------------------------------- + // Group summaries, dedup, dismissal + // --------------------------------------------------------------------- private fun NotificationManager.sendGroupSummary( - channelId: String, + category: NotificationCategory, groupKey: String, summaryId: Int, - summaryText: String, applicationContext: Context, ) { val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId } @@ -765,30 +561,22 @@ object NotificationUtils { val summaryBuilder = NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) + .Builder(applicationContext, category.channelId(applicationContext)) + .setSmallIcon(category.smallIcon) + .setColor(category.color) .setGroup(groupKey) .setGroupSummary(true) .setAutoCancel(true) + .setOnlyAlertOnce(true) .setStyle( NotificationCompat .InboxStyle() - .setSummaryText(summaryText), + .setSummaryText(stringRes(applicationContext, category.summaryTextRes)), ) notify(summaryId, summaryBuilder.build()) } - private fun NotificationManager.isDuplicate(notId: Int): Boolean { - val notifications: Array = activeNotifications - for (notification in notifications) { - if (notification.id == notId) { - return true - } - } - return false - } - /** Cancels all notifications. */ fun NotificationManager.cancelNotifications() { cancelAll() @@ -798,13 +586,18 @@ object NotificationUtils { * Dismisses the tray notification posted for [eventId] — used to auto-clear a * notification once the user reads the underlying event in-app. * - * Per-event notifications are keyed by `id.hashCode()` (see [sendNotification] - * and [sendDMNotificationStyled]), so hashing the same event id targets exactly - * the notification posted for it. Cancelling an id that isn't currently shown is - * a harmless no-op. After removing the child, any group summary left without - * children is cancelled too so the tray doesn't keep an empty summary around. + * Per-event notifications are keyed by `id.hashCode()`, so hashing the same + * event id targets exactly the notification posted for it. Cancelling an id + * that isn't currently shown is a harmless no-op. After removing the child, + * any group summary left without children is cancelled too so the tray + * doesn't keep an empty summary around. */ fun NotificationManager.dismissNotificationForEvent(eventId: HexKey) { + // Record the dismissal first, unconditionally, so an in-flight enrichment + // window can't re-post this notification after the user has read it — even + // in the race where the initial post isn't visible in activeNotifications yet. + markDismissed(eventId) + val notId = eventId.hashCode() // Most events the user reads never had a tray notification (regular feed @@ -816,7 +609,7 @@ object NotificationUtils { } private fun NotificationManager.cancelChildlessGroupSummaries() { - val active = activeNotifications + val active: Array = activeNotifications for (summary in active) { if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue val group = summary.notification.group ?: continue @@ -824,4 +617,11 @@ object NotificationUtils { if (!hasChildren) cancel(summary.id) } } + + private fun NotificationCategory.priority(): Int = + when (importance) { + NotificationManager.IMPORTANCE_HIGH, NotificationManager.IMPORTANCE_MAX -> NotificationCompat.PRIORITY_HIGH + NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_MIN -> NotificationCompat.PRIORITY_LOW + else -> NotificationCompat.PRIORITY_DEFAULT + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt new file mode 100644 index 0000000000..9e886a3e30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BadgeNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BadgeNotification.kt new file mode 100644 index 0000000000..ed953f73bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BadgeNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt new file mode 100644 index 0000000000..cdcd3ced65 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ChessNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ChessNotification.kt new file mode 100644 index 0000000000..f3aa6bf7ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ChessNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt new file mode 100644 index 0000000000..07fff4a189 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt new file mode 100644 index 0000000000..e1b7319b21 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt new file mode 100644 index 0000000000..ebcc0d834b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt @@ -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" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt new file mode 100644 index 0000000000..c073fb605d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt new file mode 100644 index 0000000000..a996247dc6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt new file mode 100644 index 0000000000..99cd834adb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt new file mode 100644 index 0000000000..beadc2b4a9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt @@ -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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt new file mode 100644 index 0000000000..d5b5676918 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt new file mode 100644 index 0000000000..a8298a21ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index c92252150a..ae2c4d0964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 24800b9010..6e9c18c89c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -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() + + // 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" } + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 193fd82b7f..f6a81cc141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -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() - - private val cache = - object : LruCache(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(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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SessionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SessionRegistry.kt new file mode 100644 index 0000000000..9b47453081 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SessionRegistry.kt @@ -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( + maxIdle: Int, + private val onDropped: (T) -> Unit, +) { + private val playing = mutableMapOf() + + // 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(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 = 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 = 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() + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index 8715fdc6eb..b52f7ee594 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt index 778532c29a..eaa90e8d10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt index ce7582fc75..168d7ee9f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt @@ -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, - ) { - 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 0aab8e2fce..d209e5888b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -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(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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt new file mode 100644 index 0000000000..25918c72f5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt new file mode 100644 index 0000000000..a2b6d758f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt @@ -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, +) + +/** + * 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/` + * (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>(emptyList()) + val sources = _sources.asStateFlow() + + private val _candidates = MutableStateFlow>(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(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 = + 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, + targets: List, + ) { + 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() + 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, + targets: List, + listAuth: String, + ): Map> { + val pubkey = account.signer.pubKey + val listed: List?>> = + 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>() + val nonListTargets = ArrayList() + 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, + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt index 8a9ad2a000..b383ccd1bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt @@ -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(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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 2a2b43ff4b..fafad6126a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs { BlossomBlobManagerScreen(accountViewModel, nav) } + composableFromEndArgs { BlossomImportScreen(accountViewModel, nav) } composableFromEndArgs { com.vitorpamplona.amethyst.ui.actions.nestsServers .NestsServersScreen(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fcd8179e4b..acb3f8f6f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -467,6 +467,8 @@ sealed class Route { @Serializable object ManageBlossomBlobs : Route() + @Serializable object ImportBlossomBlobs : Route() + @Serializable object EditNestsServers : Route() @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 365c13a9b6..5e64c5b521 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 6560edc939..e529471eda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt index 500cae7f3b..b177bfb0cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 20c295b6ee..dc5923a744 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 84976ecaec..2e615a33d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt index 72acad2890..04cfbd615e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt index 91c8cd45bc..5899bb3cd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt index 7c9738f5a6..02772811db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt @@ -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(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)) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index f69acdaa79..4db0191b85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -80,7 +80,7 @@ fun RefreshingChatroomFeedView( // callers with no external jump affordance. jumpToNoteId: State? = 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, listState: LazyListState) -> Unit)? = null, jumpToNoteId: State? = 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, listState: LazyListState) -> Unit)? = null, jumpToNoteId: State? = 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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index df73e0d147..72170071c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 60d4c023ed..fe5f68d670 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -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(initialValue = null, note.idHex) { + note.flow().edits.stateFlow.collect { + value = note.latestConcordEdit() ?: note.latestBuzzEdit() + } + } + return latest +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index 2d10c310c4..0cc1cbd6f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt new file mode 100644 index 0000000000..5004e87e6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt @@ -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, + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 0189c58e3e..6f15e93574 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 6c54aa8afc..ffe49cce4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 764961ace3..160acdf42a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -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, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt new file mode 100644 index 0000000000..36dc1e6995 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt @@ -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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 1f66f9a9dd..a6db1b8ade 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index f104994923..5a37ff3e8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -63,6 +63,10 @@ open class ConcordNewMessageViewModel : ViewModel() { val message = TextFieldState() val replyTo = mutableStateOf(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(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index 7f347f3b1a..add5333fb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt new file mode 100644 index 0000000000..17ab40f078 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt @@ -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.toPlainImetas(): List { + val attachments = IMetaAttachments() + forEach { attachments.add(it.result, it.caption, it.contentWarningReason) } + return attachments.iMetaAttachments +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt index dd0225649e..8f38e10ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index e1974d6e54..77021fa742 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index 19fdac7055..2feb4901e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 8eb3ea1e2a..9e6ea3531c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index 54e211f4db..2d52b3617b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -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 }, diff --git a/amethyst/src/main/res/drawable/ic_notif_article.xml b/amethyst/src/main/res/drawable/ic_notif_article.xml new file mode 100644 index 0000000000..33edc24b2b --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_article.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_badge.xml b/amethyst/src/main/res/drawable/ic_notif_badge.xml new file mode 100644 index 0000000000..2fee256783 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_badge.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_chess.xml b/amethyst/src/main/res/drawable/ic_notif_chess.xml new file mode 100644 index 0000000000..e20882a854 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_chess.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_code.xml b/amethyst/src/main/res/drawable/ic_notif_code.xml new file mode 100644 index 0000000000..d54a09f8ab --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_code.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_media.xml b/amethyst/src/main/res/drawable/ic_notif_media.xml new file mode 100644 index 0000000000..575a58dd8b --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_media.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_mention.xml b/amethyst/src/main/res/drawable/ic_notif_mention.xml new file mode 100644 index 0000000000..afb7e1bebd --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_mention.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_message.xml b/amethyst/src/main/res/drawable/ic_notif_message.xml new file mode 100644 index 0000000000..4c833fa3d9 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_message.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_reaction.xml b/amethyst/src/main/res/drawable/ic_notif_reaction.xml new file mode 100644 index 0000000000..9521a89868 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_reaction.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_reply.xml b/amethyst/src/main/res/drawable/ic_notif_reply.xml new file mode 100644 index 0000000000..cba9670de1 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_reply.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_repost.xml b/amethyst/src/main/res/drawable/ic_notif_repost.xml new file mode 100644 index 0000000000..845f99423c --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_repost.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_zap.xml b/amethyst/src/main/res/drawable/ic_notif_zap.xml new file mode 100644 index 0000000000..77f822994d --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_zap.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 2e13c653c3..95f677b208 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -312,6 +312,7 @@ تعذّر العثور على هذه الرسالة تم البحث في كل relay · اضغط للعرض + "خطأ في تحميل الردود:" المحاولة مرة اخرى لا توجد إشعارات حتى الآن. @@ -1263,6 +1264,14 @@ يُعلمك عندما يذكرك شخص ما أو يستشهد بك في منشور أشار إليك %1$s إشارات جديدة + + + + + + + + المكالمات الواردة إشعارات للمكالمات الصوتية والمرئية الواردة diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index f5016544b3..9f1c5d67c5 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -302,6 +302,7 @@ এই বার্তাটি খুঁজে পাওয়া যায়নি প্রতিটি relay অনুসন্ধান করা হয়েছে · দেখতে ট্যাপ করুন + "প্রতিউত্তরগুলি লোড করা যাচ্ছে না: " আবার চেষ্টা করুন এখনও কোনো বিজ্ঞপ্তি নেই। @@ -1214,6 +1215,14 @@ কেউ পোস্টে আপনাকে উল্লেখ বা উদ্ধৃত করলে আপনাকে জানায় %1$s আপনাকে উল্লেখ করেছে নতুন উল্লেখ + + + + + + + + আসা কল ইনকামিং ভয়েস ও ভিডিও কলের বিজ্ঞপ্তি diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index a205299a5f..d3f70729de 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -26,6 +26,12 @@ +%1$d\nodpovědi +%1$d\nodpovědí + + Nahlásil %1$d člověk, kterého sledujete + Nahlásili %1$d lidé, které sledujete + Nahlásilo %1$d lidí, které sledujete + Nahlásilo %1$d lidí, které sledujete + Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací 👀 Obrázek kanálu @@ -420,6 +426,12 @@ Tuto zprávu se nepodařilo najít Prohledány všechny relaye · klepnutím zobrazíte + Kdo to nahlásil? + Zavřít + Skrýt + Někdo, koho sledujete, nahlásil tento účet + Nahlášeno + "Chyba při načítání odpovědí: " Zkusit znovu Zatím žádná oznámení. @@ -1799,6 +1811,9 @@ pro %1$s Odpovědět Označit jako přečtené + Já + Nová zpráva + Byli jste přidáni do %1$s Nové zprávy Nové zapsy Reakce @@ -1820,6 +1835,49 @@ Upozorní vás, když vás někdo zmíní nebo cituje v příspěvku %1$s vás zmínil(a) Nové zmínky + + Zprávy + Sociální + Platby + Obsah + Vývojář + Hry + + Přeposlání + Upozorní vás, když někdo přepošle váš příspěvek + %1$s přeposlal váš příspěvek + Nová přeposlání + + Média + Upozorní vás, když vás někdo zmíní ve fotce nebo videu + %1$s sdílel fotku + %1$s sdílel video + Nová média + + Články a zvýraznění + Upozorní vás, když vás někdo zmíní nebo zvýrazní v článku + %1$s vás zmínil v článku + %1$s zvýraznil váš článek + Nové články + + Kód a Git + Upozorní vás na issues, patche a pull requesty + %1$s otevřel issue + %1$s odeslal patch + %1$s otevřel pull request + %1$s aktualizoval pull request + Nová aktivita v kódu + + Odznaky + Upozorní vás, když získáte odznak + Získali jste odznak + Uděleno od %1$s + Nové odznaky + + Nutzap od %1$s + Onchain zap od %1$s + + %1$s položil otázku Příchozí hovory Oznámení pro příchozí hlasové a videohovory @@ -2074,6 +2132,11 @@ Vzhled Média a data Obecné + Připojení + Směrovat přes Tor + Sekce a kanály + Viditelné karty + Připomenutí Připojení peněženky Jazyk Motiv @@ -2373,6 +2436,9 @@ Procházejte skupiny hostované na relay. Vložte adresu relay nebo vyberte některou níže. Adresa relay Procházet + Nelze dosáhnout tohoto relaye přes Tor + %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? + Použít clearnet Relaye, na kterých jste Oblíbené relaye Zatím žádné zprávy @@ -3106,6 +3172,9 @@ %1$s vytvořil(a) kanál %2$s %1$s vytvořil(a) kanál %1$s aktualizoval(a) profil kanálu + Kanály + Fóra + Přidat člena Doručení zprávy Zavřít Čekání, až relay přijme tuto zprávu @@ -4157,6 +4226,11 @@ Těžení proof of work 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. Zrušit + Odeslat nyní + Zastavit těžení? + Tento příspěvek stále těží svůj proof of work. Můžete jej odeslat hned bez proof of work, nebo jej zahodit. + Odeslat bez PoW + Zahodit příspěvek ≈ %1$s na příspěvek na tomto zařízení <1 s diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 50e31e3dfb..b157a09eb0 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -20,6 +20,10 @@ +%1$d\nAntwort +%1$d\nAntworten + + Von %1$d Person gemeldet, der du folgst + Von %1$d Personen gemeldet, denen du folgst + Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden 👀 Kanalbild @@ -406,6 +410,12 @@ Diese Nachricht konnte nicht gefunden werden Alle Relays durchsucht · zum Anzeigen tippen + Wer hat das gemeldet? + Schließen + Ausblenden + Jemand, dem du folgst, hat dieses Konto gemeldet + Gemeldet + "Fehler beim Laden der Antworten: " Erneut versuchen Noch keine Benachrichtigungen. @@ -1735,6 +1745,9 @@ für %1$s Antworten Als gelesen markieren + Ich + Neue Nachricht + Du wurdest zu %1$s hinzugefügt Neue Nachrichten Neue Zaps Reaktionen @@ -1756,6 +1769,47 @@ Benachrichtigt dich, wenn dich jemand in einem Beitrag erwähnt oder zitiert %1$s hat dich erwähnt Neue Erwähnungen + + Nachrichten + Soziales + Zahlungen + Inhalt + Entwickler + Spiele + + Benachrichtigt dich, wenn jemand deinen Beitrag repostet + %1$s hat deinen Beitrag repostet + Neue Reposts + + Medien + Benachrichtigt dich, wenn dich jemand in einem Foto oder Video erwähnt + %1$s hat ein Foto geteilt + %1$s hat ein Video geteilt + Neue Medien + + Artikel & Hervorhebungen + Benachrichtigt dich, wenn dich jemand in einem Artikel erwähnt oder hervorhebt + %1$s hat dich in einem Artikel erwähnt + %1$s hat deinen Artikel hervorgehoben + Neue Artikel + + Benachrichtigt dich über Issues, Patches und Pull Requests + %1$s hat ein Issue eröffnet + %1$s hat einen Patch gesendet + %1$s hat einen Pull Request eröffnet + %1$s hat einen Pull Request aktualisiert + Neue Code-Aktivität + + Abzeichen + Benachrichtigt dich, wenn du ein Abzeichen erhältst + Du hast ein Abzeichen erhalten + Verliehen von %1$s + Neue Abzeichen + + Nutzap von %1$s + Onchain-Zap von %1$s + + %1$s hat eine Frage gestellt Eingehende Anrufe Benachrichtigungen für eingehende Sprach- und Videoanrufe @@ -2006,6 +2060,11 @@ Darstellung Medien & Daten Allgemein + Verbindung + Über Tor leiten + Abschnitte & Feeds + Sichtbare Tabs + Erinnerungen Wallet Verbindung Sprache Design @@ -2301,6 +2360,9 @@ Durchsuche die auf einem Relay gehosteten Gruppen. Füge eine Relay-Adresse ein oder wähle unten eine aus. Relay-Adresse Durchsuchen + Dieses Relay ist über Tor nicht erreichbar + %1$s antwortet nicht über Tor — sein Host blockiert möglicherweise Tor-Exit-Nodes. Stattdessen über Clearnet verbinden? + Clearnet verwenden Relays, auf denen du bist Beliebte Relays Noch keine Nachrichten @@ -3006,6 +3068,9 @@ %1$s hat den Kanal %2$s erstellt %1$s hat den Kanal erstellt %1$s hat das Kanalprofil aktualisiert + Kanäle + Foren + Mitglied hinzufügen Nachrichtenzustellung Schließen Warte darauf, dass ein Relay diese Nachricht akzeptiert @@ -4021,6 +4086,11 @@ Arbeitsnachweis-Schürfen Zeigt Beiträge an, die noch ihren NIP-13-Arbeitsnachweis schürfen, damit sie fertig werden können, nachdem du die App verlässt. Abbrechen + Jetzt senden + Mining stoppen? + Dieser Beitrag berechnet noch seinen Proof of Work. Du kannst ihn jetzt ohne Proof of Work senden oder verwerfen. + Ohne PoW senden + Beitrag verwerfen ≈ %1$s pro Beitrag auf diesem Gerät <1 s diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 165b8d395b..52b24aaf24 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -297,6 +297,7 @@ Δεν βρέθηκε αυτό το μήνυμα Έγινε αναζήτηση σε κάθε relay · πάτα για να δεις + "Σφάλμα κατά τη φόρτωση απαντήσεων: " Προσπαθήστε ξανά Δεν υπάρχουν ακόμα ειδοποιήσεις. @@ -1201,6 +1202,14 @@ Σας ειδοποιεί όταν κάποιος σας αναφέρει ή σας παραθέτει σε ανάρτηση Ο %1$s σας ανέφερε Νέες αναφορές + + + + + + + + Εισερχόμενες κλήσεις Ειδοποιήσεις για εισερχόμενες φωνητικές και βιντεοκλήσεις diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 092f9181d8..2821e3cd3b 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -13,6 +13,7 @@ + @@ -40,6 +41,14 @@ + + + + + + + + Serĉis ĉiun relay · tuŝetu por vidi + "Eraro ŝargante respondojn: " Provi denove Ankoraŭ neniuj sciigoj. @@ -1217,6 +1218,14 @@ Sciigos vin kiam iu menciigas aŭ citas vin en afiŝo %1$s menciis vin Novaj mencioj + + + + + + + + Alvenantaj alvokoj Sciigoj por alvenantaj voĉaj kaj vidaj alvokoj diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index fe28a9b7f7..9d762a097f 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -305,6 +305,7 @@ No se pudo encontrar este mensaje Se buscó en todos los relés (tocar para ver) + "Error al cargar las respuestas: " Intentar otra vez Aún no hay notificaciones. @@ -1239,6 +1240,14 @@ Te notifica cuando alguien te menciona o te cita en una publicación %1$s te mencionó Nuevas menciones + + + + + + + + Llamadas entrantes Notificaciones de llamadas de voz y vídeo entrantes diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 29dc509576..bdad814e5d 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -305,6 +305,7 @@ No se pudo encontrar este mensaje Se buscó en todos los relés (tocar para ver) + "Error al cargar las respuestas: " Intentar de nuevo Aún no hay notificaciones. @@ -1231,6 +1232,14 @@ Te notifica cuando alguien te menciona o te cita en una publicación %1$s te mencionó Nuevas menciones + + + + + + + + Llamadas entrantes Notificaciones de llamadas de voz y vídeo entrantes diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 78847d4cfd..ee4422a25c 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -305,6 +305,7 @@ No se pudo encontrar este mensaje Se buscó en todos los relés (tocar para ver) + "Error al cargar las respuestas: " Intentar de nuevo Aún no hay notificaciones. @@ -1231,6 +1232,14 @@ Te notifica cuando alguien te menciona o te cita en una publicación %1$s te mencionó Nuevas menciones + + + + + + + + Llamadas entrantes Notificaciones de llamadas de voz y vídeo entrantes diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index a94fe4677f..aab988ccbf 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -303,6 +303,7 @@ این پیام پیدا نشد همه relay ها جستجو شد · برای مشاهده ضربه بزنید + " :خطا در بارگیری پاسخ ها" دوباره تلاش کنید هنوز هیچ اعلانی نیست. @@ -1219,6 +1220,14 @@ وقتی کسی شما را در پستی اشاره یا نقل‌قول می‌کند اطلاع می‌دهد %1$s شما را اشاره کرد اشاره‌های جدید + + + + + + + + تماس‌های ورودی اعلان‌های تماس‌های صوتی و تصویری ورودی diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index ecf025e7d1..8ff3fdfc45 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -295,6 +295,7 @@ Tätä viestiä ei löydy Haettu kaikista relay-palvelimista · napauta nähdäksesi + "Virhe ladattaessa vastauksia: " Yritä uudelleen Ei ilmoituksia vielä. @@ -1202,6 +1203,14 @@ Ilmoittaa kun joku mainitsee tai siteeraa sinua julkaisussa %1$s mainitsi sinut Uusia mainintoja + + + + + + + + Saapuvat puhelut Ilmoitukset saapuvista ääni- ja videopuheluista diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 2ee21db080..b33dec7c95 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -289,6 +289,7 @@ Impossible de trouver ce message Tous les relais consultés · appuyer pour voir + "Erreur lors du chargement des réponses : " Essayer à nouveau Aucune notification pour le moment. @@ -1172,6 +1173,14 @@ Vous notifie lorsque quelqu\'un vous mentionne ou vous cite dans une publication %1$s vous a mentionné Nouvelles mentions + + + + + + + + Appels entrants Notifications pour les appels vocaux et vidéos entrants diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index c24234f08c..b855eed96c 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -305,6 +305,7 @@ Impossible de trouver ce message Tous les relais consultés · appuyer pour voir + "Erreur lors du chargement des réponses : " Essayer à nouveau Aucune notification pour le moment. @@ -1364,6 +1365,14 @@ Vous notifie lorsque quelqu\'un vous mentionne ou vous cite dans une publication %1$s vous a mentionné Nouvelles mentions + + + + + + + + Appels entrants Notifications pour les appels vocaux et vidéos entrants diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index ff2b5d8914..ea90e9db34 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -20,6 +20,10 @@ सं॰%1$d\n उत्तर सं॰%1$d\n उत्तर + + वृत्तान्त आपके द्वारा अनुचरित %1$d व्यक्ति से + वृत्तान्त आपके द्वारा अनुचरित %1$d व्यक्तियों से + घटना उपलब्ध की जा रही है अथवा आपके पुनःप्रसारक सूची में से प्राप्त नहीं देखें प्रणाली चित्र @@ -339,6 +343,14 @@ %1$d प्रणाली %1$d प्रणालियाँ + + %1$d कार्यशाला + %1$d कार्यशालाएँ + + + %1$d प्रणाली + %1$d प्रणालियाँ + %1$d सदस्य %1$d सदस्य @@ -406,6 +418,13 @@ यह सन्देश अप्राप्त प्रत्येक पुनःप्रसारक में खोज लिया। देखने के लिए दबाएँ + किसने भेजा यह वृत्तान्त। + हटाएँ + छिपाएँ + आपके द्वारा अनुचरित किसी ने इस लेखा पर संशयचिह्न लगाया + वृत्तान्त भेज दिया + + %1$dसं॰ "प्रतिवचनों को प्राप्त करने में अपक्रम : " पुनः प्रयास करें अभी तक कोई सूचनाएँ नहीं। @@ -1240,6 +1259,7 @@ स्थान आरम्भण भविष्य समयनिर्धारण आरम्भ समय का चयन करें + घटक कोष्ठक निलय सेवासंगणक चयन करें कि किन एमओक्यू॰ जालावास सेवासंगणकों पर अमेथिस्ट आपके निलयों का प्रकाशन करता है। प्रथम विकल्प का प्रयोग होगा मूलविकल्पतः जब आप एक नया स्थान आरम्भ करेंगे। आपके सेवासंगणक @@ -1735,6 +1755,9 @@ %1$s के लिए उत्तर पढा हुआ चिह्नित करें + मैं + नया सन्देश + आप %1$s में जोडे गए हैं नए सन्देश नए ज्साप प्रतिक्रियाएँ @@ -1756,6 +1779,49 @@ आपको सूचित करता है जब किसी ने आपका उल्लेख अथवा उद्धरण किया हो %1$s ने आपका उल्लेख किया नए उल्लेख + + सन्देश + सामूहिक + भुगतान + विषयवस्तु + विकासकर्ता + खेल + + पुनःप्रकाशन + आपको सूचित करता है जब कोई आपके पत्र को पुनःप्रकाशित करते हैं + %1$s ने आपके पत्र को पुनःप्रकाशित किया + नए पुनःप्रकाशन + + ध्वनिचित्राभिलेख + आपको सूचित करता है जब कोई आपका उल्लेख करते हैं चित्र अथवा चलचित्र में + %1$s ने चित्र बाँटा + %1$s ने चलचित्र बाँटा + नए ध्वनिचित्राभिलेख + + निबन्ध तथा उद्दीप्तव्य + आपको सूचित करता है जब कोई आपका उल्लेख अथवा आपको उद्दीप्त करते हैं एक निबन्ध में + %1$s ने आपका उल्लेख किया एक निबन्ध में + %1$s ने आपके निबन्ध को उद्दीप्त किया + नए निबन्ध + + क्रमलेख तथा गिट॰ + आपको सूचित करता है विषय तथा सुधारलेख तथा कर्षण अनुरोधों पर + %1$s ने एक विषय खोला + %1$s ने एक सुधारलेख भेजा + %1$s ने एक कर्षण अनुरोध खोला + %1$s ने एक कर्षण अनुरोध का अद्यतन किया + नई क्रमलेख गतिविधि + + पदक + अपको सूचित करता है जब आप एक नया पदक प्राप्त करते हैं + आपने एक पदक प्राप्त किया + पुरस्कृत %1$s द्वारा + नए पदक + + नटज्साप॰ %1$s से + खण्डश्रृंखलाबद्ध ज्साप %1$s से + + %1$s ने प्रश्न पूछा आगामी आह्वान सूचनाएँ आगामी ध्वनि तथा दृश्य आह्वानों के लिए @@ -2306,6 +2372,9 @@ पुनःप्रसारक में उपलब्ध समूहों का वीक्षण करें। पुनःप्रसारक पता चिपकाएँ अथवा नीचे चयन करें। पुनःप्रसारक पता वीक्षण + यह पुनःप्रसारक अभिगम्य नहीं टोर॰ पर + %1$s से कोई प्रत्युत्तर नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन स्थानों को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें। + स्पष्टजाल का उपयोग करें पुनःप्रसारक जिन पर आप हैं लोकप्रिय पुनःप्रसारक कोई सन्देश नहीं अब तक @@ -3012,6 +3081,86 @@ %1$s ने %2$s प्रणाली बनाया %1$s ने प्रणाली बनाया %1$s ने प्रणाली परिचय का अद्यतन किया + (सम्पादित) + (अन्तर ह्रस्वकृत) + चित्रपट + कोई चित्रपट बाँटा नहीं गया इस कार्यशाला में अब तक। + चित्रपट सम्पादन + चित्रपट अभिलेखन + चित्रपट (मार्कडौन॰) + सम्पादन + सन्देश सम्पादन + %1$s टंकण मध्य… + %1$s तथा %2$s टंकण मध्य… + अनेक लोग टंकण मध्य… + बज्स्स कार्यशालाएँ + जहाँ मनुष्य तथा घटक एक साथ निर्माण करते हैं + कार्यशाला निर्गमन + आपके कार्यशालाएँ + कोई कार्यशालाएँ नहीं अब तक + बज्स्स कार्यशालाएँ पुनःप्रसारक हैं जहाँ मनुष्य तथा एआई॰ घटक एक साथ निर्माण करते हैं। एक के साथ जुडें उसे यहाँ देखने के लिए। + समूहों का वीक्षण + घटक कोष्ठक + परिव्यय। व्यक्तिशैलियाँ। वर्तमान अवलोकनकर्ता + नया मंच विषय + आप किस विषय पर चर्चा करना चाहते हैं। + विषय प्रकाशन + मंच सूत्र + कोई प्रत्युत्तर नहीं अब तक। सबसे पहले उत्तर देनेवाले बनें। + सीधे सन्देश + बज्स्स सीधेसन्देश + निजी वार्तालाप आपके कार्यशालाओं में + नया सन्देश + कोई सीधेसन्देश नहीं अब तक + आरम्भ करें निजी वार्तालप किसी से भी एक बज्स्स कार्यशाला में। + और अधिक + संवाद छिपाएँ + सदस्य जोडें + किसी को जोडें इस सीधेसन्देश में + मान्य एनपुब॰ अथवा षोडषांक कुंचिका नहीं + केवल आप + कार्यशाला + इनको + किसी को जोडें (एनपुब॰ अथवा षोडषांक) + वार्तालाप आरम्भ करें + खोला जा रहा है… + हटाएँ + कार्यशाला आमन्त्रण + इस कार्यशाला के साथ जुडें + कार्यशाला + भूमिका + आप कार्यशाला को खोलेंगे एक सुरक्षित क्रमकाभ्यन्तर वीक्षक में इसके नियमों का पुनरीक्षण करने के लिए तथा जुडने की प्रक्रिया सम्पन्न करेंगे। यह आपकी अमेथिस्ट कुंचिका से प्रवेशांकन करेगा। कोई पारणशब्द नहीं। + जालवीक्षक में आगे बढें + यह एक मान्य बज्स्स आमन्त्रण योजक जैसे नहीं दिखता। + यह आमन्त्रण पुराना हो चुका है। एक नए के लिए पूछें। + वीक्षक में नियमों को स्वीकार करना सम्पन्न करें। तत्पश्चात यहाँ लौट आएँ। आपकी कार्यशाला प्रतीक्षा कर रही होगी। + मेरे कार्यशालाओं को खोलें + कार्यशाला प्रणालियों का आयात + आपके प्रणालियों का खोज… + आपके प्रणालियाँ इस कार्यशाला में + जोडें + सब को जोडें + जोडा गया + कोई प्रणालियाँ प्राप्त नहीं + आप इस पुनःप्रसारक के किसी भी प्रणाली के सदस्य प्रतीत नहीं होते अब तक। पहले वीक्षक में कार्यशाला आमन्त्रण स्वीकार करें। तत्पश्चात पुनःप्रयास करें। + प्रणालियाँ + मंच + कार्य मध्य… + सदस्य जोडें + इस कार्यशाला में लोगों को जोडें + आमन्त्रण योजक बनाएँ + आमन्त्रण योजक + बाँटें + अनुकृति + आमन्त्रण बनाने में असफल + ठीक + प्रणाली ताराचिह्नित करें + प्रणाली ताराचिह्न हटाएँ + कोई संवाद नहीं अब तक + + देखें %1$d और संवाद + देखें सभी %1$d संवाद + सन्देश वितरण आवृत किसी एक पुनःप्रसारक द्वारा इस सन्देश की स्वीकृति की प्रतीक्षा @@ -4027,6 +4176,11 @@ श्रमप्रमाण खनन उन पत्रों को दिखाता है जो अपने निप॰१३ श्रमप्रमाण का खनन कर रहे हैं जिससे कि वे सम्पन्न हो सके आप क्रमक छोडने के पश्चात। निरस्त + अभी भेजें + क्या खनन रोक दें। + यह पत्र अभी भी अपना श्रमप्रमाण का खनन कर रहा है। आप इसे अभी भेज सकते हैं श्रमप्रमाण के बिना अथवा इसे हटा सकते हैं। + श्रमप्रमाण बिना भेज दें + पत्र हटाएँ लगभग %1$s प्रति पत्र इस यन्त्र पर १ से॰ से न्यून diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 4a82d6278c..f722c1d1c7 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -391,6 +391,7 @@ Nem sikerült megtalálni ezt az üzenetet Minden átjátszó le lett kérve · koppintson ide a megtekintéshez + "Hiba a válaszok betöltésekor: " Próbálja újra Még nincsenek értesítések. @@ -1543,6 +1544,14 @@ Értesítés, ha valaki megemlíti vagy hivatkozik Önre egy bejegyzésben %1$s megemlítette Önt Új említések + + + + + + + + Bejövő hívások Értesítések a bejövő hang- és videóhívásokhoz diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index b891e0f259..615adddd1c 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -291,6 +291,7 @@ Pesan tidak ditemukan Telah dicari di semua relay · ketuk untuk melihat + "Gagal memuat balasan: " Coba lagi Belum ada notifikasi. @@ -1176,6 +1177,14 @@ Memberi tahu Anda saat seseorang menyebut atau mengutip Anda dalam postingan %1$s menyebut Anda Sebutan baru + + + + + + + + Panggilan masuk Notifikasi untuk panggilan suara dan video masuk diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 16a0556992..24f28f808e 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -291,6 +291,7 @@ Impossibile trovare questo messaggio Cercato in ogni relay · tocca per vedere + "Errore durante il caricamento delle risposte: " Prova di nuovo Nessuna notifica ancora. @@ -1186,6 +1187,14 @@ Ti notifica quando qualcuno ti menziona o ti cita in un post %1$s ti ha menzionato Nuove menzioni + + + + + + + + Chiamate in arrivo Notifiche per chiamate vocali e video in arrivo diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 02e0d059a1..2d34cd1fa5 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -297,6 +297,7 @@ このメッセージが見つかりませんでした すべての relay を検索済み · タップして表示 + "リプライ読み込みエラー: " 後ほどお試しください 通知はまだありません。 @@ -1203,6 +1204,14 @@ 誰かがあなたをメンションまたは引用したときに通知します %1$s があなたをメンションしました 新しいメンション + + + + + + + + 着信 音声・ビデオ通話の着信通知 diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index cadf07877c..1d46a2b2d7 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -291,6 +291,7 @@ 이 메시지를 찾을 수 없습니다 모든 relay 검색 완료 · 탭하여 보기 + "답글 불러오기 오류: " 다시 시도 아직 알림이 없습니다. @@ -1195,6 +1196,14 @@ 누군가 게시물에서 나를 언급하거나 인용하면 알립니다 %1$s님이 나를 언급했습니다 새 멘션 + + + + + + + + 수신 전화 수신 음성 및 영상 통화 알림 diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 905403df75..2549001991 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -304,6 +304,7 @@ Neizdevās atrast šo ziņojumu Pārbaudīti visi relay · pieskarieties, lai redzētu + "Kļūda, ielādējot atbildes: " Mēģināt vēlreiz Vēl nav paziņojumu. @@ -1218,6 +1219,14 @@ Paziņo, kad kāds jūs piemin vai citē ierakstā %1$s jūs pieminēja Jaunas pieminēšanas + + + + + + + + Ienākošie zvani Paziņojumi par ienākošajiem balss un video zvaniem diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 538ea1ed90..78223ad862 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -305,6 +305,7 @@ Kon dit bericht niet vinden Alle relays doorzocht · tik om te zien + "Fout bij laden reacties:" Opnieuw proberen Nog geen meldingen. @@ -1402,6 +1403,14 @@ Melding wanneer iemand je vermeldt of citeert in een bericht %1$s heeft je vermeld Nieuwe vermeldingen + + + + + + + + Inkomende oproepen Meldingen voor inkomende spraak- en video-oproepen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index ad92657261..4e1865c437 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -420,6 +420,7 @@ Nie można znaleźć tej wiadomości Przeszukano wszystkie transmitery · kliknij, by wyświetlić + "Błąd wczytywania odpowiedzi: " Spróbuj ponownie Brak powiadomień. @@ -1820,6 +1821,14 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Powiadamia Cię, gdy ktoś wspomni o Tobie lub zacytuje Cię w poście %1$s wspomniał o Tobie Nowe wzmianki + + + + + + + + Połączenia przychodzące Powiadomienia o przychodzących połączeniach głosowych i wideo diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index fdb40268d6..17f0f340ec 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -20,6 +20,10 @@ +%1$d\nresposta +%1$d\nrespostas + + Denunciado por %1$d pessoa que você segue + Denunciado por %1$d pessoas que você segue + postagem não encontrada 👀 Imagem do Canal @@ -406,6 +410,12 @@ Não foi possível encontrar esta mensagem Pesquisado em todos os relays · toque para ver + Quem denunciou isto? + Dispensar + Ocultar + Alguém que você segue sinalizou esta conta + Denunciado + "Erro ao carregar respostas" Tente novamente Ainda não há notificações. @@ -1733,6 +1743,9 @@ por %1$s Responder Marcar como lida + Eu + Nova mensagem + Você foi adicionado a %1$s Novas mensagens Novos zaps Reações @@ -1754,6 +1767,47 @@ Notifica você quando alguém menciona ou cita você em uma publicação %1$s mencionou você Novas menções + + Mensagens + Pagamentos + Conteúdo + Desenvolvedor + Jogos + + Notifica você quando alguém reposta sua publicação + %1$s repostou sua publicação + Novos reposts + + Mídia + Notifica você quando alguém menciona você em uma foto ou vídeo + %1$s compartilhou uma foto + %1$s compartilhou um vídeo + Nova mídia + + Artigos e destaques + Notifica você quando alguém menciona ou destaca você em um artigo + %1$s mencionou você em um artigo + %1$s destacou seu artigo + Novos artigos + + Código e Git + Notifica você sobre issues, patches e pull requests + %1$s abriu um issue + %1$s enviou um patch + %1$s abriu um pull request + %1$s atualizou um pull request + Nova atividade de código + + Selos + Notifica você quando você ganha um selo + Você ganhou um selo + Concedido por %1$s + Novos selos + + Nutzap de %1$s + Zap onchain de %1$s + + %1$s fez uma pergunta Chamadas recebidas Notificações de chamadas de voz e vídeo recebidas @@ -2004,6 +2058,11 @@ Aparência Mídia e Dados Geral + Conexão + Rotear pelo Tor + Seções e feeds + Abas visíveis + Lembretes Conectar Carteira Linguagem Tema @@ -2299,6 +2358,9 @@ Explore os grupos hospedados em um relay. Cole um endereço de relay ou escolha um abaixo. Endereço do relay Explorar + Não é possível acessar este relay pelo Tor + %1$s não está respondendo pelo Tor — seu host pode estar bloqueando nós de saída do Tor. Conectar a ele via clearnet? + Usar clearnet Relays em que você está Relays populares Nenhuma mensagem ainda @@ -3004,6 +3066,9 @@ %1$s criou o canal %2$s %1$s criou o canal %1$s atualizou o perfil do canal + Canais + Fóruns + Adicionar membro Entrega da mensagem Fechar Aguardando um relay aceitar esta mensagem @@ -4019,6 +4084,11 @@ Mineração de prova de trabalho Mostra publicações que ainda estão minerando sua prova de trabalho NIP-13 para que possam terminar depois que você sair do app. Cancelar + Enviar agora + Parar de minerar? + Esta publicação ainda está minerando seu proof of work. Você pode enviá-la agora sem proof of work ou descartá-la. + Enviar sem PoW + Descartar publicação ≈ %1$s por publicação neste dispositivo <1 s diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index d36024ccd0..353e1ac6a2 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -295,6 +295,7 @@ Não foi possível encontrar esta mensagem Pesquisado em todos os relays · toque para ver + "Erro ao carregar respostas" Tente novamente Ainda não há notificações. @@ -1196,6 +1197,14 @@ Notifica você quando alguém menciona ou cita você em uma publicação %1$s mencionou você Novas menções + + + + + + + + Chamadas recebidas Notificações de chamadas de voz e vídeo recebidas diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index cd92e288f1..c09c990a76 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -303,6 +303,7 @@ Не удалось найти это сообщение Поиск по всем relay · нажмите, чтобы увидеть + "Не удалось загрузить ответы: " Повторить Уведомлений пока нет. @@ -1235,6 +1236,14 @@ Уведомляет вас, когда кто-то упоминает или цитирует вас в посте %1$s упомянул(а) вас Новые упоминания + + + + + + + + Входящие звонки Уведомления о входящих голосовых и видеозвонках diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index bf149ae132..7bc4aaeef7 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -294,6 +294,7 @@ Не удалось найти это сообщение Поиск по всем relay · нажмите, чтобы увидеть + "Ошибка загрузки ответов: " Попробовать снова Уведомлений пока нет. @@ -1221,6 +1222,14 @@ Уведомляет вас, когда кто-то упоминает или цитирует вас в посте %1$s упомянул(а) вас Новые упоминания + + + + + + + + Входящие звонки Уведомления о входящих голосовых и видеозвонках diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index f9227271c8..20d5ebf08f 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -375,6 +375,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ne nejdem tega sporočila Preiskani vsi releji · dotakni se za ogled + "Napaka pri nalaganju odgovorov: " Poskusi ponovno Ni še obvestil. @@ -1544,6 +1545,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Obvesti vas, ko vas nekdo omeni ali citira v objavi %1$s vas je omenil Nove omembe + + + + + + + + Dohodni klici Obvestila o dohodnih glasovnih in video klicih diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 1b259496ac..dd171a6c3e 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -296,6 +296,7 @@ Poruka nije pronađena Pretraženi svi relay-i · dodirnite za prikaz + "Greška pri učitavanju odgovora: " Pokušaj ponovo Još nema obaveštenja. @@ -1207,6 +1208,14 @@ Obaveštava vas kada vas neko pomene ili citira u objavi %1$s vas je pomenuo Nova pominjanja + + + + + + + + Dolazni pozivi Obaveštenja za dolazne glasovne i video pozive diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 3d9f675406..9ad4a48e7b 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -20,6 +20,10 @@ +%1$d\nsvar +%1$d\nsvar + + Anmäld av %1$d person du följer + Anmäld av %1$d personer du följer + Inlägg hittades inte 👀 Kanal bild @@ -406,6 +410,12 @@ Kunde inte hitta detta meddelande Sökte på alla relayer · tryck för att visa + Vem anmälde detta? + Avfärda + Dölj + Någon du följer har flaggat detta konto + Anmäld + "Det gick inte att läsa in svar: " Försök igen Inga aviseringar ännu. @@ -1733,6 +1743,9 @@ till %1$s Svara Markera som läst + Jag + Nytt meddelande + Du har lagts till i %1$s Nya meddelanden Nya zaps Reaktioner @@ -1754,6 +1767,48 @@ Meddelar dig när någon nämner eller citerar dig i ett inlägg %1$s nämnde dig Nya omnämnanden + + Meddelanden + Socialt + Betalningar + Innehåll + Utvecklare + Spel + + Reposter + Meddelar dig när någon repostar ditt inlägg + %1$s repostade ditt inlägg + Nya reposter + + Meddelar dig när någon nämner dig i ett foto eller en video + %1$s delade ett foto + %1$s delade en video + Nya media + + Artiklar & markeringar + Meddelar dig när någon nämner eller markerar dig i en artikel + %1$s nämnde dig i en artikel + %1$s markerade din artikel + Nya artiklar + + Kod & Git + Meddelar dig om issues, patchar och pull requests + %1$s öppnade ett issue + %1$s skickade en patch + %1$s öppnade en pull request + %1$s uppdaterade en pull request + Ny kodaktivitet + + Märken + Meddelar dig när du får ett märke + Du fick ett märke + Tilldelat av %1$s + Nya märken + + Nutzap från %1$s + Onchain-zap från %1$s + + %1$s ställde en fråga Inkommande samtal Aviseringar för inkommande röst- och videosamtal @@ -2004,6 +2059,11 @@ Utseende Media & data Allmänt + Anslutning + Dirigera via Tor + Sektioner & flöden + Synliga flikar + Påminnelser Plånboksanslut Språk Tema @@ -2299,6 +2359,9 @@ Bläddra bland grupperna som hostas på ett relä. Klistra in en reläadress eller välj en nedan. Reläadress Bläddra + Kan inte nå denna relay via Tor + %1$s svarar inte via Tor — dess värd kan blockera Tor-exitnoder. Anslut till den via clearnet istället? + Använd clearnet Reläer du är på Populära reläer Inga meddelanden ännu @@ -3004,6 +3067,9 @@ %1$s skapade kanalen %2$s %1$s skapade kanalen %1$s uppdaterade kanalprofilen + Kanaler + Forum + Lägg till medlem Meddelandeleverans Stäng Väntar på att ett relä ska acceptera detta meddelande @@ -4019,6 +4085,11 @@ Mining av bevis på arbete Visar inlägg som fortfarande minar sitt NIP-13-bevis på arbete så att de kan slutföras efter att du lämnat appen. Avbryt + Skicka nu + Sluta utvinna? + Detta inlägg utvinner fortfarande sitt proof of work. Du kan skicka det nu utan proof of work, eller kasta det. + Skicka utan PoW + Kasta inlägg ≈ %1$s per inlägg på den här enheten <1 s diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index 348d31edf4..9c6dea4409 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -296,6 +296,7 @@ Haiwezi kupata ujumbe huu Imetafuta kila relay · gusa kuona + "Kosa katika kupakia majibu: " Jaribu tena Hakuna arifa bado. @@ -1204,6 +1205,14 @@ Inakuarifu mtu anapokutaja au kukununulia kwenye chapisho %1$s amekutaja Kutajwa mapya + + + + + + + + Simu zinazoingia Arifa za simu za sauti na video zinazoingia diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 98d03911c0..6d153627a3 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -299,6 +299,7 @@ இந்த செய்தியை கண்டுபிடிக்க முடியவில்லை ஒவ்வொரு relay யும் தேடப்பட்டது · காண தட்டவும் + "பதில்களை ஏற்றுவதில் பிழை: " மறுமுயற்சி செய் இன்னும் அறிவிப்புகள் எதுவும் இல்லை. @@ -1207,6 +1208,14 @@ யாரோ ஒரு இடுகையில் உங்களை குறிப்பிடும்போது அல்லது மேற்கோள் காட்டும்போது அறிவிக்கிறது %1$s உங்களை குறிப்பிட்டார் புதிய குறிப்பீடுகள் + + + + + + + + வரும் அழைப்புகள் வரும் குரல் மற்றும் வீடியோ அழைப்புகளுக்கான அறிவிப்புகள் diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 0f3af6a697..bb567a4a21 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -297,6 +297,7 @@ ไม่พบข้อความนี้ ค้นหาทุก relay แล้ว · แตะเพื่อดู + "ไม่สามารถโหลดการตอบกลับ: " ลองอีกครั้ง ยังไม่มีการแจ้งเตือน @@ -1181,6 +1182,14 @@ แจ้งเตือนเมื่อมีคนกล่าวถึงหรืออ้างอิงคุณในโพสต์ %1$s กล่าวถึงคุณ การกล่าวถึงใหม่ + + + + + + + + สายเรียกเข้า การแจ้งเตือนสำหรับสายโทรด้วยเสียงและวิดีโอที่เข้ามา diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index baa00ac0f6..e084aaa792 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -299,6 +299,7 @@ Bu mesaj bulunamadı Her relay arandı · görmek için dokun + "Yanıtları yüklemekte hata: " Tekrar dene Henüz bildirim yok. @@ -1211,6 +1212,14 @@ Birisi sizi bir gönderide bahsettiğinde veya alıntıladığında bildirim alırsınız %1$s sizi bahsetti Yeni bahsedilmeler + + + + + + + + Gelen aramalar Gelen sesli ve görüntülü aramalar için bildirimler diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 12fb2961de..4643b0a977 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -301,6 +301,7 @@ Не вдалося знайти це повідомлення Перевірено всі relay · натисніть, щоб побачити + "Не вдалося завантажити відповіді: " Повторити Поки що немає сповіщень. @@ -1231,6 +1232,14 @@ Сповіщає вас, коли хтось згадує або цитує вас у пості %1$s згадав(ла) вас Нові згадки + + + + + + + + Вхідні дзвінки Сповіщення про вхідні голосові та відеодзвінки diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 20ff611c65..a49d51be7c 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -301,6 +301,7 @@ Bu xabarni topa olmadim Barcha relay lar qidirildi · ko\'rish uchun bosing + "Javoblarni yuklashda xato: " Qayta urinib ko\'ring Hali bildirishnomalar yo\'q. @@ -1211,6 +1212,14 @@ Kimdir postda sizni eslatsa yoki iqtibos keltirsa xabardor qiladi %1$s sizni eslatdi Yangi eslatmalar + + + + + + + + Kiruvchi qo\'ng\'iroqlar Kiruvchi ovozli va video qo\'ng\'iroqlar uchun bildirishnomalar diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index e25d036469..75c612de36 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -290,6 +290,7 @@ Không tìm thấy tin nhắn này Đã tìm trên mọi relay · nhấn để xem + "Lỗi khi tải trả lời: " Thử lại Chưa có thông báo nào. @@ -1186,6 +1187,14 @@ Thông báo khi có người nhắc đến hoặc trích dẫn bạn trong bài đăng %1$s đã nhắc đến bạn Lượt nhắc đến mới + + + + + + + + Cuộc gọi đến Thông báo cho cuộc gọi thoại và video đến diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index d5e0280a64..f6b2e062f9 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -383,6 +383,7 @@ 找不到这条消息 搜索了每个中继 · 点击查看 + "加载回复出错:" 重试 暂无通知 @@ -1651,6 +1652,14 @@ 当某人在帖子中提到或引用你时,通知你 %1$s 提到了你 新提及 + + + + + + + + 来电 语音和视频来电通知 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index b37cb2fc56..5a6da17336 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -299,6 +299,7 @@ 找不到这条消息 搜索了每个中继 · 点击查看 + "加載回覆出錯:" 重試 暂无通知 @@ -1211,6 +1212,14 @@ 当某人在帖子中提到或引用你时,通知你 %1$s 提到了你 新提及 + + + + + + + + 来电 语音和视频来电通知 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index e6d9084701..09d3abbbc0 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -299,6 +299,7 @@ 找不到这条消息 搜索了每个中继 · 点击查看 + "加载回复出错:" 重试 暂无通知 @@ -1211,6 +1212,14 @@ 当某人在帖子中提到或引用你时,通知你 %1$s 提到了你 新提及 + + + + + + + + 来电 语音和视频来电通知 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index f80e499011..c775fc71a9 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -295,6 +295,7 @@ 找不到此訊息 已搜尋所有 relay · 點擊查看 + "加載回覆出錯:" 重試 尚無通知。 @@ -1202,6 +1203,14 @@ 當有人在貼文中提及或引用您時通知您 %1$s 提及了您 新提及 + + + + + + + + 來電 語音及視訊通話來電通知 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2bcba65691..12c980bf3c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -417,6 +417,8 @@ In thread Thread + + Send image %1$d reply @@ -1671,6 +1673,10 @@ Blossom sync Shows progress while copying your files across your Blossom servers. No stored files found on your Blossom servers. + Stored on + File details + On all servers + Not on all servers Mirror to missing Delete from… Delete from %1$s @@ -1688,6 +1694,33 @@ Report blob Reason (optional) Send + More actions + Import files… + Import files + Check other Blossom servers for files you\'ve uploaded elsewhere and copy them into your own servers. + Servers to check + Enable all + Disable all + Or paste a server address + Check servers + Checking… + Couldn\'t reach this server + No new files were found on the selected servers. + Add your own Blossom servers first, so there\'s somewhere to copy the imported files into. + Manage my servers + A file sync is already running. Try importing again once it finishes. + + %1$d file + %1$d files + + + Found %1$d file to import into your servers. + Found %1$d files to import into your servers. + + + Import %1$d file + Import %1$d files + Recommended Media Servers Amethyst\'s default list. You can add them individually or add the list. @@ -1875,6 +1908,9 @@ Reply Mark Read + Me + New message + You\'ve been added to %1$s New messages New zaps @@ -1902,6 +1938,62 @@ %1$s mentioned you New mentions + + Messages + Social + Payments + Content + Developer + Games + + + RepostsID + Reposts + Notifies you when somebody reposts your post + %1$s reposted your post + New reposts + + + MediaID + Media + Notifies you when somebody mentions you in a photo or video + %1$s shared a photo + %1$s shared a video + New media + + + ArticlesID + Articles & Highlights + Notifies you when somebody mentions or highlights you in an article + %1$s mentioned you in an article + %1$s highlighted your article + New articles + + + CodeID + Code & Git + Notifies you about issues, patches, and pull requests + %1$s opened an issue + %1$s sent a patch + %1$s opened a pull request + %1$s updated a pull request + New code activity + + + BadgesID + Badges + Notifies you when you earn a badge + You earned a badge + Awarded by %1$s + New badges + + + Nutzap from %1$s + Onchain zap from %1$s + + + %1$s asked a question + Incoming calls Notifications for incoming voice and video calls @@ -3342,6 +3434,9 @@ Canvas (Markdown) Edit Editing message + Edit + (edited) + Editing message %1$s is typing… %1$s and %2$s are typing… Several people are typing… diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 064d951d04..a37f67260d 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -94,8 +94,8 @@ class PushNotificationReceiverService : FirebaseMessagingService() { PushNotificationUtils.checkAndInit(token, LocalPreferences.allSavedAccounts()) { Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady()) } - NotificationUtils.getOrCreateZapChannel(applicationContext) - NotificationUtils.getOrCreateDMChannel(applicationContext) + NotificationCategory.ZAP.ensureChannel(applicationContext) + NotificationCategory.DIRECT_MESSAGE.ensureChannel(applicationContext) } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index fb355c45cf..20a410b170 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -33,6 +33,7 @@ object PushNotificationUtils { var lastToken: String? = null var hasInit: List? = null + @Suppress("DEPRECATION") // FirebaseMessaging.token is deprecated in the current SDK. suspend fun checkAndInit( accounts: List, okHttpClient: (String) -> OkHttpClient, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 3b149f1359..6632a51f0d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import io.mockk.every import io.mockk.mockk @@ -48,7 +49,7 @@ import java.util.UUID /** * The Buzz dialect of NIP-29 in `LocalCache`: dialect detection off VERIFIED events, * timeline attachment into the group's (stable, never-swapped) `RelayGroupChannel`, and - * the kind-40003 edit overlay held in `BuzzWorkspaceStates` keyed by the channel id. + * the kind-40003 edit overlay anchored on the message it edits (`Note.edits`). */ class BuzzWorkspaceChannelTest { private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!! @@ -147,9 +148,11 @@ class BuzzWorkspaceChannelTest { LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false) LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false) - val state = BuzzWorkspaceStates.getIfExists(channelId)!! - assertEquals("newest edit wins regardless of arrival order", "the fix", state.effectiveContentFor(original.id)) - assertEquals(edit2.id, state.editFor(original.id)?.idHex) + // The edits are anchored on the message they edit (Note.edits); newest by created_at wins. + val target = LocalCache.getNoteIfExists(original.id)!! + val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L } + assertEquals("newest edit wins regardless of arrival order", "the fix", newest?.event?.content) + assertEquals(edit2.id, newest?.idHex) val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))!! assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit1.id)) @@ -157,10 +160,10 @@ class BuzzWorkspaceChannelTest { } @Test - fun overlayIsKeyedByChannelIdSoOwnSendsWithNoRelayLand() = + fun ownSendsWithNoRelayStillOverlayTheirMessage() = runBlocking { - // An edit consumed with a null provenance relay (own optimistic send) must - // still record its overlay — the registry is keyed by channel id, not relay. + // An edit consumed with a null provenance relay (own optimistic send) must still + // overlay its message — it is anchored on the message note, independent of any relay. val channelId = newChannelId() val original = streamMessage(channelId, "original") LocalCache.checkDeletionAndConsume(original, null, true) @@ -171,11 +174,64 @@ class BuzzWorkspaceChannelTest { ) LocalCache.checkDeletionAndConsume(edit, null, true) - assertEquals("edited offline", BuzzWorkspaceStates.getIfExists(channelId)?.effectiveContentFor(original.id)) + val target = LocalCache.getNoteIfExists(original.id)!! + val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L } + assertEquals("edited offline", newest?.event?.content) } @Test - fun pruneDropsOverlaysForMessagesNoLongerInTheChannel() = + fun aForgedEditByAnotherAuthorNeverOverridesTheMessage() = + runBlocking { + val channelId = newChannelId() + val original = streamMessage(channelId, "the truth") // authored by `signer` + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + + // Mallory publishes a well-formed, VERIFIED 40003 targeting someone else's message. + val mallory = NostrSignerInternal(KeyPair()) + val forged = + mallory.sign( + StreamMessageEditEvent.build(channelId, original.id, "lies", createdAt = original.createdAt + 100), + ) + LocalCache.checkDeletionAndConsume(forged, buzzRelay, false) + + // The forged edit still lands in the store (it is a valid signed event)… + val target = LocalCache.getNoteIfExists(original.id)!! + assertTrue("the forged edit is stored", target.edits.any { it.idHex == forged.id }) + // …but the overlay only applies the ORIGINAL author's edits, so it is ignored. + assertNull( + "an edit by a different author must never override the message", + target.latestBuzzEdit(), + ) + + // The real author's own later edit does apply. + val real = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 200)) + LocalCache.checkDeletionAndConsume(real, buzzRelay, false) + assertEquals("the fix", target.latestBuzzEdit()?.event?.content) + } + + @Test + fun deletingAnEditUnlinksItFromTheMessage() = + runBlocking { + val channelId = newChannelId() + val original = streamMessage(channelId, "typo") + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "fixed", createdAt = original.createdAt + 5)) + LocalCache.checkDeletionAndConsume(edit, buzzRelay, false) + + val target = LocalCache.getNoteIfExists(original.id)!! + assertEquals("fixed", target.latestBuzzEdit()?.event?.content) + + // The author deletes their own edit (NIP-09). It must stop overlaying the message and + // be unlinked from Note.edits, not linger as a stale overlay. + val deletion = signer.sign(DeletionEvent.build(listOf(edit))) + LocalCache.checkDeletionAndConsume(deletion, buzzRelay, false) + + assertNull("a deleted edit must no longer overlay its message", target.latestBuzzEdit()) + assertTrue("the deleted edit is unlinked from the message", target.edits.none { it.idHex == edit.id }) + } + + @Test + fun pruningAMessageReleasesItsEdits() = runBlocking { val channelId = newChannelId() val original = streamMessage(channelId, "will be pruned") @@ -183,11 +239,12 @@ class BuzzWorkspaceChannelTest { val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "edit", createdAt = original.createdAt + 5)) LocalCache.checkDeletionAndConsume(edit, buzzRelay, false) - val state = BuzzWorkspaceStates.getIfExists(channelId)!! - assertNotNull(state.editFor(original.id)) + val target = LocalCache.getNoteIfExists(original.id)!! + assertTrue("the edit is anchored on its message", target.edits.any { it.idHex == edit.id }) - // Simulate the message having been reaped from the channel. - state.pruneEdits(emptySet()) - assertNull("overlay for a pruned message must be dropped", state.editFor(original.id)) + // An edit lives in Note.edits, so reaping the message releases the overlay with it — + // no separate side store to prune. + target.clearChildLinks() + assertTrue("overlay for a pruned message must be dropped", target.edits.isEmpty()) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt new file mode 100644 index 0000000000..7b2783a490 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt @@ -0,0 +1,140 @@ +/* + * 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.os.Looper +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [NotificationContent.resolveMentions] rewrites inline `nostr:npub` / `nostr:nprofile` tokens to a + * cited user's `@DisplayName` and returns those users so a renderer can observe them until their kind:0 + * loads. This is what makes a notification body flip from `@npub1abc…` to `@RealName` in place, matching + * the author-name enrichment the shade already does for the title. + */ +class NotificationContentMentionTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example/")!! + private val cited = NostrSignerInternal(KeyPair()) + + @Before + fun setup() { + // LocalCache.consume refuses the main thread; plain JVM tests have no Looper (null == null reads + // as "main"). Distinct mocks make it a worker thread. See BuzzDmNotificationResolutionTest. + mockkStatic(Looper::class) + every { Looper.myLooper() } returns mockk() + every { Looper.getMainLooper() } returns mockk() + } + + @After + fun tearDown() { + unmockkStatic(Looper::class) + } + + @Test + fun `an unknown cited npub is replaced with its short handle and returned for observation`() { + val npub = NPub.create(cited.pubKey) + val resolved = NotificationContent.resolveMentions("hey nostr:$npub how are you") + + assertFalse("the raw npub token is gone", resolved.text.contains(npub)) + assertTrue("it is replaced by an @handle", resolved.text.contains("@")) + assertEquals("the cited user is returned for the enrichment window", 1, resolved.citedUsers.size) + assertEquals(cited.pubKey, resolved.citedUsers.first().pubkeyHex) + } + + @Test + fun `a cited npub whose kind0 is loaded renders the real display name`() = + runBlocking { + val metadata = cited.sign(MetadataEvent.createNew(name = "Alice")) + LocalCache.checkDeletionAndConsume(metadata, relay, false) + + val npub = NPub.create(cited.pubKey) + val resolved = NotificationContent.resolveMentions("gm nostr:$npub") + + assertTrue("the display name fills in", resolved.text.contains("@Alice")) + assertEquals(1, resolved.citedUsers.size) + } + + @Test + fun `event references are left untouched and not treated as cited users`() { + val nevent = "nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtnwda" + val resolved = NotificationContent.resolveMentions("look at nostr:$nevent here") + + assertTrue("the event ref stays verbatim", resolved.text.contains(nevent)) + assertTrue("no user was cited", resolved.citedUsers.isEmpty()) + } + + @Test + fun `plain text with no mentions passes through unchanged`() { + val resolved = NotificationContent.resolveMentions("just a normal note with no tags") + + assertEquals("just a normal note with no tags", resolved.text) + assertTrue(resolved.citedUsers.isEmpty()) + } + + @Test + fun `an inline image link is pulled out as the big picture and stripped from the body`() { + val rendered = NotificationContent.renderNoteText("look at this https://example.com/cat.jpg amazing") + + assertEquals("https://example.com/cat.jpg", rendered.imageUrl) + assertEquals("look at this amazing", rendered.text) + } + + @Test + fun `an image url on its own line becomes the big picture while the caption stays the body`() { + val rendered = NotificationContent.renderNoteText("Check out my new photo\nhttps://example.com/pic.png") + + assertEquals("https://example.com/pic.png", rendered.imageUrl) + assertEquals("Check out my new photo", rendered.text) + } + + @Test + fun `trailing punctuation after an image url is trimmed`() { + assertEquals("https://example.com/a.webp", NotificationContent.firstImageUrl("nice pic https://example.com/a.webp.")) + } + + @Test + fun `a video link is not treated as an image and stays in the text`() { + val rendered = NotificationContent.renderNoteText("watch https://example.com/clip.mp4 now") + + assertEquals(null, rendered.imageUrl) + assertTrue(rendered.text.contains("https://example.com/clip.mp4")) + } + + @Test + fun `a bare filename without a url scheme is not mistaken for an image`() { + assertEquals(null, NotificationContent.firstImageUrl("my file is called cat.jpg locally")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SessionRegistryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SessionRegistryTest.kt new file mode 100644 index 0000000000..35202d9a7f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/SessionRegistryTest.kt @@ -0,0 +1,165 @@ +/* + * 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 org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class SessionRegistryTest { + private val dropped = mutableListOf() + + private fun registry(maxIdle: Int) = SessionRegistry(maxIdle) { dropped.add(it) } + + @Test + fun evictionDropsTheOldestExactlyOnce() { + val registry = registry(maxIdle = 1) + registry.register("a", "A") + registry.register("b", "B") + assertEquals(listOf("A"), dropped) + } + + @Test + fun playingSessionSurvivesEviction() { + val registry = registry(maxIdle = 1) + registry.register("a", "A") + registry.setPlaying("a", true) + registry.register("b", "B") + assertEquals(emptyList(), dropped) + } + + // The replacement guard. setPlaying(false) re-puts the SAME entry, which fires + // entryRemoved(newValue === oldValue). Treating that as a drop would retire the + // session we are trying to keep. + @Test + fun pauseDoesNotDropTheSessionItIsKeeping() { + val registry = registry(maxIdle = 2) + registry.register("a", "A") + registry.setPlaying("a", true) + registry.setPlaying("a", false) + assertEquals(emptyList(), dropped) + } + + @Test + fun pausedSessionBecomesEvictableAgain() { + val registry = registry(maxIdle = 1) + registry.register("a", "A") + registry.setPlaying("a", true) + registry.register("b", "B") // A survives, but leaves idle + registry.setPlaying("a", false) // A re-enters idle and displaces B + assertEquals(listOf("B"), dropped) + } + + // Leak path 2: an explicit release must drop even while playing. + @Test + fun explicitDropOfPlayingSessionDropsExactlyOnce() { + val registry = registry(maxIdle = 2) + registry.register("a", "A") + registry.setPlaying("a", true) + registry.drop("a") + assertEquals(listOf("A"), dropped) + } + + // Leak path 2, the exact case the old cache.remove(id) missed: already evicted + // from idle while playing, so there is no cache entry left to trigger the callback. + @Test + fun explicitDropAfterEvictionWhilePlayingStillDrops() { + val registry = registry(maxIdle = 1) + registry.register("a", "A") + registry.setPlaying("a", true) + registry.register("b", "B") + registry.drop("a") + assertEquals(listOf("A"), dropped) + } + + @Test + fun dropAllDropsEveryEntryExactlyOnce() { + val registry = registry(maxIdle = 3) + registry.register("a", "A") + registry.register("b", "B") + registry.register("c", "C") + registry.setPlaying("b", true) + registry.dropAll() + assertEquals(3, dropped.size) + assertEquals(setOf("A", "B", "C"), dropped.toSet()) + } + + @Test + fun dropAllIncludesSessionEvictedWhilePlaying() { + val registry = registry(maxIdle = 1) + registry.register("a", "A") + registry.setPlaying("a", true) + registry.register("b", "B") + registry.dropAll() + assertEquals(2, dropped.size) + assertEquals(setOf("A", "B"), dropped.toSet()) + } + + @Test + fun getFindsPlayingAndIdleEntries() { + val registry = registry(maxIdle = 2) + registry.register("a", "A") + registry.register("b", "B") + registry.setPlaying("a", true) + assertSame("A", registry.get("a")) + assertSame("B", registry.get("b")) + assertNull(registry.get("missing")) + } + + @Test + fun droppingUnknownIdIsANoOp() { + val registry = registry(maxIdle = 2) + assertNull(registry.drop("missing")) + assertEquals(emptyList(), dropped) + } + + @Test + fun enumerationSeesIdleAndPlayingSeparately() { + val registry = registry(maxIdle = 2) + registry.register("a", "A") + registry.register("b", "B") + registry.setPlaying("a", true) + assertEquals(setOf("A", "B"), registry.idleSnapshot().toSet()) + assertEquals(listOf("A"), registry.playingEntries()) + } + + // The replacement guard uses !== (identity, not equality). This test pins that distinction: + // replacing an entry with an equal-but-not-identical instance must drop the old one. + // Changing !== to != would break this test, even though all existing tests would pass. + @Test + fun replacementWithEqualButDifferentInstanceDropsOldEntry() { + val registry = registry(maxIdle = 2) + val original = "A" + registry.register("a", original) + + // Create a new String instance that is equal to but not identical to the original. + // String literals are interned, so we must construct it explicitly. + val replacement = buildString { append("A") } + assertEquals("Equal values", original, replacement) + assertNotSame("Not the same instance", original, replacement) + + // Registering the replacement should drop the original. + registry.register("a", replacement) + assertEquals(listOf(original), dropped) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 6fe7308eff..f37c1b5509 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -300,7 +300,7 @@ class Context( .header("Accept", "application/nostr+json") .build() okhttp.newCall(request).execute().use { resp -> - resp.body?.string()?.let { Nip11RelayInformation.fromJson(it) } + resp.body.string().let { Nip11RelayInformation.fromJson(it) } } }.getOrNull() } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt index 7e3c37b289..60562864dc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -380,7 +380,7 @@ object BuzzCommands { .get() .build(), ).execute() - .use { it.code to (it.body?.string() ?: "") } + .use { it.code to it.body.string() } } private suspend fun httpPost( @@ -392,7 +392,7 @@ object BuzzCommands { withContext(Dispatchers.IO) { val builder = Request.Builder().url(url).post(body.toRequestBody(jsonMedia)) if (auth != null) builder.header("Authorization", auth) - http.newCall(builder.build()).execute().use { it.code to (it.body?.string() ?: "") } + http.newCall(builder.build()).execute().use { it.code to it.body.string() } } /** `buzz post RELAY GID ` → publishes a kind-40002 stream message with an `h` tag. */ @@ -578,7 +578,7 @@ object BuzzCommands { .groupBy { it.slug() } .values .mapNotNull { versions -> versions.maxByOrNull { it.createdAt } } - .sortedBy { it.personaOrNull()?.displayName ?: it.slug() ?: "" } + .sortedBy { it.personaOrNull()?.displayName ?: it.slug() } .map { val content = it.personaOrNull() mapOf( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt index 11ac0dd2aa..8dbf63890c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt @@ -113,7 +113,7 @@ object GitPatchCommands { "event_id" to signed.id, "kind" to signed.kind, "repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag), - "subject" to (signed as? GitPatchEvent)?.subject(), + "subject" to signed.subject(), ) + RawEventSupport.ackFields(ack), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt index 02e5a6655c..e1c9664660 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt @@ -133,6 +133,7 @@ object GitReadCommands { * comment trees and PR-update (1619) events (which use NIP-22 uppercase `E`) * are out of scope here. */ + @Suppress("DEPRECATION") // legacy kind:1622 GitReplyEvent is read for backward compatibility. suspend fun thread( dataDir: DataDir, rest: Array, diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index bc603dacb0..74b279d621 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -64,6 +64,9 @@ actual class SecureKeyStorage private actual constructor() { } } + // androidx.security.crypto is deprecated with no drop-in successor; migrating the + // on-disk key store is a separate, security-sensitive effort. + @Suppress("DEPRECATION") private val masterKey: MasterKey by lazy { MasterKey .Builder(appContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS) @@ -71,6 +74,7 @@ actual class SecureKeyStorage private actual constructor() { .build() } + @Suppress("DEPRECATION") private val encryptedPrefs by lazy { EncryptedSharedPreferences.create( appContext, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 4847685fa0..fa23a7ee33 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -255,6 +255,44 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * Builds an encrypted-seal thread-reply wrap carrying one or more encrypted image [imetas] + * (kind-1111 NIP-22 comment on [parent]) on the [channel] plane — the minichat image path. + */ + suspend fun buildChannelImageReply( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + parent: Event, + text: String, + imetas: List, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.imageReply(authorSigner.pubKey, channelId, epoch, text, imetas, parent, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + + /** + * Builds an encrypted-seal **edit** wrap (kind-3302 [ChannelChat.edit] of [target]) on the + * [channel] plane. [newText] replaces [target]'s content on receivers that apply the edit overlay; + * only the original author's edits take effect, so restrict callers to their own messages. + */ + suspend fun buildChannelEdit( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + target: Event, + newText: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ suspend fun buildChannelReaction( authorSigner: NostrSigner, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 98096f4ce8..d9e781eed7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -134,6 +134,66 @@ object ConcordSubscriptionPlanner { return current + historical } + /** + * Per-channel **preview / catch-up** filters for every channel of a folded community, so its + * channel list and the Messages inbox fill in without the user opening each channel one by one. + * One filter per channel (never collapsed like the live [channelPlaneSubs], where the relay caps + * every channel's planes as one result and starves the quiet channels); each channel's authors + * span every held epoch so it reaches across a CORD-06 Refounding, and only stored wraps + * ([ConcordStreamEnvelope.KIND_WRAP]) count — the ephemeral 21059 is a typing heartbeat. + * + * How much to pull is per-channel, from [lastReadFor] (0 = never read): + * - **Read before** → everything since the last-read time (capped at [catchUpLimit]), so the + * unread badge is accurate and the missed messages are already in cache when the channel opens. + * The last-read value *is a message's `created_at`* (the read marker is stamped with the seen + * message's timestamp, not wall-clock), so the window is `since = lastRead - 1`: that + * re-includes the last-read message itself, guaranteeing a preview even for a fully caught-up + * channel — and since the unread count is a strict `created_at > lastRead`, that boundary + * message is not miscounted as unread. + * - **Never read** → the [previewLimit] newest wraps — enough for a preview and a rough sense of + * how busy the channel is (the unread badge counts what we pulled), without dragging a whole + * channel's backlog into a community the user has only glanced at. The full history still pages + * in on open (the backward history pager). + * + * The returned filters `groupByRelay` into one REQ per relay, one filter per channel. + */ + fun channelPreviewFilters( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + lastReadFor: (channelIdHex: String) -> Long, + catchUpLimit: Int = 50, + previewLimit: Int = 10, + ): List { + val authorsByChannel = LinkedHashMap>() + val relaysByChannel = HashMap>() + for (sub in channelPlaneSubs(entry, state)) { + val channelId = sub.channelId ?: continue + authorsByChannel.getOrPut(channelId) { LinkedHashSet() }.add(sub.pubKeyHex) + relaysByChannel[channelId] = sub.relays + } + return authorsByChannel.flatMap { (channelId, authors) -> + val lastRead = lastReadFor(channelId.channelId) + val filter = + if (lastRead > 0L) { + Filter( + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = authors.toList(), + // -1 so the last-read message (created_at == lastRead) is itself returned: + // a caught-up channel still gets a preview, and strict-> unread stays correct. + since = lastRead - 1, + limit = catchUpLimit, + ) + } else { + Filter( + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = authors.toList(), + limit = previewLimit, + ) + } + (relaysByChannel[channelId] ?: emptySet()).map { relay -> RelayBasedFilter(relay = relay, filter = filter) } + } + } + /** * Collapses [subs] into a `relay -> [filter]` map ready for a drain/subscribe. * All plane wraps are kind-1059 authored by the plane address, so each relay diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index 2cb3af0592..6b1e4d1640 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -70,6 +70,17 @@ data class LiveHiddenUsers( fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex) fun isHashtagHidden(hashtag: String) = hiddenHashtags.contains(hashtag.lowercase()) + + companion object { + /** Neutral value that hides nothing — a safe default before any list has loaded. */ + val EMPTY = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + ) + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 910adf0a82..146811e496 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider +import com.vitorpamplona.quartz.nip03Timestamp.VerificationState import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -163,6 +164,8 @@ open class Note( removeReply(note) removeBoost(note) removeReaction(note) + removeEdit(note) + removeTimestamp(note) removeZap(note) removeZapPayment(note) removeReport(note) @@ -178,6 +181,18 @@ open class Note( fun pollState(): PollResponsesCache = poll ?: PollResponsesCache().also { poll = it } + /** + * When this note holds a NIP-03 OpenTimestamps event (kind 1040), its memoized blockchain + * verification verdict. Kept on the note itself — not in a separate id-keyed cache — so the + * (expensive, network-backed) result shares the note's lifecycle: the attestation is anchored on + * its target's [timestamps] and both are evicted together. `null` means never verified yet. + * + * `@Volatile`: written by the OTS verifier on `applicationIOScope` and read from the Compose + * read path when folding an earliest-attested time. + */ + @Volatile + var otsVerification: VerificationState? = null + // These fields are updated every time an event related to this note is received. var replies = listOf() private set @@ -188,6 +203,27 @@ open class Note( var boosts = listOf() private set + /** + * Concord chat edits (kind 3302) targeting this message, held here — like [reactions] and + * [replies] — so an edit survives exactly as long as its message does. Concord decrypts each + * wrap's rumor only once per session (the community session dedups re-delivered wraps), so an + * edit evicted from the soft event cache can never be re-downloaded; anchoring it to the + * (channel-retained) message keeps it strongly reachable. The chat bubble overlays the latest + * author-matching edit. + */ + var edits = listOf() + private set + + /** + * NIP-03 OpenTimestamps attestations (kind 1040) proving this note existed by a given time, + * held here — like [reactions] and [edits] — so an attestation survives exactly as long as the + * note it timestamps and is dropped the moment that note leaves the cache (including on a NIP-09 + * delete). Anchoring them here also replaces the old full-cache scan: the OTS pill folds this + * list directly. Each attestation memoizes its own blockchain verdict in [Note.otsVerification]. + */ + var timestamps = listOf() + private set + var reports = mapOf>() private set @@ -416,6 +452,34 @@ open class Note( } } + fun addEdit(note: Note) { + if (note !in edits) { + edits = edits + note + flowSet?.edits?.invalidateData() + } + } + + fun removeEdit(note: Note) { + if (note in edits) { + edits = edits - note + flowSet?.edits?.invalidateData() + } + } + + fun addTimestamp(note: Note) { + if (note !in timestamps) { + timestamps = timestamps + note + flowSet?.ots?.invalidateData() + } + } + + fun removeTimestamp(note: Note) { + if (note in timestamps) { + timestamps = timestamps - note + flowSet?.ots?.invalidateData() + } + } + fun removeBoost(note: Note) { if (note in boosts) { boosts = boosts - note @@ -428,6 +492,8 @@ open class Note( val reactionsChanged = reactions.isNotEmpty() val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() || bolt12Zaps.isNotEmpty() val boostsChanged = boosts.isNotEmpty() + val editsChanged = edits.isNotEmpty() + val timestampsChanged = timestamps.isNotEmpty() val reportsChanged = reports.isNotEmpty() val labelsChanged = labels.isNotEmpty() @@ -435,6 +501,8 @@ open class Note( replies + reactions.values.flatten() + boosts + + edits + + timestamps + reports.values.flatten() + labels.values.flatten() + zaps.keys + @@ -448,6 +516,8 @@ open class Note( replies = listOf() reactions = mapOf() boosts = listOf() + edits = listOf() + timestamps = listOf() reports = mapOf() labels = mapOf() zaps = mapOf() @@ -462,6 +532,8 @@ open class Note( if (repliesChanged) flowSet?.replies?.invalidateData() if (reactionsChanged) flowSet?.reactions?.invalidateData() if (boostsChanged) flowSet?.boosts?.invalidateData() + if (editsChanged) flowSet?.edits?.invalidateData() + if (timestampsChanged) flowSet?.ots?.invalidateData() if (reportsChanged) flowSet?.reports?.invalidateData() if (labelsChanged) flowSet?.labels?.invalidateData() if (zapsChanged) flowSet?.zaps?.invalidateData() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt index dd56aa77a8..c31ae3e38c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt @@ -57,7 +57,7 @@ object BuzzAgentActivityState { ) = lock.withLock { val prev = mutableActivity.value[agent] if (prev != null && atSecs <= prev) return@withLock - mutableActivity.value = mutableActivity.value.put(agent, atSecs) + mutableActivity.value = mutableActivity.value.putting(agent, atSecs) } /** Test-only: clears all activity so unit tests don't leak into each other. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzPresenceState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzPresenceState.kt index dc86b33b31..739532689b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzPresenceState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzPresenceState.kt @@ -65,8 +65,8 @@ object BuzzPresenceState { ) = lock.withLock { val prev = stampByUser[subject] if (prev != null && atSecs <= prev) return@withLock - stampByUser = stampByUser.put(subject, atSecs) - mutablePresence.value = mutablePresence.value.put(subject, status) + stampByUser = stampByUser.putting(subject, atSecs) + mutablePresence.value = mutablePresence.value.putting(subject, status) } /** The latest known status string for [subject], or `null` if none has arrived. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt index 5ef916c889..9876820844 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt @@ -23,16 +23,15 @@ package com.vitorpamplona.amethyst.commons.model.buzz import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlin.concurrent.Volatile /** - * Buzz-only overlay state for one workspace channel: the kind-40003 edit overlay - * (newest edit per message — rendering the original without it shows stale text as - * current) and the newest kind-40100 canvas. + * Buzz-only overlay state for one workspace channel: the newest kind-40100 canvas. + * (Kind-40003 message edits are no longer tracked here — like every other edit kind + * they are anchored on the message they edit via `Note.edits`.) * * This lives OUTSIDE the channel object on purpose. Screens, feed filters, and * composers capture their `RelayGroupChannel` instance once and hold it for the whole @@ -41,17 +40,11 @@ import kotlin.concurrent.Volatile * orphaned instance. Keeping the overlay in a registry keyed by the channel id makes * dialect discovery a non-event for object identity. * - * All mutations are guarded by a per-state lock: consume runs on multiple relay - * dispatcher threads, and unsynchronized check-then-act would let an older edit - * overwrite a newer one. + * The mutation is guarded by a per-state lock: consume runs on multiple relay dispatcher + * threads, and unsynchronized check-then-act would let an older canvas overwrite a newer one. */ class BuzzWorkspaceState { private val lock = KmpLock() - private val editsByTarget = LargeCache() - private val editVersion = MutableStateFlow(0) - - /** Bumps when any overlay entry changes, so rows re-read [editFor]. */ - val editUpdates: StateFlow = editVersion /** The newest canvas (kind 40100) note for this channel, or null when none seen. */ @Volatile @@ -63,24 +56,6 @@ class BuzzWorkspaceState { /** Bumps when [canvasNote] is replaced by a newer revision, so a canvas view re-reads it. */ val canvasUpdates: StateFlow = canvasVersion - /** Records a 40003 edit; keeps only the newest per target (last-write-wins by created_at). */ - fun addEdit( - targetId: HexKey, - editNote: Note, - ) = lock.withLock { - val current = editsByTarget.get(targetId) - if (current == null || (editNote.createdAt() ?: 0L) > (current.createdAt() ?: 0L)) { - editsByTarget.put(targetId, editNote) - editVersion.value = editVersion.value + 1 - } - } - - /** The newest edit note overlaying [targetId], or null when the message is unedited. */ - fun editFor(targetId: HexKey): Note? = editsByTarget.get(targetId) - - /** The effective display content for a message: its newest edit's text, or null when unedited. */ - fun effectiveContentFor(targetId: HexKey): String? = editsByTarget.get(targetId)?.event?.content - fun updateCanvas(note: Note) = lock.withLock { if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) { @@ -88,16 +63,6 @@ class BuzzWorkspaceState { canvasVersion.value = canvasVersion.value + 1 } } - - /** Drops overlay entries whose target message id is not in [aliveTargetIds] (memory pruning). */ - fun pruneEdits(aliveTargetIds: Set) = - lock.withLock { - val dead = editsByTarget.keys().filter { it !in aliveTargetIds } - if (dead.isNotEmpty()) { - dead.forEach { editsByTarget.remove(it) } - editVersion.value = editVersion.value + 1 - } - } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt index b13d10c1d8..aafc7c431a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt @@ -52,7 +52,7 @@ class AuthApprovalRequests { /** Track a newly surfaced tier-2 challenge. Last write per relay wins. */ fun add(approval: PendingAuthApproval) { - _pending.update { it.put(approval.relayUrl, approval) } + _pending.update { it.putting(approval.relayUrl, approval) } } /** @@ -65,7 +65,7 @@ class AuthApprovalRequests { scope: AuthApprovalScope, ): Boolean { val approval = _pending.value[relayUrl] ?: return false - _pending.update { it.remove(relayUrl) } + _pending.update { it.removing(relayUrl) } return approval.decision.complete(scope) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt index bc308cc97b..2a606b1c93 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt @@ -391,7 +391,7 @@ fun RelayReachDetailDialog( confirmButton = { if (showRetry) { TextButton(onClick = { - onRetry?.invoke() + onRetry() onDismiss() }) { Text(stringResource(Res.string.action_try_again)) } } else { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index 7455b8315c..33f4306799 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -107,6 +107,64 @@ class ConcordSubscriptionPlannerTest { assertTrue(filter.authors!!.contains(general.pubKeyHex)) } + @Test + fun channelPreviewFiltersAreOnePerChannelWithLimitOne() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + + // Never read (lastRead == 0) ⇒ the newest few wraps (previewLimit), no `since`. + val previews = ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { 0L }, previewLimit = 10) + + // One filter per channel (a fresh community has just #general) on the one community relay. + assertEquals(1, previews.size) + val preview = previews.first() + assertEquals(10, preview.filter.limit) // a handful of recent messages, not the whole backlog + assertNull(preview.filter.since) // never read ⇒ no catch-up window + assertEquals(listOf(1059), preview.filter.kinds) // stored wraps only — not ephemeral 21059 typing + val general = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch).publicKeyHex + assertTrue(preview.filter.authors?.contains(general) == true, "no limit:1 preview for #general") + } + + @Test + fun channelPreviewFiltersCatchUpSinceLastReadWhenRead() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + + // Read before (lastRead > 0) ⇒ everything since, capped, so the unread badge is accurate. + val lastRead = 1_700_000_000L + val previews = + ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { lastRead }, catchUpLimit = 25) + + assertEquals(1, previews.size) + val preview = previews.first() + // `since = lastRead - 1` re-includes the last-read message (its created_at == lastRead), + // so even a caught-up channel still yields a preview. + assertEquals(lastRead - 1, preview.filter.since) + assertEquals(25, preview.filter.limit) // bounded by catchUpLimit, not 1 + } + @Test fun relayBasedFiltersCollapsePerRelayAndApplySince() = runTest { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeoRelayCsvLoader.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeoRelayCsvLoader.kt index 94eda65544..87c27914ea 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeoRelayCsvLoader.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeoRelayCsvLoader.kt @@ -46,7 +46,7 @@ class GeoRelayCsvLoader( .build() okHttpClient(url).newCall(request).execute().use { response -> if (response.isSuccessful) { - GeoRelayDirectory.parseCsv(response.body?.string().orEmpty()) + GeoRelayDirectory.parseCsv(response.body.string()) } else { emptyList() } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt new file mode 100644 index 0000000000..6c9e80724c --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt @@ -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.commons.moderation + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * "Always show sensitive content" (NIP-36) preference, backed by + * [java.util.prefs.Preferences] under the same shared node as the other content + * filters (so Desktop and the `amy` CLI observe the same value). + * + * [showSensitiveContent] follows the `LiveHiddenUsers`/`Note.isHiddenFor` + * convention: `null` = respect content warnings (blur), `true` = always show. + * `false` is never emitted — the toggle is binary (on ⇒ true, off ⇒ null). + */ +class PreferencesSensitiveContentSettings( + private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME), +) { + private val mutable = MutableStateFlow(toChoice(prefs.getBoolean(KEY_ALWAYS_SHOW, DEFAULT_ALWAYS_SHOW))) + + /** `null` = respect warnings (blur); `true` = always show. */ + val showSensitiveContent: StateFlow = mutable.asStateFlow() + + fun setAlwaysShow(alwaysShow: Boolean) { + mutable.value = toChoice(alwaysShow) + prefs.putBoolean(KEY_ALWAYS_SHOW, alwaysShow) + } + + companion object { + const val NODE_NAME = "com/vitorpamplona/amethyst/filters" + const val KEY_ALWAYS_SHOW = "always_show_sensitive" + const val DEFAULT_ALWAYS_SHOW = false + + private fun toChoice(alwaysShow: Boolean): Boolean? = if (alwaysShow) true else null + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt new file mode 100644 index 0000000000..ade6c23960 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt @@ -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.commons.moderation + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.prefs.Preferences + +class PreferencesSensitiveContentSettingsTest { + private val testNode = "com/vitorpamplona/amethyst/test/sensitive_${System.currentTimeMillis()}" + + private fun prefs(): Preferences = Preferences.userRoot().node(testNode) + + @Before + fun setup() { + prefs().clear() + } + + @After + fun teardown() { + prefs().removeNode() + } + + @Test + fun defaultRespectsWarnings() { + // null = respect content warnings (blur), matching Note.isHiddenFor. + assertNull(PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value) + } + + @Test + fun onMapsToTrue() { + val settings = PreferencesSensitiveContentSettings(prefs()) + settings.setAlwaysShow(true) + assertEquals(true, settings.showSensitiveContent.value) + } + + @Test + fun offMapsToNullNotFalse() { + val settings = PreferencesSensitiveContentSettings(prefs()) + settings.setAlwaysShow(true) + settings.setAlwaysShow(false) + // Off is null (respect warnings), never false — Note.isHiddenFor only + // blurs when showSensitiveContent == false, which must never happen here. + assertNull(settings.showSensitiveContent.value) + } + + @Test + fun persistsAcrossReload() { + PreferencesSensitiveContentSettings(prefs()).setAlwaysShow(true) + assertEquals(true, PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value) + } +} diff --git a/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md b/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md new file mode 100644 index 0000000000..dcd30309a5 --- /dev/null +++ b/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md @@ -0,0 +1,157 @@ +# Manual Testing Sheet — Desktop Moderation & Safety + +**Feature branch:** `worktree-feat-desktop-moderation-safety` +**Covers commits:** `9090dfe8` → `274f8c01` (enforcement, report, CW blur, follow-ups) +**Date executed:** ____________ **Tester:** ____________ **Build:** ____________ + +Run: `./gradlew :desktopApp:run` + +--- + +## 0. Setup & Prerequisites + +You need up to **three accounts** to cover every path. Note their npubs below. + +| Role | Requirement | npub / note | +|------|-------------|-------------| +| **A — main (writeable)** | Local `nsec` login. Ideally already has a kind-10000 mute list set from the Amethyst mobile app (to test hydration). | | +| **B — bunker (writeable, NIP-46)** | NIP-46 remote signer login. Used only for the async-decrypt path. | | +| **C — read-only (npub)** | Login with an `npub` only (no signer). | | +| **Target user X** | Any account you can mute/report/block. Should be posting notes visible in your feeds. | | +| **Followed user Y** | A user account **A follows** — used to prove mute wins over follow. | | + +Legend for results: **P** = pass, **F** = fail (add note), **B** = blocked/couldn't test, **N/A**. + +> Tip: to observe published events (mutes = kind 10000, reports = kind 1984), tail a +> relay you publish to, or use `amy` to fetch them by author/kind. + +--- + +## 1. Enforcement — the core bug fix (feeds + DMs) + +Logged in as **A**. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| E1 | A already has a mobile-set mute on user X → open **Following** and **Global** | X's notes do **not** appear in either feed | | +| E2 | Right-click one of X's notes → **Mute user** | X's notes disappear from the feed **without restarting** the app (allow a moment for publish + flow update) | | +| E3 | Mute followed user **Y** (E2 flow) → open **Following** | Y's notes are hidden even though you follow Y (mute wins over follow) | | +| E4 | Open a **thread** whose root is by someone else but has a reply from a muted user | The muted user's **reply is hidden**; the thread **root is still shown** | | +| E5 | Open the **Notifications** tab; have a muted user react to / reply to your note | The muted user's notification does **not** appear | | +| E6 | Open the **DM / Messages** conversation list while a muted user has messaged you | The muted user's conversation is filtered out of the list | | +| E7 | A's mute list (from mobile) contains a **hidden word** → find a note containing that word | The note is hidden/collapsed | | +| E8 | Open the **Reads** (long-form) tab and **Search** with a muted author present | Muted author's articles/results are hidden | | +| E9 | **Unmute** X (see §4) | X's notes **reappear** across feeds, live | | + +--- + +## 2. Thread + Profile enforcement (follow-up A) + +Logged in as **A**. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| T1 | Mute user X → open a thread containing X's reply | X's reply hidden; root shown; sibling replies still there | | +| T2 | Open **X's profile** → Notes tab | X's own notes are hidden (empty / filtered) | | +| T3 | X's profile → **Replies** tab | X's replies hidden too | | +| T4 | While X's profile is open, **Unmute** X from the header overflow (§5) | Their notes **repopulate** the tabs live | | + +--- + +## 3. Report — NIP-56 (note + user) + +Logged in as **A**. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| R1 | Right-click a note → **Report…** | Dialog opens with reason radios (Spam / Profanity / Impersonation / Nudity / Illegal / Malware) + a comment field | | +| R2 | Pick a reason, add a comment → **Report** | A **kind-1984** event is published (verify on a relay / via `amy`) tagging the note id + author; the note is **not** hidden | | +| R3 | Report a note → **Block & report** | Report published **and** the author is muted (their notes vanish) | | +| R4 | Open X's profile → header **⋮** → **Report…** → pick reason → **Report** | kind-1984 published tagging **the user** (no `e` tag required) | | +| R5 | Profile **⋮** → **Block & report** | Report published + user muted | | + +--- + +## 4. Content warning — NIP-36 blur (follow-up B render) + +Logged in as **A**. Ensure "Always show sensitive content" is **OFF** (§6) for C1–C3. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| C1 | Find a note tagged `content-warning` / NSFW in a feed | Renders **collapsed**: "Sensitive content" + the reason text + a **Show** button; media not visible | | +| C2 | Click **Show** | The note body/media reveals; stays revealed while scrolling away and back (per-note) | | +| C3 | Open a CW-tagged note as a **thread root** | Auto-revealed (you navigated into it — `forceReveal`) | | +| C4 | Turn **ON** "Always show sensitive content" (§6) → revisit a CW note in the feed | Renders **unblurred** immediately, no Show gate | | +| C5 | Turn it **OFF** again | CW notes blur again | | +| C6 | Confirm spam-collapse still works and doesn't conflict on a note that is both spammy and CW | One collapse shown; **Show** reveals the note | | + +--- + +## 5. Profile moderation actions (follow-up B) + +Logged in as **A**, viewing **X's** profile (not your own). + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| P1 | Profile header shows a **⋮ (MoreVert)** button next to Follow | Present for other users on a writeable account | | +| P2 | ⋮ → menu | Shows **Mute user** (or **Unmute user** if already muted) + **Report…** | | +| P3 | ⋮ → **Mute user** | X muted; label flips to **Unmute user** on reopen; notes hide | | +| P4 | ⋮ → **Unmute user** | X unmuted; notes return | | +| P5 | Open **your own** profile | **No** ⋮ moderation button (can't mute yourself) | | + +--- + +## 6. Settings — sensitive toggle + management screens (follow-ups B & C) + +Open **Settings → Content Filters** as **A**. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| S1 | Locate the **Moderation & Safety** section (below Hashtag-spam filter) | Section renders with an "Always show sensitive content" switch | | +| S2 | Toggle the switch on → **restart the app** → reopen settings | Switch is **still on** (persisted via prefs) | | +| S3 | **Muted users** list | Lists every muted/blocked user with a resolved display name (or short pubkey) + **Unmute** | | +| S4 | Click **Unmute** on a user | Row disappears; that user's notes reappear in feed live; kind-10000 re-published | | +| S5 | **Hidden words**: type a word → **Add** | Word appears in the list; notes containing it collapse in feeds | | +| S6 | **Hidden words**: **Remove** a word | Word gone; matching notes reappear | | +| S7 | **Muted threads** list (mute a thread first, if none) | Lists muted thread ids (truncated) + **Unmute**; removing restores the thread | | +| S8 | Mute a user via note menu (§1 E2) → reopen settings | The new user shows up in the **Muted users** list immediately | | + +--- + +## 7. Account edge cases + +| # | Account | Steps | Expected | Result | +|---|---------|-------|----------|:---:| +| A1 | **C (read-only)** | Right-click a note; open a profile ⋮; open settings Moderation section | **No** Mute/Report items in menus; management lists render **read-only** (no Unmute/Add/Remove); sensitive toggle still works (local pref). No crash. | | +| A2 | **C (read-only)** | A public (unencrypted) mute entry exists on this npub | Public mute portion is still **enforced** in feeds | | +| A3 | **B (bunker)** | Log in fresh; the private mute list must be decrypted via the remote signer | Enforcement engages **once decryption resolves** (may lag a beat); no crash while waiting; approve the decrypt on the signer if prompted | | +| A4 | **A** | Mute someone, then kill the relay / go offline mid-publish | App doesn't crash; local hide still applied; re-syncs when back online | | + +--- + +## 8. Regression + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| X1 | Browse feeds with **no** mutes set | Everything renders as before; no scroll jank / perf regression | | +| X2 | Existing hashtag-spam collapse | Still works independently of moderation | | +| X3 | Reactions, reposts, zaps, bookmarks on a note | Unchanged (moderation menu items are additive to ShareMenu) | | +| X4 | Open/refresh a thread and a profile repeatedly | No leaks / duplicate VMs (filters recreated on account change only) | | + +--- + +## Automated coverage (already green) + +- `./gradlew :desktopApp:test --tests "com.vitorpamplona.amethyst.desktop.filters.DesktopMuteEnforcementTest"` + — muted author hidden (even if followed), hidden-word drop, clean notes pass (§1 E1/E3/E7). +- `./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettingsTest"` + — sensitive toggle mapping `on→true`, `off→null` (never false), persistence (§6 S2). + +## Sign-off + +- [ ] All §1 enforcement scenarios pass (the core bug fix). +- [ ] All follow-ups (§2, §5, §6) pass. +- [ ] Edge cases (§7) behave with no crash. +- [ ] No regressions (§8). + +Overall: ☐ PASS ☐ FAIL — notes: ______________________________________________ diff --git a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md new file mode 100644 index 0000000000..5bd9bc5d8a --- /dev/null +++ b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md @@ -0,0 +1,144 @@ +--- +title: Desktop Moderation & Safety — deferred follow-ups (management screens, sensitive-content toggle, thread/profile enforcement) +type: feat +status: active +date: 2026-07-23 +origin: desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md +module: desktopApp (+ commons/jvmMain) +--- + +# ✨ Desktop Moderation & Safety — follow-ups + +Continuation of the shipped core (branch `worktree-feat-desktop-moderation-safety`, +commits `9090dfe8`→`f4435bfe`). The account-layer write API +(`hideUser/showUser/hideWord/showWord/hideThread/showThread`, `report`) and the +`LocalDesktopIAccount` CompositionLocal already exist — these follow-ups are the +remaining UI + persistence. + +## Scope (the 3 deferred items) + +1. **Thread + Profile enforcement** — `ThreadScreen`/`UserProfileScreen` build + their `DesktopThreadFilter`/`DesktopProfileFeedFilter` with the default + `hidden = { EMPTY }`, so mutes don't apply there. Wire the real lambda. +2. **Sensitive-content toggle + profile moderation actions** — persist an + "Always show sensitive content" setting and back + `DesktopIAccount.showSensitiveContentSetting` with it; add Mute/Report to the + profile screen. +3. **Management screens** — list + remove Blocked users / Hidden words / Muted + threads, reachable from the Content Filters settings section. + +## Key findings (grounded 2026-07-23) + +- `ThreadScreen` (`ui/ThreadScreen.kt:98`) + `UserProfileScreen` + (`ui/UserProfileScreen.kt:120`) take `account: AccountState.LoggedIn?` — **not** + `DesktopIAccount`. But `LocalDesktopIAccount.current` is now in scope inside + them, so **no param/caller threading needed** (callers: `DeckColumnContainer.kt` + :566,:720). Just read the local and pass + `{ LocalDesktopIAccount.current?.hiddenUsers?.value ?: LiveHiddenUsers.EMPTY }` + to the filter. (Filters already accept the lambda.) +- Persistence pattern: `commons/jvmMain/.../moderation/PreferencesHashtagSpamSettings.kt` + — `Preferences.userRoot().node(NODE)` with `MutableStateFlow` fields. Mirror for + a sensitive-content boolean. +- Content Filters UI: `desktopApp/.../ui/settings/HashtagSpamSettingsSection.kt` + is the section composable; add the toggle + management entries alongside it. +- Read side for management screens: `DesktopIAccount.hiddenUsersState.flow` + (`LiveHiddenUsers`) already exposes `hiddenUsers` / `hiddenWords` / `mutedThreads` + sets. Remove = `account.showUser/showWord/showThread`. Resolve a blocked pubkey + to a name via `localCache.getUserIfExists(hex)?.toBestDisplayName()`. + +## Technical Approach — phases + +### Phase A — Thread + Profile enforcement (S) +- In `ThreadScreen` and `UserProfileScreen`, capture + `val iAccount = LocalDesktopIAccount.current` and pass + `hidden = { iAccount?.hiddenUsers?.value ?: LiveHiddenUsers.EMPTY }` to + `DesktopThreadFilter` / both `DesktopProfileFeedFilter` constructions + (`ThreadScreen.kt:125`, `UserProfileScreen.kt:192,212`). +- Thread root intentionally stays visible (filter already only hides replies). +- Success: muting a reply author hides them in an open thread + on the profile + Notes/Replies tabs, live. + +### Phase B — Sensitive-content toggle + profile moderation (M) +- New `commons/jvmMain/.../moderation/PreferencesSensitiveContentSettings.kt`: + persisted `alwaysShowSensitive: MutableStateFlow` (default false) under + a stable prefs node; expose `showSensitiveContent: StateFlow` mapping + `true → true`, `false → null` (null = respect warnings, matching + `Note.isHiddenFor`). +- `DesktopIAccount`: replace the placeholder `showSensitiveContentSetting = + MutableStateFlow(null)` with this store's flow; add + `setAlwaysShowSensitive(Boolean)`. +- Content Filters section: a "Show sensitive content" switch (reads/writes via + `LocalDesktopIAccount.current`). +- `UserProfileScreen`: add **Mute user** + **Report…** actions (reuse + `ReportNoteDialog`; call `account.hideUser` / `account.report(userHex, …)`), + shown only for writeable accounts and not for self. +- Success: toggling the switch flips CW blur app-wide and persists across restart; + profile has working Mute/Report. + +### Phase C — Management screens (M) +- New `desktopApp/.../ui/settings/BlockedContentSettings.kt` (or 3 small + composables): three expandable lists driven by + `LocalDesktopIAccount.current?.hiddenUsers` (collectAsState): + - **Blocked/muted users** → rows with resolved display name + "Unmute" + (`showUser`). + - **Hidden words** → text rows + "Remove" (`showWord`) + an add-word field + (`hideWord`). + - **Muted threads** → note-id rows + "Unmute" (`showThread`). +- Surfaced from the Content Filters section (expander or sub-screen). +- Success: lists reflect the live mute set and removing an entry publishes the + updated kind-10000 and un-hides immediately. + +## System-Wide Impact +- **Interaction graph:** removing a mute → `account.showUser/Word/Thread` → + `MuteListEvent.remove` signed + `justConsumeMyOwnEvent` + broadcast → mute flow + re-emits → feeds `invalidateData` → entry disappears from feed AND from the + management list (same flow). No separate refresh path. +- **State lifecycle:** management-list edits and the sensitive toggle are + independent stores (NIP-51 event vs local prefs); no shared partial-failure. +- **API parity:** the CW toggle now feeds three readers — + `Note.isHiddenFor` (feed filters) and `SpamCheckedNoteRender` (blur). Both read + the same `showSensitiveContent` value; verify one source of truth. +- **Read-only accounts:** management screens are read-only (show lists, disable + remove/add); the sensitive toggle still works (it's local prefs, not signed). + +### Integration test scenarios +1. Mute a reply author → open the thread → reply hidden; root still shown. +2. Mute a user → open their profile → their Notes/Replies tabs empty. +3. Toggle "show sensitive content" on → CW notes render unblurred everywhere; + restart app → still on. +4. Unmute from the Blocked-users screen → their notes reappear in feed live. +5. Add a hidden word in the management screen → matching notes collapse. +6. Read-only account → management screen lists render, remove/add disabled. + +## Acceptance Criteria +- [x] Muting hides in open threads (replies) + on profile tabs, live. +- [x] "Always show sensitive content" toggle persists and flips CW blur app-wide. +- [x] Profile screen has working Mute + Report actions (writeable, non-self) — + MoreVert overflow. +- [x] Blocked-users / Hidden-words / Muted-threads lists in + `ModerationSettingsSection`; remove publishes the updated mute list and + un-hides live; hidden-words has an add field. +- [x] All reachable from the Content Filters settings section. +- [x] Read-only + bunker accounts behave (write actions gated on `isWriteable()`; + management remove/add hidden for read-only). +- [x] Unit test: sensitive-content mapping (`true→true`, `false→null`, persists) + in `PreferencesSensitiveContentSettingsTest`. +- [x] `spotlessApply` clean; commons + desktopApp compile; tests green. + +**Status: COMPLETE** — commit `49d0518d`. Manual run (`./gradlew :desktopApp:run`) ++ PR remain (nostr-git repo → `ngit-pr` flow). + +## Dependencies & Risks +- Low risk — all reuse the shipped write API + `LocalDesktopIAccount`. The one + new persisted store mirrors an existing pattern. No new third-party deps. +- Watch: two readers of `showSensitiveContent` (feed filter via `LiveHiddenUsers` + vs blur composable) must agree — thread the same setting into + `DesktopHiddenUsersState` (already takes a `showSensitiveContent` flow param). + +## Sources & References +- Origin core plan: `desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md` +- `desktopApp/.../ui/settings/HashtagSpamSettingsSection.kt`, + `commons/jvmMain/.../moderation/PreferencesHashtagSpamSettings.kt` +- `desktopApp/.../model/{DesktopIAccount,DesktopHiddenUsersState,LocalDesktopIAccount}.kt` +- `desktopApp/.../ui/{ThreadScreen,UserProfileScreen}.kt`, + `desktopApp/.../ui/note/{ShareMenu,ReportNoteDialog}.kt` diff --git a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md new file mode 100644 index 0000000000..62f1e5fc80 --- /dev/null +++ b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md @@ -0,0 +1,375 @@ +--- +title: Desktop Moderation & Safety (mute/block enforcement + NIP-56 report + NIP-36 content-warning) +type: feat +status: active +date: 2026-07-23 +origin: docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md +module: desktopApp (+ commons, reusing quartz) +--- + +# ✨ Desktop Moderation & Safety + +## Enhancement Summary (deepened 2026-07-23) + +Grounding research + direct code inspection **resolved the two make-or-break +cruxes**, de-risking the whole feature: + +1. **Enforcement primitive is already shared.** `LiveHiddenUsers` and + `Note.isHiddenFor(accountChoices: LiveHiddenUsers)` live in **`commons/`** + (`commons/.../model/Note.kt:1279`, referenced from + `commons/.../model/IAccount.kt`, covered by + `commons/.../model/NoteIsHiddenForTest.kt`). Desktop does **not** need to port + or promote the hidden-check logic — it only needs to *assemble a + `LiveHiddenUsers` value* from the account's lists and hold it as a StateFlow. + → Crux 1 collapses to "build the value object"; **desktop-local state holders + are sufficient** (no risky commons extraction for v1). +2. **Feeds have a live-invalidation hook.** Desktop feeds implement + `commons/.../ui/feeds/InvalidatableContent` via `FeedContentState` + (`invalidateData()`). Wiring the hidden-users StateFlow to call + `invalidateData()` on emit makes mute/block hide **without restart**. + → Crux 2 resolved: reuse `invalidateData()`, don't invent a bus. +3. **Concrete APIs confirmed** (see Research Insights under Technical Approach): + `MuteListEvent.create/add/remove(MuteTag, isPrivate, signer)` where `MuteTag ∈ + {UserTag, HashtagTag(word), EventTag(thread)}`; `PeopleListEvent.addUser/ + removeUser` for blocks; publish via `signer.sign(template)` + + `relayManager.broadcastToAll(signed)` (precedent `reactToNote`/`repostNote` in + `desktopApp/.../ui/NoteActions.kt`). + +**Net effect on phases:** Phase 0 shrinks (assemble a value, don't port a +subsystem). Phase 1's only real work is the `invalidateData()` wiring + predicate. +Overall risk downgraded from *Medium* to *Low-Medium*. + +> _Note: 3 of 4 deepening sub-agents were killed by the 600s stall watchdog +> (environment load); the facts above were recovered by direct grep and are +> first-hand, not agent-summarized._ + +## Overview + +Close the largest verified Desktop parity gap after Polls by shipping a coherent +**Moderation & Safety** bundle: + +1. **P2 — Fix the silent mute/block no-op** (a live bug: muting does nothing on + desktop feeds today) + management surfaces. +2. **P3 — Report a note/user (NIP-56).** +3. **P4 — Content-warning blur/reveal (NIP-36).** + +All three share the same account-moderation state and the same "hide-with-reveal" +UX primitive, so they ship together as one feature branch (see brainstorm: +`docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md`). + +## Problem Statement + +- **Mute is a lie on desktop.** `DesktopIAccount.isHidden(user)` is hardcoded + `false`; `isAcceptable(note)` only checks deletions; `DesktopFeedFilters` never + calls either. Desktop also holds **no** mute/block state at all — the fields + (`hiddenUsersHashCodes`, `hiddenWordsCase`, `spammersHashCodes`, + `showSensitiveContent`) are empty/`null` stubs. A user who mutes someone + reasonably believes it works; it does not. +- **No way to report.** Desktop has zero NIP-56 UI; Android has a full report + dialog. +- **Sensitive media shows unblurred.** `showSensitiveContent` is hardcoded + `null` and no NIP-36 gate exists, so content-warning-tagged media renders in + the clear. + +## Proposed Solution + +Port Android's moderation state + UI into shared/desktop code and wire real +enforcement, reusing quartz protocol and the existing spam-collapse reveal: + +- **State:** hold mute list (kind 10000) + blocked people (kind 10001) + + assembled `LiveHiddenUsers` on `DesktopIAccount`; hydrate via a new account + subscription and the account signer (decrypts the private list). +- **Enforcement:** implement `isHidden`/`isAcceptable` by delegating to quartz + `Note.isHiddenFor(liveHiddenUsers)`; chain `&& account.isAcceptable(note)` into + every `DesktopFeedFilters` feed path. The chatroom list already calls + `isAcceptable`, so DMs get filtered for free once the predicate is real. +- **Actions:** `hideUser`/`showUser`/`blockUser`/`hideWord`/`hideThread` on + `DesktopIAccount` build → sign → publish an updated `MuteListEvent`/ + `PeopleListEvent`. +- **Report:** `report(note|user, type, comment)` reuses quartz `ReportEvent.build` + (kind 1984) + `ReportType`, published to the account's public + private outbox + (match Android). New `ReportNoteDialog` ported to desktop Compose, wired into + `ShareMenu` and `UserProfileScreen`. +- **Content warning:** generalize `SpamCheckedNoteRender` to also gate + `Event.isSensitiveOrNSFW()` notes with a blur/scrim + reason + tap-to-reveal, + plus a "always show sensitive content" settings toggle persisted alongside the + spam settings. + +## Technical Approach + +### Architecture — moderation state & enforcement flow + +```mermaid +flowchart TD + subgraph relays[Relays] + K10000[kind 10000 MuteListEvent - encrypted] + K10001[kind 10001 PeopleListEvent - block] + K1068[content notes, some content-warning tagged] + end + K10000 & K10001 --> SUB[Main.kt account subscription] + SUB --> DLC[DesktopLocalCache] + DLC --> MLS[MuteListState / BlockPeopleListState] + SIGNER[Account signer - decrypts private list] --> MLS + MLS --> HUS[HiddenUsersState.assembleLiveHiddenUsers] + HUS --> LHU[[LiveHiddenUsers flow]] + LHU --> IACC[DesktopIAccount.isHidden / isAcceptable] + IACC --> FF[DesktopFeedFilters feed / applyFilter] + IACC --> CHAT[ChatroomList - already calls isAcceptable] + K1068 --> FF + FF --> UI[FeedNoteCard] + UI --> SCNR[SpamCheckedNoteRender - generalized] + SCNR -->|spam OR content-warning| COLLAPSE[Collapsed w/ reveal] + SCNR -->|clean| BODY[FeedNoteCardBody] +``` + +### Key reuse (confirmed by code research, 2026-07-23) + +| Concern | Reuse (no reimpl) | Source | +|---|---|---| +| Report protocol | `ReportEvent.build(post/user, type, comment)` kind 1984 | `quartz/.../nip56Reports/ReportEvent.kt` | +| Report types | `ReportType` enum (SPAM/PROFANITY/IMPERSONATION/NUDITY/ILLEGAL/MALWARE/VIOLENCE/…) | `quartz/.../nip56Reports/ReportType.kt` | +| CW detection | `Event.isSensitiveOrNSFW()`, `contentWarningReason()` | `quartz/.../nip36SensitiveContent/EventExt.kt` | +| Hidden check | `Note.isHiddenFor(liveHiddenUsers)` (users+words+threads) | `quartz/.../Note.kt` | +| Mute/block events | `MuteListEvent` (10000), `PeopleListEvent` (10001) | `quartz/.../nip51Lists/…` | +| Reveal primitive | `SpamCheckedNoteRender` `forceReveal` + `rememberSaveable(id)` | `desktopApp/.../ui/note/SpamCheckedNoteRender.kt` | + +### Android references to port + +| Piece | Android reference | +|---|---| +| Mute state | `amethyst/.../model/nip51Lists/muteList/MuteListState.kt` | +| Block state | `amethyst/.../model/nip51Lists/…/BlockPeopleListState` | +| Hidden assembly | `amethyst/.../model/nip51Lists/HiddenUsersState.kt` (`assembleLiveHiddenUsers`) | +| Report dialog | `amethyst/.../ui/screen/loggedIn/report/ReportNoteDialog.kt` | +| CW gate | `amethyst/.../ui/components/SensitivityWarning.kt` | +| Mgmt screens | `amethyst/.../settings/{BlockedUsersScreen,HiddenWordsScreen,MutedThreadsScreen}.kt` | + +### Design cruxes — RESOLVED (deepened 2026-07-23) + +1. **State-holder placement → desktop-local, value-object only. RESOLVED.** + `LiveHiddenUsers`/`Note.isHiddenFor` already in `commons/` — no port, no type + promotion. Build a small desktop assembler that reads the account's mute list + (kind 10000) + blocked people (kind 10001) from `DesktopLocalCache` + signer + and emits `StateFlow`. Option (a) full commons extraction is + **deferred** — unnecessary for v1. +2. **Reactivity → `FeedContentState.invalidateData()`. RESOLVED.** Desktop feeds + implement `commons/.../ui/feeds/InvalidatableContent`. Collect the + `StateFlow` where feeds are built and call `invalidateData()` + on change so mutes apply live. Verify each feed's content-state gets the call + (Following/Global/Thread/Notifications/Profile). +3. **Reveal generalization shape.** One generalized `SpamCheckedNoteRender` + (collapse for spam **or** CW), two reasons — avoids double nesting. (Decision + stands; confirm final shape during Phase 4.) +4. **Media blur fidelity.** v1 = opaque scrim + reason overlay + reveal (no new + dependency; Compose Desktop `Modifier.blur` support is uneven across the + skiko backend, so avoid depending on it). True blur is a v2 nicety. Documented + limitation. +5. **Report publish path → `signer.sign` + `relayManager.broadcastToAll`. + RESOLVED.** Desktop has no `sendMyPublicAndPrivateOutbox` helper; the + established pattern (`reactToNote`/`repostNote`, + `desktopApp/.../ui/NoteActions.kt:1387,1472`) is sign-then-`broadcastToAll`. + v1 reports broadcast to connected relays (NIP-56 is advisory) — noted as a + deliberate simplification vs Android's outbox split. + +### Research Insights — confirmed APIs (copy-ready) + +**Mute / block writes** (`quartz/.../nip51Lists/`), all `suspend`, signer-based: +``` +// mute a user (private by default), returns the new event to publish +MuteListEvent.create(mute = UserTag(pubkey), isPrivate = true, signer = signer) +MuteListEvent.add(earlierVersion, mute = HashtagTag(word), isPrivate, signer) // hidden word +MuteListEvent.add(earlierVersion, mute = EventTag(rootId), isPrivate, signer) // muted thread +MuteListEvent.remove(earlierVersion, mute, signer) // unmute +// reading private entries requires the signer: +earlierVersion.privateTags(signer) // throws SignerExceptions.UnauthorizedDecryptionException on read-only +// block (kind 10001) +PeopleListEvent.addUser(...) / removeUser(...) +``` +MuteTag subtypes: `UserTag` (pubkey), `HashtagTag` (word), `EventTag` (thread +root). Enforcement then reads these via `Note.isHiddenFor(liveHiddenUsers)`. + +**Publish** (precedent in `desktopApp/.../ui/NoteActions.kt`): +``` +val signed = signer.sign(template) // DesktopIAccount already does signer.sign at ~L187 +relayManager.broadcastToAll(signed) // reactToNote L1395 / repostNote L1479 +``` + +**Report** (`quartz/.../nip56Reports/`): `ReportEvent.build(reportedPost, type, +comment)` and `ReportEvent.build(reportedUser, type, comment)` (kind 1984); +`ReportType ∈ {SPAM, PROFANITY, IMPERSONATION, NUDITY, ILLEGAL, MALWARE, +VIOLENCE, …}`. + +**Content warning** (`quartz/.../nip36SensitiveContent/EventExt.kt`): +`Event.isSensitiveOrNSFW(): Boolean`, `Event.contentWarningReason(): String?`. + +**Read-only / bunker caveat:** `privateTags(signer)` throws on a read-only +account and is async for NIP-46 bunkers — assemble from the **public** list +portion immediately and recompute when decryption resolves; disable write actions +(mute/block/report) when there's no local signer. + +### Implementation Phases + +Each phase is independently compilable; Phase 1 alone fixes the enforcement bug. + +#### Phase 0 — Moderation state foundation +- New: `MuteListState`/`BlockPeopleListState`/`HiddenUsersState` (desktop-local + per crux 1), producing a `LiveHiddenUsers` StateFlow from `DesktopLocalCache` + + signer. +- Add kind-10000/10001 to desktop account subscription in `Main.kt`. +- Success: flow populates + decrypts on login (log the assembled set). + +#### Phase 1 — Enforcement wiring (the bug fix) +- Implement `DesktopIAccount.isHidden(user)` / `isAcceptable(note)` via + `Note.isHiddenFor(hiddenUsers.value)`; populate `showSensitiveContent`, + `hiddenUsersHashCodes`, etc. from state. +- Chain `&& account.isAcceptable(note)` into every `DesktopFeedFilters` + `feed()`/`applyFilter()`; ensure reactivity (crux 2). +- Success: muting a user (even via a hand-inserted list) hides their notes in + Following/Global/Thread/Notifications + chatroom list, live. + +#### Phase 2 — Mute/Block actions + management screens +- `DesktopIAccount.hideUser/showUser/blockUser/hideWord/hideThread` build+sign+ + publish updated list events. +- Mute/Block action in note context menu (`ShareMenu`) + `UserProfileScreen`. +- Port `BlockedUsersScreen`/`HiddenWordsScreen`/`MutedThreadsScreen` to desktop + settings (read + remove). +- Success: block from profile persists (kind-10000 on relay) and hides live; + management screens list + remove entries. + +#### Phase 3 — Report (NIP-56) +- `DesktopIAccount.report(note|user, type, comment)` → `ReportEvent.build` → + publish to public+private outbox. +- Port `ReportNoteDialog` (report-type list + comment + "Block & Hide User" + + "Post Report") to desktop Compose; wire into `ShareMenu` + `UserProfileScreen`. +- Success: reporting publishes a well-formed kind-1984 to the right relays + (verify via `amy` / relay log); "Block & Hide" also mutes. + +#### Phase 4 — Content-warning blur (NIP-36) +- Generalize `SpamCheckedNoteRender` to also gate `isSensitiveOrNSFW()` notes: + scrim + reason + reveal, honoring per-note `rememberSaveable` reveal and the + account "always show sensitive" setting. +- Add "always show sensitive content" toggle persisted with spam settings. +- Success: CW note renders blurred; reveal works; toggle bypasses blur. + +#### Phase 5 — Settings, tests, polish +- Surface all toggles + management-screen entries in the existing **Content + Filters** settings section (where hashtag-spam lives). +- Unit tests: enforcement predicate (user/word/thread hidden), report-event + shape, CW detection/gate state. +- `./gradlew spotlessApply`; compile commons+desktopApp+cli; manual testing sheet + `desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md`. + +## Alternative Approaches Considered +- **Three separate PRs** — rejected: triplicates the state extraction, loses + coherence (see brainstorm). +- **Enforcement-only (defer report + CW)** — kept as fallback if scope creeps; + report + CW are S each and share plumbing. +- **Extract state holders to `commons/` now** — deferred (crux 1); higher refactor + risk than desktop-local for v1. + +## System-Wide Impact + +### Interaction graph +Mute action → sign kind-10000 → publish → echoes back into `DesktopLocalCache` → +`MuteListState` recomputes → `LiveHiddenUsers` emits → feed filters re-evaluate → +note disappears. Requires the emit to invalidate feeds (crux 2). + +### Error & failure propagation +- Sign/publish failure on a mute/report: match Android's optimistic model but + surface a failure toast; avoid leaving local state ahead of relays silently. +- Bunker (NIP-46) decrypt is async/remote — private mute list may be empty until + it returns; enforcement must tolerate a late-arriving set (recompute on emit). + +### State lifecycle risks +- Read-only (npub) accounts: no signer → can't decrypt private list or publish. + Enforce the public portion only; disable mute/block/report actions (no crash). +- "Block & Hide" from report dialog mutates the mute list and publishes a report + — two events; partial success must not corrupt local list state. + +### API surface parity +`isAcceptable` is consumed by feed filters, the chatroom/DM list, and +notifications. Fixing the predicate intentionally affects all three (muted users +vanish from DMs + notifications too). Verify each path. + +### Integration test scenarios +1. Mute user → notes vanish across Following/Global/Thread/Notifications + DM + list, live; unmute restores. +2. Block via report "Block & Hide" → kind-10000 published + note hidden. +3. Hidden word → matching notes collapse (via `isHiddenFor`). +4. Muted thread → replies under that root hidden. +5. Report note → kind-1984 published to public+private outbox, correct tags. +6. CW note → blurred; reveal shows; "always show" bypasses. +7. Read-only account → actions disabled, enforcement of public list still works. +8. Bunker account → mute list decrypts asynchronously; enforcement engages on + arrival. + +## Acceptance Criteria + +### Functional +- [x] Muting/blocking a user hides their notes across all desktop feeds + the DM + list **without restart**. *(DesktopHiddenUsersState + isAcceptable + feed + filter chaining + invalidateData; DM list already called isAcceptable.)* +- [ ] Blocked/hidden-words/muted-threads management screens list + remove entries, + persisted as kind-10000/10001 on relays. **DEFERRED** — write actions exist + (`hideWord`/`hideThread`/`showUser`…), but the settings *screens* are a + follow-up. +- [x] Mute action available from note context menu (`ShareMenu`). *(Mute-from- + profile deferred.)* +- [x] Report action (note) publishes a valid NIP-56 kind-1984; report dialog + offers the NIP-56 types + optional comment + "Block & report". *(Publishes + via broadcastToAll rather than a public/private outbox split — noted + simplification; NIP-56 is advisory.)* +- [x] Content-warning-tagged notes render scrimmed with reason + reveal. + *(Account setting bypass is honored; the toggle UI to flip it is deferred.)* +- [ ] All toggles + management entries reachable from the Content Filters + settings section. **DEFERRED** with the management screens. + +### Non-functional / quality gates +- [x] Read-only + bunker accounts behave correctly (write actions gated on + `isWriteable()`; enforcement tolerates late async decrypt via the flow). +- [x] Enforcement predicate is hash-set lookups (`Note.isHiddenFor`), no feed jank. +- [x] Unit tests for the enforcement predicate green (`DesktopMuteEnforcementTest`). +- [x] `spotlessApply` clean; commons + desktopApp compile; desktopApp + commons + test suites green. +- [ ] Manual testing sheet executed. *(Sheet written: + `desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md` + — needs a human run of the app.)* + +### Deferred to a focused follow-up +- Management screens (Blocked users / Hidden words / Muted threads) + their + Content-Filters settings entries. +- "Always show sensitive content" settings toggle (persisted) + mute/report from + the profile screen. +- Thread-root/profile-screen enforcement wiring (needs `iAccount` threaded into + those two composables). + +## Dependencies & Risks +- **Reactivity of feed filters to the hidden-users flow** — top risk (crux 2). +- **State-holder placement / `LiveHiddenUsers` type location** (crux 1). +- **Async decryption for bunker accounts.** +- No new third-party dependency anticipated (all quartz-internal) → no licensing + action required; re-check if a blur library is introduced for Phase 4. + +## Sources & References + +### Origin +- **Brainstorm:** `docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md`. + Carried-forward decisions: bundle P2+P3+P4; state in shared/desktop code + + desktop-native UI; enforcement via `Note.isHiddenFor`; report via quartz + `ReportEvent`; CW reuses the spam reveal; settings under Content Filters. + +### Internal references +- `desktopApp/.../model/DesktopIAccount.kt` (stub predicates ~L176–182, + `showSensitiveContent` ~L128) +- `desktopApp/.../feeds/DesktopFeedFilters.kt` (`feed()`/`applyFilter()` hooks) +- `desktopApp/.../ui/note/SpamCheckedNoteRender.kt` (reveal primitive) +- `desktopApp/.../ui/note/ShareMenu.kt`, `desktopApp/.../ui/NoteActions.kt` +- `quartz/.../nip56Reports/{ReportEvent,ReportType}.kt` +- `quartz/.../nip36SensitiveContent/{ContentWarningTag,EventExt}.kt` +- Android: `amethyst/.../model/nip51Lists/**`, `.../ui/screen/loggedIn/report/ReportNoteDialog.kt`, + `.../ui/components/SensitivityWarning.kt`, `.../settings/{Blocked,HiddenWords,MutedThreads}*.kt` + +### Related work +- Hashtag-spam filter (reveal pattern + Content Filters settings section) — + memory `moderation/` package + `SpamCheckedNoteRender`. +- Parity backlog P2/P3/P4 — `desktopApp/plans/2026-07-16-desktop-parity-gap-backlog.md`. diff --git a/desktopApp/plans/2026-07-24-moderation-test-results.md b/desktopApp/plans/2026-07-24-moderation-test-results.md new file mode 100644 index 0000000000..dbc0148049 --- /dev/null +++ b/desktopApp/plans/2026-07-24-moderation-test-results.md @@ -0,0 +1,49 @@ +# Test Results — Desktop Moderation & Safety + +**Executed:** 2026-07-24 · **Build:** branch `feat/desktop-moderation-safety` +**Method:** live app (`./gradlew :desktopApp:run`) on a real account (22 relays +connected), plus automated unit tests. Moderation publishes are logged under the +`[Moderation]` tag. + +Legend: **[x] PASS** · [ ] not run. + +## Live app — verified + +- [x] **Enforcement** — muting a user removes their notes from feeds live (no restart). +- [x] **Mute publishes** — kind-10000 mute list signed + broadcast to relays. +- [x] **Report (NIP-56) publishes** — kind-1984 report signed + broadcast to relays. +- [x] **Discoverability** — note **⋮** overflow **and** right-click both open the + note menu with Mute / Report / copy / broadcast (same items). +- [x] **Report dialog** — reason picker + comment + Report / Block & report. +- [x] **Profile ⋮** — Mute/Unmute + Report on another user's profile. +- [x] **Content-warning blur** — NIP-36 notes collapse with tap-to-reveal; + "always show sensitive content" toggle persists. +- [x] **Management screens** — Content Filters → Moderation & Safety lists + muted users / hidden words / muted threads with remove/add. +- [x] **Feedback (new)** — moderation actions now show a snackbar + (Muted user / Report sent / Reported & muted) and log the relay count. + +### Captured logs (this session) + +``` +INFO: [Moderation] mute+ kind=10000 id=95057975 → 22 relays +INFO: [Moderation] report(spam) kind=1984 id=447fbf93 → 22 relays +INFO: [Moderation] report(spam) kind=1984 id=c6f58e03 → 22 relays +``` + +→ one mute and two spam reports were signed and delivered to **22 relays** each, +with **no failures**. (A zero-relay send would log `WARNING … → 0 connected +relays (not delivered)`; a signer error logs `WARNING … report failed: …`.) + +## Automated tests — green + +- [x] `DesktopMuteEnforcementTest` — muted author hidden (even if followed), + hidden-word note dropped, clean notes pass. +- [x] `PreferencesSensitiveContentSettingsTest` — sensitive toggle maps + `on→true` / `off→null` (never false) and persists. +- [x] `./gradlew :desktopApp:test :commons:jvmTest` — **BUILD SUCCESSFUL**. +- [x] `spotlessApply` clean; pre-commit hook (spotless + tests) green on every commit. + +## Notes / known gaps +- Reports broadcast to **connected relays** (not a NIP-56 outbox split) — advisory per NIP-56. +- Thread *root* is always shown even if the author is muted (intentional — you navigated in). diff --git a/desktopApp/plans/2026-07-24-moderation-ux-manual-testing-sheet.md b/desktopApp/plans/2026-07-24-moderation-ux-manual-testing-sheet.md new file mode 100644 index 0000000000..836e57a2ec --- /dev/null +++ b/desktopApp/plans/2026-07-24-moderation-ux-manual-testing-sheet.md @@ -0,0 +1,108 @@ +# Manual Testing Sheet — Moderation UX & Reporting (discoverability + feedback) + +Covers the follow-on UX fixes: right-click note menu, the **⋮** overflow, and the +new **moderation publish logging**. Companion to the full feature sheet +(`2026-07-23-desktop-moderation-safety-manual-testing-sheet.md`). + +**Branch:** `worktree-feat-desktop-moderation-safety` (commit `65579ff` or later) +**Run:** `./gradlew :desktopApp:run` +**Watch the log** (this terminal, or `desktop-run*.log`) — moderation actions now +print a line tagged `[Moderation]`. + +Legend: **P** pass · **F** fail (note it) · **B** blocked. + +--- + +## 0. Preconditions + +- Logged in with a **writeable** account (local nsec, or a connected bunker). +- **At least one relay connected** — check the relay indicator. This matters: + a report/mute publishes to *connected relays only*; with 0 connected it is + logged as **not delivered**. +- Have another user **X**'s notes visible in a feed. + +--- + +## 1. Discoverability — how to reach the actions + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| D1 | Look at a note's action row (reply / ♥ / repost / zap / bookmark / **⋮**) | The last icon is now a **⋮ (three dots)**, not a share arrow | | +| D2 | **Left-click the ⋮** | Menu opens: Copy Text / Copy Note ID / Copy Event Link / Copy Raw JSON / Copy Web Link / Broadcast / **Mute user** / **Report…** | | +| D3 | **Right-click anywhere on a note** | Context menu opens: **Copy text / Mute user / Report…** | | +| D4 | Right-click **your own** note | Only **Copy text** (no Mute/Report on yourself) | | +| D5 | Open **another user's profile** → header shows a **⋮** next to Follow → click it | **Mute/Unmute user** + **Report…** | | +| D6 | On a **read-only (npub)** login, repeat D2/D3 | Only Copy items; no Mute/Report | | + +--- + +## 2. Report (NIP-56) — with verifiable feedback + +Do each, then **check the log line**. + +| # | Steps | Expected + log signal | Result | +|---|-------|------------------------|:---:| +| R1 | ⋮ (or right-click) → **Report…** | Dialog: reason radios + comment + **Report** / **Block & report** / Cancel | | +| R2 | Pick a reason → **Report** | Log shows `[Moderation] report() kind=1984 id=… → N relays` (N ≥ 1) | | +| R3 | Confirm on a relay (optional) | A kind-1984 event by you, tagging the note id + author, exists on relay | | +| R4 | **Report a user** from profile ⋮ → Report… | Log: `[Moderation] report-user() kind=1984 id=… → N relays` | | +| R5 | **Block & report** | Two log lines: `report(...)` **and** `mute+ kind=10000 …`; author's notes vanish | | +| R6 | Disconnect all relays, then Report | Log **WARN**: `… → 0 connected relays (not delivered)` — proves the "silent failure" is now visible | | + +> There is **no success snackbar yet** — confirmation is via the `[Moderation]` +> log line (and the author disappearing for mutes). A toast/snackbar is a noted +> follow-up; see "Known gaps" below. + +--- + +## 3. Mute / unmute — live effect + log + +| # | Steps | Expected + log | Result | +|---|-------|-----------------|:---:| +| M1 | ⋮ / right-click X's note → **Mute user** | X's notes disappear from the feed live; log `[Moderation] mute+ kind=10000 id=… → N relays` | | +| M2 | Settings → Content Filters → **Moderation & Safety** → Muted users | X is listed with a display name + **Unmute** | | +| M3 | Click **Unmute** | X reappears live; log `[Moderation] mute- kind=10000 … → N relays` | | +| M4 | **Hidden words** → type a word → **Add** | Notes with the word collapse; log `mute+ kind=10000` | | +| M5 | Profile ⋮ → **Mute user**, reopen menu | Label now reads **Unmute user** | | + +--- + +## 4. Content warning (NIP-36) — sanity + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| C1 | Sensitive/CW-tagged note in feed | Collapsed "Sensitive content" + reason + **Show** | | +| C2 | Settings → Moderation & Safety → toggle **Always show sensitive content** ON | CW notes render unblurred; persists after app restart | | + +--- + +## 5. Log reference + +While testing, moderation lines look like: + +``` +INFO: [Moderation] mute+ kind=10000 id=3f9a1c02 → 4 relays +INFO: [Moderation] report(spam) kind=1984 id=9b7e4410 → 4 relays +WARNING: [Moderation] report(spam) kind=1984 id=9b7e4410 → 0 connected relays (not delivered) +WARNING: [Moderation] report failed: +``` + +- **`→ N relays` (N≥1)** = published. **`→ 0 connected relays`** = you had no relay + connected; reconnect and retry. **`report failed:`** = signing/publish threw + (e.g. bunker rejected/timed out). + +--- + +## Known gaps (not bugs — noted follow-ups) +- **No success snackbar/toast** — feedback is via the log line + the muted author + disappearing. A user-facing confirmation is a small follow-up (needs a + SnackbarHostState threaded to the note card / dialog). +- Reports are broadcast to **connected relays** (not a NIP-56 outbox split). + +## Sign-off +- [ ] ⋮ overflow + right-click both reach Mute/Report (§1). +- [ ] Report logs a `→ N relays` line with N ≥ 1 (§2 R2/R4). +- [ ] Zero-relay report is logged as not-delivered (§2 R6). +- [ ] Mute hides live + shows in management screen + unmute restores (§3). + +Overall: ☐ PASS ☐ FAIL — notes: __________________________________________ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index ec33ae1ff2..a472cd2b92 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -148,6 +148,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -1430,6 +1431,7 @@ private fun AppInner( LocalNamecoinPreferences provides namecoinPreferences, LocalNamecoinService provides namecoinService, LocalSpamExemptKeys provides spamExemptKeys, + com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, ) { val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() Column(modifier = Modifier.fillMaxSize()) { @@ -1721,6 +1723,7 @@ fun MainContent( SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, BlossomServersEvent.KIND, + MuteListEvent.KIND, ), authors = listOf(account.pubKeyHex), limit = 5, @@ -1745,7 +1748,8 @@ fun MainContent( // accountRelays' persisted copy. if (event is AdvertisedRelayListEvent || event is ChatMessageRelayListEvent || - event is BlossomServersEvent + event is BlossomServersEvent || + event is MuteListEvent ) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) @@ -1779,6 +1783,7 @@ fun MainContent( SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, BlossomServersEvent.KIND, + MuteListEvent.KIND, ), authors = listOf(account.pubKeyHex), limit = 10, @@ -1793,7 +1798,7 @@ fun MainContent( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) { + if (event is AdvertisedRelayListEvent || event is BlossomServersEvent || event is MuteListEvent) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) } @@ -2055,6 +2060,8 @@ fun MainContent( CompositionLocalProvider( LocalRelayCategories provides relayCategories, LocalBlossomServers provides iAccount.blossomServerList.flow, + com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost provides snackbarHostState, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, @@ -2663,6 +2670,9 @@ fun RelaySettingsScreen( settings = LocalHashtagSpamSettings.current, ) Spacer(Modifier.height(16.dp)) + com.vitorpamplona.amethyst.desktop.ui.settings + .ModerationSettingsSection() + Spacer(Modifier.height(16.dp)) HorizontalDivider() Spacer(Modifier.height(16.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index ebcfb2e98d..aa0d81af9f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.feeds import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder @@ -59,17 +60,18 @@ private fun List.deduplicateReposts(): List = */ class DesktopGlobalFeedFilter( private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "global" override fun feed(): List = cache.notes - .filterIntoSet { _, note -> isFeedNote(note.event) } + .filterIntoSet { _, note -> isFeedNote(note.event) && !note.isHiddenFor(hidden()) } .sortedWith(DefaultFeedOrder) .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isFeedNote(it.event) } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isFeedNote(it.event) && !it.isHiddenFor(hidden()) } override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() @@ -81,6 +83,7 @@ class DesktopGlobalFeedFilter( */ class DesktopFollowingFeedFilter( private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, private val followedPubkeys: () -> Set, ) : AdditiveFeedFilter() { override fun feedKey(): String = "following-${followedPubkeys().hashCode()}" @@ -89,7 +92,7 @@ class DesktopFollowingFeedFilter( val follows = followedPubkeys() return cache.notes .filterIntoSet { _, note -> - isFeedNote(note.event) && note.author?.pubkeyHex in follows + isFeedNote(note.event) && note.author?.pubkeyHex in follows && !note.isHiddenFor(hidden()) }.sortedWith(DefaultFeedOrder) .deduplicateReposts() .take(limit()) @@ -98,7 +101,7 @@ class DesktopFollowingFeedFilter( override fun applyFilter(newItems: Set): Set { val follows = followedPubkeys() return newItems.filterTo(HashSet()) { - isFeedNote(it.event) && it.author?.pubkeyHex in follows + isFeedNote(it.event) && it.author?.pubkeyHex in follows && !it.isHiddenFor(hidden()) } } @@ -115,12 +118,14 @@ class DesktopCustomFeedFilter( private val cache: DesktopLocalCache, private val feedId: String, private val source: FeedSource.Filter, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "custom-$feedId" private fun matchesSource(note: Note): Boolean { val event = note.event ?: return false if (!isFeedNote(event)) return false + if (note.isHiddenFor(hidden())) return false // Kind filter if (source.kinds.isNotEmpty() && event.kind !in source.kinds) return false @@ -167,6 +172,7 @@ class DesktopCustomFeedFilter( class DesktopThreadFilter( private val noteId: HexKey, private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : FeedFilter() { override fun feedKey(): String = "thread-$noteId" @@ -174,6 +180,8 @@ class DesktopThreadFilter( val root = cache.getNoteIfExists(noteId) ?: return emptyList() // Use LinkedHashSet for O(1) containment checks (was O(R) with MutableList) val seen = LinkedHashSet() + // The thread root is always shown even if muted — the user explicitly + // navigated into it. Replies by muted/blocked authors are still hidden. seen.add(root) collectReplies(root, seen) return seen.sortedWith(compareBy { it.createdAt() ?: 0L }) @@ -183,7 +191,9 @@ class DesktopThreadFilter( note: Note, seen: LinkedHashSet, ) { + val choices = hidden() for (reply in note.replies) { + if (reply.isHiddenFor(choices)) continue if (seen.add(reply)) { collectReplies(reply, seen) } @@ -207,6 +217,7 @@ class DesktopProfileFeedFilter( private val pubkey: HexKey, private val cache: DesktopLocalCache, private val repliesOnly: Boolean = false, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = if (repliesOnly) "profile-$pubkey-replies" else "profile-$pubkey" @@ -220,6 +231,7 @@ class DesktopProfileFeedFilter( private fun isProfileNote(note: Note): Boolean { val event = note.event ?: return false if (note.author?.pubkeyHex != pubkey) return false + if (note.isHiddenFor(hidden())) return false return if (repliesOnly) { isReply(event) } else { @@ -265,16 +277,17 @@ class DesktopBookmarkFeedFilter( */ class DesktopReadsFeedFilter( private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "reads" override fun feed(): List = cache.notes - .filterIntoSet { _, note -> note.event is LongTextNoteEvent } + .filterIntoSet { _, note -> note.event is LongTextNoteEvent && !note.isHiddenFor(hidden()) } .sortedWith(DefaultFeedOrder) .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent && !it.isHiddenFor(hidden()) } override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) @@ -288,6 +301,7 @@ class DesktopReadsFeedFilter( class DesktopNotificationFeedFilter( private val userPubKeyHex: HexKey, private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { companion object { val NOTIFICATION_KINDS = @@ -316,7 +330,8 @@ class DesktopNotificationFeedFilter( val event = note.event ?: return false return event.kind in NOTIFICATION_KINDS && event.pubKey != userPubKeyHex && - event.isTaggedUser(userPubKeyHex) + event.isTaggedUser(userPubKeyHex) && + !note.isHiddenFor(hidden()) } } @@ -327,6 +342,7 @@ class DesktopNotificationFeedFilter( class DesktopSearchFeedFilter( private val query: String, private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "search-$query" @@ -335,7 +351,7 @@ class DesktopSearchFeedFilter( return cache.notes .filterIntoSet { _, note -> val event = note.event ?: return@filterIntoSet false - event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) && !note.isHiddenFor(hidden()) }.sortedWith(DefaultFeedOrder) .take(limit()) } @@ -344,7 +360,7 @@ class DesktopSearchFeedFilter( val lowerQuery = query.lowercase() return newItems.filterTo(HashSet()) { val event = it.event - event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) && !it.isHiddenFor(hidden()) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt new file mode 100644 index 0000000000..81356eadc7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt @@ -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.desktop.model + +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * Desktop mute/block state holder. + * + * Assembles the user's NIP-51 mute list (kind 10000, [MuteListEvent]) and the + * legacy block people list (kind 30000 `d=mute`, [PeopleListEvent]) into a single + * [LiveHiddenUsers] value that [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount] + * feeds to [com.vitorpamplona.amethyst.commons.model.Note.isHiddenFor]. + * + * Both lists carry a mix of public tags and an NIP-44-encrypted private section; + * the shared [MuteListDecryptionCache]/[PeopleListDecryptionCache] handle the + * async decrypt (and are no-ops for read-only accounts, which simply see the + * public portion). The result is exposed as a hot [StateFlow] so feeds can + * re-filter live: when the list events change (or decryption resolves), a new + * [LiveHiddenUsers] emits and observers call `invalidateData()`. + * + * Mirrors Android's `HiddenUsersState`, but self-contained for the desktop + * cache/account shape (no `AccountSettings` dependency). + */ +class DesktopHiddenUsersState( + private val signer: NostrSigner, + private val cache: ICacheProvider, + private val scope: CoroutineScope, + /** From the "always show sensitive content" setting; `null` = respect content warnings. */ + private val showSensitiveContent: StateFlow = MutableStateFlow(null), +) { + private val muteCache = MuteListDecryptionCache(signer) + private val blockCache = PeopleListDecryptionCache(signer) + + // Strong refs so the GC keeps these addressable notes (and their decrypt caches) alive. + private val muteListNote = cache.getOrCreateAddressableNote(MuteListEvent.createAddress(signer.pubKey)) + private val blockListNote = cache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(signer.pubKey)) + + /** Session-only user hides (e.g. "hide this spammer" without persisting a mute). */ + val transientHiddenUsers = MutableStateFlow>(emptySet()) + + private val muteEventFlow: StateFlow = muteListNote.flow().metadata.stateFlow + private val blockEventFlow: StateFlow = blockListNote.flow().metadata.stateFlow + + private suspend fun assemble( + muteEvent: MuteListEvent?, + blockEvent: PeopleListEvent?, + transient: Set, + showSensitive: Boolean?, + ): LiveHiddenUsers { + val hiddenUsers = mutableSetOf() + val hiddenWords = mutableSetOf() + val mutedThreads = mutableSetOf() + + if (muteEvent != null) { + hiddenUsers.addAll(muteCache.mutedUserIdSet(muteEvent)) + hiddenWords.addAll(muteCache.mutedWordSet(muteEvent).map { it.word }) + mutedThreads.addAll(muteCache.mutedThreadIdSet(muteEvent)) + } + if (blockEvent != null) { + hiddenUsers.addAll(blockCache.userIdSet(blockEvent)) + } + + return LiveHiddenUsers( + showSensitiveContent = showSensitive, + hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }, + hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() }, + spammersHashCodes = transient.mapTo(HashSet()) { it.hashCode() }, + hiddenUsers = hiddenUsers, + spammers = transient, + hiddenWords = hiddenWords, + mutedThreads = mutedThreads, + ) + } + + /** Hot flow of the current moderation choices. Emits on every list/setting change. */ + val flow: StateFlow = + combine( + muteEventFlow, + blockEventFlow, + transientHiddenUsers, + showSensitiveContent, + ) { muteState, blockState, transient, showSensitive -> + assemble( + muteState.note.event as? MuteListEvent, + blockState.note.event as? PeopleListEvent, + transient, + showSensitive, + ) + }.onStart { emit(LiveHiddenUsers.EMPTY) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, LiveHiddenUsers.EMPTY) + + /** The user's current mute list event, or null if none has loaded yet. */ + fun currentMuteList(): MuteListEvent? = muteListNote.event as? MuteListEvent + + fun hideUserTransiently(pubkeyHex: String) { + transientHiddenUsers.value = transientHiddenUsers.value + pubkeyHex + } + + fun showUserTransiently(pubkeyHex: String) { + transientHiddenUsers.value = transientHiddenUsers.value - pubkeyHex + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 75079df07f..5c83476af8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.model import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.INwcSignerState +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListRepository @@ -32,11 +33,13 @@ import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRep import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -50,6 +53,13 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEve import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -58,10 +68,12 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClient import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Logger /** * Desktop implementation of IAccount. @@ -138,13 +150,34 @@ class DesktopIAccount( // --------------------------------------------------------------------------------- - override val showSensitiveContent: Boolean? = null + /** + * "Always show sensitive content" preference (NIP-36). `null` = respect + * content warnings (blur), `true` = always show. Persisted via + * [PreferencesSensitiveContentSettings] (shared with `amy`); flip it with + * [setAlwaysShowSensitive]. + */ + private val sensitiveContentSettings = PreferencesSensitiveContentSettings() + val showSensitiveContentSetting: StateFlow = sensitiveContentSettings.showSensitiveContent - override val hiddenWordsCase: List = emptyList() + fun setAlwaysShowSensitive(alwaysShow: Boolean) = sensitiveContentSettings.setAlwaysShow(alwaysShow) - override val hiddenUsersHashCodes: Set = emptySet() + /** + * Mute (kind 10000) + block (kind 30000 `d=mute`) state, assembled into a + * live [com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers] used by the + * feed filters and [isHidden]/[isAcceptable]. See [DesktopHiddenUsersState]. + */ + val hiddenUsersState = DesktopHiddenUsersState(signer, localCache, scope, showSensitiveContentSetting) - override val spammersHashCodes: Set = emptySet() + /** Current moderation choices — feeds observe this to re-filter live on mute/block. */ + val hiddenUsers: StateFlow get() = hiddenUsersState.flow + + override val showSensitiveContent: Boolean? get() = hiddenUsersState.flow.value.showSensitiveContent + + override val hiddenWordsCase: List get() = hiddenUsersState.flow.value.hiddenWordsCase + + override val hiddenUsersHashCodes: Set get() = hiddenUsersState.flow.value.hiddenUsersHashCodes + + override val spammersHashCodes: Set get() = hiddenUsersState.flow.value.spammersHashCodes override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex) override val marmotGroupList = @@ -173,12 +206,12 @@ class DesktopIAccount( override fun followingKeySet(): Set = kind3FollowList.flow.value.authors - override fun isHidden(user: User): Boolean = false + override fun isHidden(user: User): Boolean = hiddenUsersState.flow.value.isUserHidden(user.pubkeyHex) override fun isAcceptable(note: Note): Boolean { - // Accept all notes on desktop for now val event = note.event ?: return true - return !localCache.hasBeenDeleted(event) + if (localCache.hasBeenDeleted(event)) return false + return !note.isHiddenFor(hiddenUsersState.flow.value) } override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { @@ -351,6 +384,107 @@ class DesktopIAccount( } } + // ----- Moderation write actions (NIP-51 mute list + NIP-56 reports) ----- + + /** Mute a user (private entry). Persists to the kind-10000 mute list and hides live. */ + suspend fun hideUser(pubkeyHex: HexKey) = updateMuteList(UserTag(pubkeyHex), isPrivate = true, add = true) + + /** Un-mute a user. */ + suspend fun showUser(pubkeyHex: HexKey) = updateMuteList(UserTag(pubkeyHex), isPrivate = true, add = false) + + /** Hide a word/phrase (private entry). Notes containing it collapse. */ + suspend fun hideWord(word: String) = updateMuteList(WordTag(word), isPrivate = true, add = true) + + suspend fun showWord(word: String) = updateMuteList(WordTag(word), isPrivate = true, add = false) + + /** Mute a thread by its root event id. */ + suspend fun hideThread(rootIdHex: HexKey) = updateMuteList(EventTag(rootIdHex), isPrivate = true, add = true) + + suspend fun showThread(rootIdHex: HexKey) = updateMuteList(EventTag(rootIdHex), isPrivate = true, add = false) + + private suspend fun updateMuteList( + tag: MuteTag, + isPrivate: Boolean, + add: Boolean, + ) { + if (!isWriteable()) return + try { + val current = hiddenUsersState.currentMuteList() + val event = + when { + !add -> if (current != null) MuteListEvent.remove(current, tag, signer) else return + current == null -> MuteListEvent.create(tag, isPrivate, signer) + else -> MuteListEvent.add(current, tag, isPrivate, signer) + } + // Optimistic local apply so enforcement + the management screens update + // immediately, then fan out to relays. + localCache.justConsumeMyOwnEvent(event) + publishModeration(event, if (add) "mute+" else "mute-") + } catch (e: Exception) { + moderationLog.warning("[Moderation] mute list update failed: ${e.message}") + throw e + } + } + + /** Publish a NIP-56 (kind 1984) report about a note. */ + suspend fun report( + note: Note, + type: ReportType, + comment: String = "", + ) { + val reported = note.event ?: return + reportEvent(reported, type, comment) + } + + /** Publish a NIP-56 (kind 1984) report about a raw event. */ + suspend fun reportEvent( + reportedEvent: Event, + type: ReportType, + comment: String = "", + ) { + if (!isWriteable()) return + try { + val signed = signer.sign(ReportEvent.build(reportedEvent, type, comment)) + publishModeration(signed, "report(${type.code})") + } catch (e: Exception) { + moderationLog.warning("[Moderation] report failed: ${e.message}") + throw e + } + } + + /** Publish a NIP-56 (kind 1984) report about a user. */ + suspend fun report( + userPubKeyHex: HexKey, + type: ReportType, + comment: String = "", + ) { + if (!isWriteable()) return + try { + val signed = signer.sign(ReportEvent.build(userPubKeyHex, type, comment)) + publishModeration(signed, "report-user(${type.code})") + } catch (e: Exception) { + moderationLog.warning("[Moderation] user report failed: ${e.message}") + throw e + } + } + + /** + * Broadcast a moderation event and log the outcome — including the relay + * count, so a publish to zero connected relays is visible rather than silent. + */ + private fun publishModeration( + event: Event, + action: String, + ) { + val relayCount = relayManager.connectedRelays.value.size + relayManager.broadcastToAll(event) + if (relayCount == 0) { + moderationLog.warning("[Moderation] $action kind=${event.kind} id=${event.id.take(8)} → 0 connected relays (not delivered)") + } else { + moderationLog.info("[Moderation] $action kind=${event.kind} id=${event.id.take(8)} → $relayCount relays") + } + } + private fun addEventToChatroom( event: com.vitorpamplona.quartz.nip01Core.core.Event, roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey, @@ -365,5 +499,6 @@ class DesktopIAccount( companion object { const val CLIENT_TAG_NAME = "Amethyst" + private val moderationLog: Logger = Logger.getLogger("DesktopModeration") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt new file mode 100644 index 0000000000..01397da087 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt @@ -0,0 +1,31 @@ +/* + * 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.desktop.model + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * The logged-in [DesktopIAccount], provided at the app content root so deeply + * nested note surfaces (context menus, profile actions) can reach account-level + * moderation actions (mute/block/report) without threading it through every + * composable. Null when logged out or in a preview. + */ +val LocalDesktopIAccount = staticCompositionLocalOf { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ac737368e4..16696692b5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.background @@ -205,29 +207,45 @@ fun FeedNoteCard( forceReveal: Boolean = false, ) { val event = note.event ?: return + var showReportDialog by remember(note.idHex) { mutableStateOf(false) } + // Same action list the ⋮ overflow uses, so right-click and ⋮ are identical. + val menuActions = + com.vitorpamplona.amethyst.desktop.ui.note.rememberNoteMenuActions(event, relayManager) { + showReportDialog = true + } + SpamCheckedNoteRender( note = note, localCache = localCache, forceReveal = forceReveal, ) { - FeedNoteCardBody( - note = note, - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onReply, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onImageClick = onImageClick, - onMediaClick = onMediaClick, - onHashtagClick = onHashtagClick, - followedUsers = followedUsers, - myPubKeyHex = myPubKeyHex, - onFollow = onFollow, - ) + ContextMenuArea(items = { + menuActions.map { action -> ContextMenuItem(action.label) { action.onClick() } } + }) { + FeedNoteCardBody( + note = note, + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onReply, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + onHashtagClick = onHashtagClick, + followedUsers = followedUsers, + myPubKeyHex = myPubKeyHex, + onFollow = onFollow, + ) + } + } + + if (showReportDialog) { + com.vitorpamplona.amethyst.desktop.ui.note + .NoteReportDialog(event) { showReportDialog = false } } } @@ -690,15 +708,16 @@ fun FeedScreen( // DesktopFeedViewModel keyed on feedMode — recreated on mode switch val viewModel = - remember(feedMode, activeFeedId) { + remember(feedMode, activeFeedId, iAccount) { + val hidden = { iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY } val filter = when (feedMode) { FeedMode.GLOBAL -> { - DesktopGlobalFeedFilter(localCache) + DesktopGlobalFeedFilter(localCache, hidden) } FeedMode.FOLLOWING -> { - DesktopFollowingFeedFilter(localCache) { + DesktopFollowingFeedFilter(localCache, hidden) { localCache.followedUsers.value } } @@ -709,10 +728,11 @@ fun FeedScreen( activeFeedId ?: "custom", activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource .Filter(), + hidden, ) } } - DesktopFeedViewModel(filter, localCache) + DesktopFeedViewModel(filter, localCache, iAccount?.hiddenUsers) } // Cancel old ViewModel's viewModelScope on recreation diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt new file mode 100644 index 0000000000..94cb6dc7e6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt @@ -0,0 +1,31 @@ +/* + * 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.desktop.ui + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * The app's shared [SnackbarHostState], provided at the content root so deeply + * nested surfaces (note menus, moderation actions) can show a brief confirmation + * without threading a callback through every composable. Null in previews/tests. + */ +val LocalSnackbarHost = staticCompositionLocalOf { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 9c91070f36..1aa9f3a1fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1313,7 +1313,7 @@ fun NoteActionsRow( ) } - // Share menu + // Overflow menu: copy / broadcast / share links + mute / report. val shareMenuState = rememberShareMenuState() Box { IconButton( @@ -1321,8 +1321,8 @@ fun NoteActionsRow( modifier = Modifier.size(32.dp), ) { Icon( - MaterialSymbols.Share, - contentDescription = "Share", + MaterialSymbols.MoreVert, + contentDescription = "More actions", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ba8a07a4b4..ecbd160bc1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -119,11 +119,15 @@ fun ThreadScreen( var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } // DesktopFeedViewModel reads thread from cache (root + replies via graph walk) + val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current val threadViewModel = - remember(noteId) { + remember(noteId, iAccount) { DesktopFeedViewModel( - DesktopThreadFilter(noteId, localCache), + DesktopThreadFilter(noteId, localCache) { + iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY + }, localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(threadViewModel) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 896b9d930f..8682b79941 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -185,12 +185,23 @@ fun UserProfileScreen( val scope = rememberCoroutineScope() + val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current + val profileSnackbar = com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost.current + val hidden = { + iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY + } + var showProfileModMenu by remember(pubKeyHex) { mutableStateOf(false) } + var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) } + val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState() + val isUserMuted = profileHidden.isUserHidden(pubKeyHex) + // User's posts — cache-backed via DesktopFeedViewModel val profileViewModel = - remember(pubKeyHex) { + remember(pubKeyHex, iAccount) { DesktopFeedViewModel( - DesktopProfileFeedFilter(pubKeyHex, localCache), + DesktopProfileFeedFilter(pubKeyHex, localCache, hidden = hidden), localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(profileViewModel) { @@ -207,10 +218,11 @@ fun UserProfileScreen( // User's replies — separate VM, same cache. Predicate inside the filter. val repliesViewModel = - remember(pubKeyHex) { + remember(pubKeyHex, iAccount) { DesktopFeedViewModel( - DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true), + DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true, hidden = hidden), localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(repliesViewModel) { @@ -581,6 +593,51 @@ fun UserProfileScreen( } } + // Moderation overflow (mute / report) for other profiles. + if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) { + Box { + IconButton( + onClick = { showProfileModMenu = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.MoreVert, + contentDescription = "Moderation actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu( + expanded = showProfileModMenu, + onDismissRequest = { showProfileModMenu = false }, + ) { + DropdownMenuItem( + text = { Text(if (isUserMuted) "Unmute user" else "Mute user") }, + onClick = { + scope.launch { + if (isUserMuted) { + iAccount.showUser(pubKeyHex) + profileSnackbar?.showSnackbar("Unmuted user") + } else { + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Muted user") + } + } + showProfileModMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Report…") }, + onClick = { + showProfileReportDialog = true + showProfileModMenu = false + }, + ) + } + } + Spacer(Modifier.width(4.dp)) + } + // Follow/Unfollow button for other profiles — compact to // match the header row height (32dp); primary-coloured // text button so the affordance is still legible. @@ -1196,6 +1253,33 @@ fun UserProfileScreen( onDismiss = { showEditProfile = false }, ) } + + if (showProfileReportDialog && iAccount != null) { + com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( + onDismiss = { showProfileReportDialog = false }, + onReport = { type, comment -> + scope.launch { + try { + iAccount.report(pubKeyHex, type, comment) + profileSnackbar?.showSnackbar("Report sent") + } catch (e: Exception) { + profileSnackbar?.showSnackbar("Report failed: ${e.message}") + } + } + }, + onBlockAndReport = { type, comment -> + scope.launch { + try { + iAccount.report(pubKeyHex, type, comment) + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Reported & muted") + } catch (e: Exception) { + profileSnackbar?.showSnackbar("Report failed: ${e.message}") + } + } + }, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt new file mode 100644 index 0000000000..673be60738 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt @@ -0,0 +1,109 @@ +/* + * 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.desktop.ui.note + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip56Reports.ReportType + +private val REPORT_OPTIONS = + listOf( + ReportType.SPAM to "Spam", + ReportType.PROFANITY to "Profanity / Hateful speech", + ReportType.IMPERSONATION to "Impersonation", + ReportType.NUDITY to "Nudity / Sexual content", + ReportType.ILLEGAL to "Illegal content", + ReportType.MALWARE to "Malware / Phishing", + ) + +/** + * NIP-56 report dialog. Lets the user pick a report reason and optionally add a + * comment, then either just report or report-and-block the author. Mirrors the + * Android `ReportNoteDialog`, adapted to Compose Desktop. + */ +@Composable +fun ReportNoteDialog( + onDismiss: () -> Unit, + onReport: (ReportType, String) -> Unit, + onBlockAndReport: (ReportType, String) -> Unit, +) { + var selected by remember { mutableStateOf(REPORT_OPTIONS.first().first) } + var comment by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Report note") }, + text = { + Column { + REPORT_OPTIONS.forEach { (type, label) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .selectable(selected = selected == type, onClick = { selected = type }) + .padding(vertical = 2.dp), + ) { + RadioButton(selected = selected == type, onClick = { selected = type }) + Text(label, modifier = Modifier.padding(start = 4.dp)) + } + } + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton(onClick = { + onReport(selected, comment) + onDismiss() + }) { Text("Report") } + }, + dismissButton = { + Row { + TextButton(onClick = { + onBlockAndReport(selected, comment) + onDismiss() + }) { Text("Block & report") } + TextButton(onClick = onDismiss) { Text("Cancel") } + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt index 7287250cba..701de331e7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt @@ -22,17 +22,20 @@ package com.vitorpamplona.amethyst.desktop.ui.note import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import kotlinx.coroutines.launch import java.awt.Toolkit import java.awt.datatransfer.StringSelection @@ -52,62 +55,132 @@ class ShareMenuState { @Composable fun rememberShareMenuState(): ShareMenuState = remember { ShareMenuState() } +/** A single note-menu entry — one source of truth shared by the ⋮ overflow and the right-click menu. */ +class NoteMenuAction( + val label: String, + val onClick: () -> Unit, +) + +/** + * The canonical note action list. Rendered identically by [ShareMenu] (⋮ dropdown) + * and the feed's right-click context menu, so both offer the same items. + * Moderation actions (mute/report) are added only for other authors on a + * writeable account; each action shows a snackbar via [LocalSnackbarHost]. + */ +@Composable +fun rememberNoteMenuActions( + event: Event, + relayManager: DesktopRelayConnectionManager, + onReportClick: () -> Unit, +): List { + val account = LocalDesktopIAccount.current + val snackbar = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + val canModerate = account != null && account.isWriteable() && event.pubKey != account.pubKey + return remember(event, canModerate) { + buildList { + add(NoteMenuAction("Copy Text") { copyToClipboard(event.content) }) + add(NoteMenuAction("Copy Note ID") { copyToClipboard("nostr:${NNote.create(event.id)}") }) + add( + NoteMenuAction("Copy Event Link") { + val relays = relayManager.connectedRelays.value.take(3) + copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}") + }, + ) + add(NoteMenuAction("Copy Raw JSON") { copyToClipboard(event.toJson()) }) + add( + NoteMenuAction("Copy Web Link") { + copyToClipboard("https://njump.me/${NEvent.create(event.id, event.pubKey, event.kind, emptyList())}") + }, + ) + add( + NoteMenuAction("Broadcast") { + relayManager.broadcastToAll(event) + scope.launch { snackbar?.showSnackbar("Broadcast to relays") } + }, + ) + if (canModerate) { + add( + NoteMenuAction("Mute user") { + scope.launch { + try { + account.hideUser(event.pubKey) + snackbar?.showSnackbar("Muted user") + } catch (e: Exception) { + snackbar?.showSnackbar("Mute failed: ${e.message}") + } + } + }, + ) + add(NoteMenuAction("Report…", onReportClick)) + } + } + } +} + +/** The ⋮ overflow dropdown. Renders [rememberNoteMenuActions] + the report dialog. */ @Composable fun ShareMenu( state: ShareMenuState, event: Event, relayManager: DesktopRelayConnectionManager, ) { + var showReportDialog by remember { mutableStateOf(false) } + val actions = rememberNoteMenuActions(event, relayManager) { showReportDialog = true } + DropdownMenu( expanded = state.expanded, onDismissRequest = { state.dismiss() }, ) { - DropdownMenuItem( - text = { Text("Copy Text") }, - onClick = { - copyToClipboard(event.content) - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Note ID") }, - onClick = { - copyToClipboard("nostr:${NNote.create(event.id)}") - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Event Link") }, - onClick = { - val relays = relayManager.connectedRelays.value.take(3) - copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}") - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Raw JSON") }, - onClick = { - copyToClipboard(event.toJson()) - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Web Link") }, - onClick = { - val nevent = NEvent.create(event.id, event.pubKey, event.kind, emptyList()) - copyToClipboard("https://njump.me/$nevent") - state.dismiss() - }, - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Broadcast") }, - onClick = { - relayManager.broadcastToAll(event) - state.dismiss() - }, - ) + actions.forEach { action -> + DropdownMenuItem( + text = { Text(action.label) }, + onClick = { + action.onClick() + state.dismiss() + }, + ) + } } + + if (showReportDialog) { + NoteReportDialog(event) { showReportDialog = false } + } +} + +/** Report dialog wired to the account + snackbar, reused by every note surface. */ +@Composable +fun NoteReportDialog( + event: Event, + onDismiss: () -> Unit, +) { + val account = LocalDesktopIAccount.current ?: return + val snackbar = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + ReportNoteDialog( + onDismiss = onDismiss, + onReport = { type, comment -> + scope.launch { + try { + account.reportEvent(event, type, comment) + snackbar?.showSnackbar("Report sent") + } catch (e: Exception) { + snackbar?.showSnackbar("Report failed: ${e.message}") + } + } + }, + onBlockAndReport = { type, comment -> + scope.launch { + try { + account.reportEvent(event, type, comment) + account.hideUser(event.pubKey) + snackbar?.showSnackbar("Reported & muted") + } catch (e: Exception) { + snackbar?.showSnackbar("Report failed: ${e.message}") + } + } + }, + ) } private fun copyToClipboard(text: String) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt index 6924ed6235..9d38369b36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt @@ -20,6 +20,15 @@ */ package com.vitorpamplona.amethyst.desktop.ui.note +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -27,6 +36,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings @@ -34,8 +46,12 @@ import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys import com.vitorpamplona.amethyst.commons.moderation.displayedEvent import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import kotlinx.coroutines.flow.MutableStateFlow /** * Wraps a note render in the hashtag-spam check. @@ -80,24 +96,73 @@ fun SpamCheckedNoteRender( ) } + // NIP-36 content warning: blur sensitive content unless the account opted + // into "always show sensitive content". + val account = LocalDesktopIAccount.current + val showSensitiveFlow = remember(account) { account?.showSensitiveContentSetting ?: MutableStateFlow(null) } + val showSensitive by showSensitiveFlow.collectAsState() + val isSensitive = remember(noteIdHex, displayedEvent) { displayedEvent?.isSensitiveOrNSFW() == true } + var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) } - if (!forceReveal && isSpam && !revealed && displayedEvent != null) { - val authorPubkey = displayedEvent.pubKey - val author = localCache?.getUserIfExists(authorPubkey) - val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8) - val avatarUrl = author?.profilePicture() - val hashtagCount = displayedEvent.tags.countHashtags() - CollapsedSpamNote( - authorPubkeyHex = authorPubkey, - authorDisplayName = displayName, - authorAvatarUrl = avatarUrl, - hashtagCount = hashtagCount, - threshold = threshold, - onReveal = { revealed = true }, - ) - } else { - normal() + when { + forceReveal || revealed -> normal() + isSpam && displayedEvent != null -> { + val authorPubkey = displayedEvent.pubKey + val author = localCache?.getUserIfExists(authorPubkey) + val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8) + val avatarUrl = author?.profilePicture() + val hashtagCount = displayedEvent.tags.countHashtags() + CollapsedSpamNote( + authorPubkeyHex = authorPubkey, + authorDisplayName = displayName, + authorAvatarUrl = avatarUrl, + hashtagCount = hashtagCount, + threshold = threshold, + onReveal = { revealed = true }, + ) + } + isSensitive && showSensitive != true && displayedEvent != null -> { + CollapsedContentWarning( + reason = displayedEvent.contentWarningReason(), + onReveal = { revealed = true }, + ) + } + else -> normal() + } +} + +/** + * Collapsed placeholder for a NIP-36 content-warning note. Mirrors the + * spam-collapse reveal affordance: the body stays hidden behind a scrim with the + * warning reason until the user taps "Show". + */ +@Composable +private fun CollapsedContentWarning( + reason: String?, + onReveal: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Sensitive content", style = MaterialTheme.typography.titleSmall) + if (!reason.isNullOrBlank()) { + Text( + reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + TextButton(onClick = onReveal) { Text("Show") } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt new file mode 100644 index 0000000000..506982e916 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt @@ -0,0 +1,152 @@ +/* + * 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.desktop.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.unit.dp +import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache +import kotlinx.coroutines.launch + +/** + * Content-Filters entry for NIP-36 sensitive content + NIP-51 mute-list + * management (blocked users / hidden words / muted threads). Reads/writes the + * logged-in [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount] via + * [LocalDesktopIAccount]; the lists are driven by its live hidden-users flow, so + * removing an entry publishes the updated kind-10000 and un-hides immediately. + */ +@Composable +fun ModerationSettingsSection(modifier: Modifier = Modifier) { + val account = LocalDesktopIAccount.current ?: return + val cache = LocalDesktopCache.current + val scope = rememberCoroutineScope() + + val hidden by account.hiddenUsers.collectAsState() + val showSensitive by account.showSensitiveContentSetting.collectAsState() + val writeable = account.isWriteable() + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Moderation & Safety", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + // NIP-36 sensitive content + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = showSensitive == true, onCheckedChange = { account.setAlwaysShowSensitive(it) }) + Spacer(Modifier.width(8.dp)) + Text("Always show sensitive content", style = MaterialTheme.typography.bodyMedium) + } + + // Muted users + if (hidden.hiddenUsers.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("Muted users (${hidden.hiddenUsers.size})", style = MaterialTheme.typography.labelLarge) + hidden.hiddenUsers.sorted().forEach { hex -> + val name = remember(hex) { cache?.getUserIfExists(hex)?.toBestDisplayName() ?: hex.take(12) } + EntryRow(label = name, actionLabel = "Unmute", enabled = writeable) { + scope.launch { account.showUser(hex) } + } + } + } + + // Hidden words + Spacer(Modifier.height(12.dp)) + Text("Hidden words", style = MaterialTheme.typography.labelLarge) + var newWord by remember { mutableStateOf("") } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = newWord, + onValueChange = { newWord = it }, + label = { Text("Add word to hide") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + enabled = writeable && newWord.isNotBlank(), + onClick = { + val w = newWord.trim() + if (w.isNotEmpty()) { + scope.launch { account.hideWord(w) } + newWord = "" + } + }, + ) { Text("Add") } + } + hidden.hiddenWords.sorted().forEach { word -> + EntryRow(label = word, actionLabel = "Remove", enabled = writeable) { + scope.launch { account.showWord(word) } + } + } + + // Muted threads + if (hidden.mutedThreads.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("Muted threads (${hidden.mutedThreads.size})", style = MaterialTheme.typography.labelLarge) + hidden.mutedThreads.sorted().forEach { id -> + EntryRow(label = "${id.take(12)}…", actionLabel = "Unmute", enabled = writeable) { + scope.launch { account.showThread(id) } + } + } + } + } +} + +@Composable +private fun EntryRow( + label: String, + actionLabel: String, + enabled: Boolean, + onAction: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + if (enabled) { + TextButton(onClick = onAction) { Text(actionLabel) } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index 3027d35da4..90fd06b871 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -275,31 +275,6 @@ fun WalletColumnScreen( if (showReceiveDialog && nwcConnection != null) { ReceiveDialog( onDismiss = { showReceiveDialog = false }, - onGenerate = { amountSats, description -> - scope.launch { - val result = - paymentHandler.makeInvoice( - nwcConnection = nwcConnection, - amountMsats = amountSats * 1000, - description = description.ifBlank { null }, - ) - when (result) { - is NwcPaymentHandler.InvoiceResult.Success -> { - result.invoice - } - - is NwcPaymentHandler.InvoiceResult.Error -> { - snackbarHostState.showSnackbar("Error: ${result.message}") - null - } - - is NwcPaymentHandler.InvoiceResult.Timeout -> { - snackbarHostState.showSnackbar("Invoice request timed out") - null - } - } - } - }, paymentHandler = paymentHandler, nwcConnection = nwcConnection, snackbarHostState = snackbarHostState, @@ -833,7 +808,6 @@ private fun SendDialog( @Composable private fun ReceiveDialog( onDismiss: () -> Unit, - onGenerate: (Long, String) -> Unit, paymentHandler: NwcPaymentHandler, nwcConnection: Nip47URINorm, snackbarHostState: SnackbarHostState, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt index b094bcc79c..bb9f39e71c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt @@ -21,12 +21,15 @@ package com.vitorpamplona.amethyst.desktop.viewmodels import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch /** @@ -40,11 +43,24 @@ import kotlinx.coroutines.launch class DesktopFeedViewModel( filter: FeedFilter, cacheProvider: ICacheProvider, + hiddenUsers: StateFlow? = null, ) : FeedViewModel(filter, cacheProvider) { init { viewModelScope.launch(Dispatchers.IO) { feedState.refreshSuspended() } + + // Re-filter live when the account's mute/block/sensitive choices change + // (e.g. the user mutes someone, or a private mute list finishes + // decrypting) so hidden notes disappear without a restart. `drop(1)` + // skips the initial replay — the refresh above already covers first load. + if (hiddenUsers != null) { + viewModelScope.launch(Dispatchers.IO) { + hiddenUsers.drop(1).collect { + feedState.invalidateData(false) + } + } + } } /** diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMuteEnforcementTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMuteEnforcementTest.kt new file mode 100644 index 0000000000..67411253b1 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMuteEnforcementTest.kt @@ -0,0 +1,116 @@ +/* + * 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.desktop.filters + +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.DualCase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression coverage for the "mute is a silent no-op on desktop" bug: the feed + * filters must drop notes that `Note.isHiddenFor(...)` rejects. Exercises the + * real [DesktopGlobalFeedFilter] / [DesktopFollowingFeedFilter] with a + * hand-built [LiveHiddenUsers], the same value the account assembles at runtime. + */ +class DesktopMuteEnforcementTest { + private val mutedAuthor = "0000000000000000000000000000000000000000000000000000000000000001" + private val cleanAuthor = "0000000000000000000000000000000000000000000000000000000000000002" + private val cache = DesktopLocalCache() + + private fun user(hex: String): User = User(hex) { addr -> Note(addr.toValue()) } + + private fun note( + id: String, + pubkey: String, + content: String = "hi", + ): Note { + val event = TextNoteEvent(id, pubkey, 0L, emptyArray(), content, "") + val n = Note(id) + n.loadEvent(event, user(pubkey), emptyList()) + return n + } + + private fun hiddenUsers(pubkeys: Set) = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = pubkeys.mapTo(HashSet()) { it.hashCode() }, + spammersHashCodes = emptySet(), + hiddenUsers = pubkeys, + ) + + @Test + fun globalFeed_hidesMutedAuthor() { + val filter = DesktopGlobalFeedFilter(cache) { hiddenUsers(setOf(mutedAuthor)) } + val muted = note("aa", mutedAuthor) + val clean = note("bb", cleanAuthor) + val result = filter.applyFilter(setOf(muted, clean)) + assertEquals("Muted author's note must be filtered out", setOf(clean), result) + } + + @Test + fun globalFeed_showsEverythingWhenNothingMuted() { + val filter = DesktopGlobalFeedFilter(cache) { LiveHiddenUsers.EMPTY } + val a = note("aa", mutedAuthor) + val b = note("bb", cleanAuthor) + val result = filter.applyFilter(setOf(a, b)) + assertEquals(setOf(a, b), result) + } + + @Test + fun followingFeed_hidesMutedAuthorEvenIfFollowed() { + // Muting wins over following: a muted author you follow is still hidden. + val filter = + DesktopFollowingFeedFilter(cache, { hiddenUsers(setOf(mutedAuthor)) }) { + setOf(mutedAuthor, cleanAuthor) + } + val muted = note("aa", mutedAuthor) + val clean = note("bb", cleanAuthor) + val result = filter.applyFilter(setOf(muted, clean)) + assertEquals(setOf(clean), result) + } + + @Test + fun globalFeed_hidesNoteContainingHiddenWord() { + val choices = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = listOf(DualCase("spam", "SPAM")), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + hiddenWords = setOf("spam"), + ) + val filter = DesktopGlobalFeedFilter(cache) { choices } + val spammy = note("aa", cleanAuthor, content = "buy my SPAM now") + val ok = note("bb", cleanAuthor, content = "good morning") + val result = filter.applyFilter(setOf(spammy, ok)) + assertTrue("Hidden-word note must be filtered", spammy !in result) + assertTrue("Clean note must remain", ok in result) + } +} diff --git a/docs/changelog/v1.13.00.md b/docs/changelog/v1.13.00.md index d98be693d9..fafdcab6ee 100644 --- a/docs/changelog/v1.13.00.md +++ b/docs/changelog/v1.13.00.md @@ -24,6 +24,10 @@ Highlights: trust relays used by people you follow. - Adds **NIP-29 Groups**: relay-hosted group chat with subgroups, custom roles, invite links, threads, and pinned messages. +- Adds **Buzz workspaces**: a self-hosted space where people and AI agents build + together on a relay you own — with native channels, forums, direct messages, + live typing and presence, and an agent-owner console for personas, costs, and + attestations. - Redesigns the **Messages inbox**: groups community and group channels with last-message previews, unread dots, facepiles, live typing, and per-type load toggles. @@ -143,6 +147,42 @@ Highlights: - Adds a map-based location picker when creating a group, and lets pinned group tabs act as bottom-nav roots. +### Buzz (Agent Workspaces) + +- Adds Buzz: a self-hosted workspace where humans and AI agents build together on + a relay you own, built on the NIP-29 relay-group family with plaintext content + so the relay can run search, audit, and workflows. Reachable from a "Buzz + Workspaces" bottom-nav tab and the drawer Feeds list. +- Renders workspace channels natively and composes messages; sections a community + into Channels, Forums, inline DMs, and an Agent Console; and renders forum posts + as Concord-style threads with the full chat composer. +- Adds an agent-owner console with cost and personas tabs, an agent-fleet cost + aggregator, live observer telemetry, persona create/edit, and NIP-OA attestation + issuance. +- Adds live typing indicators, presence dots, collapsible sections with unread and + stars, a workspace canvas viewer and edit composer, and a bot "Working…" + indicator. +- Invites people to a channel or community and mints invite links; redeems invite + links (intercepted into the in-app browser) and auto-invites mentioned + non-members. +- Wires Buzz direct messages end to end, titles a DM by its participant, and + surfaces Buzz DMs in the Notification feed rendered as messages. +- Discovers workspaces and DMs via the relay's own kinds, imports workspace + channels from a relay in "Find groups", and routes Tor workspaces to clearnet + when needed. + +### Notifications + +- Redesigns push notifications into per-kind, observable, richly-rendered + notifications that resolve inline @npub mentions to display names, render an + inline image link as the big picture, and attribute a reply's parent to its real + author. +- Adds push notifications for Buzz DMs and for bare reactions and reposts to your + notes, with repost-mute parity. +- Rebuilds the in-app Notifications tab with infinite-scroll paging via a + look-ahead buffer, time-based history paging with in-feed load markers, and + auto-retry of stalled or faulty relays with an actionable relay detail. + ### Remote Signer & Security - Lets other apps and websites sign through Amethyst for the first time @@ -219,6 +259,12 @@ Highlights: - Renders NIP-30 custom-emoji reactions as an avatar badge, a lone zap/nutzap as a large activity card, and NIP-84 highlights from web highlighter clients. - Supports NIP-51 mute-list hashtag entries and public-chat reply notifications. +- Modernizes the Privacy, Profile UI, Home Tabs, and Calendar Reminder settings + screens into grouped in-screen options. +- Adds "Share as QR" for a note, with a choice of `note` or `naddr` payload and an + image thumbnail for image-only notes. +- Resolves NIP-05 identifiers (including Namecoin `.bit`) in the @-mention + popover, wiring them to `nostr:` mentions. ## Performance @@ -237,6 +283,11 @@ Highlights: an accent change. - Parallelizes Blossom "Sync all", prefetches a joined group's history, and mines proof-of-work on half the device's cores. +- Orders NIP-50 relay search by bm25 relevance and scales search and tag-watcher + queries with corpus size, picking the SQLite filesystem driver by cost and + materializing indexes at runtime. +- Removes live-path allocations in `LiveEventStore` and `FilterIndex` (and on the + frame/search paths), and snapshots `FilterIndex` with a persistent map. ## Improvements and Bug fixes @@ -256,6 +307,19 @@ Highlights: - Fixes Concord invite handling (revocation, keyless CORD-05, clearer failure copy, no hang on bad links, skip re-join) and channel rows stuck on "No messages yet". +- Persists the Concord community list and loads pinned communities from their own + relays so cold boot no longer refetches, shows channels on Messages as soon as + the control plane folds, warms channel previews so the list fills without opening + each channel, and rank-gates the Ban/Remove affordances (CORD-04). +- Warns about DMs from senders reported by people you follow: flags a reported + counterpart on the chat-list row and inside the room, with a per-user + report-warning flow. +- Sizes the video player box so live streams stop rendering black bars, and + enforces the decoder budget when acquiring players. +- Stops stamping our client tag on someone else's event template so external + signers sign it unchanged. +- Keeps calendar RSVP buttons on a single line, drops dead top padding on Home + when there are no live bubbles, and modernizes the Zap the Devs donation card. - Rejects NIP-29 group state not signed by the relay, opens group posts in the group chat, and fixes subgroup edit safety and pinned-message jump. - Makes tapping a message timestamp open delivery info instead of toggling the @@ -288,6 +352,12 @@ Highlights: and the Wallet column, and a hashtag-spam filter. - Renders rich text through the shared core (removes the desktop-only fork) and ships as a Flatpak bundle. +- Adds moderation & safety: mute/block and NIP-56 report actions in the note menu + with feed, thread, and profile enforcement (previously a silent no-op), NIP-36 + content-warning blur with tap-to-reveal, a sensitive-content toggle, and + management screens — with snackbar feedback and zero-relay send warnings. +- Adds NIP-88 polls (render, vote, create) with a "Polls" search facet, and + unifies the note ⋮ overflow and right-click menus. - Fixes a desktop-cache race that could wipe the follow list, and makes sidebar navigation replace the detail overlay instead of hiding behind it. @@ -304,6 +374,11 @@ Highlights: live interop smoke test. - Adds `amy git` (NIP-34), `amy podcast` and `amy podcast20` (Podcasting 2.0 with V4V splits). +- Rounds out `amy git` to full NIP-34 parity: `browse`/`cat`/`log` over + smart-HTTP, `init` from a local checkout, `label` (NIP-32), `apply` a patch to + the working tree, and `grasp list`/`set` (GRASP server list, kind 10317). +- Adds `amy buzz` for block/buzz workspaces (channels, DMs, invite-link + redemption), verified against a live Buzz relay. - Adds the `amy cashu` wallet (NIP-60/61): wallet lifecycle, balance, mint, receive/send (LN / token / nutzap), and maintenance. - Adds `amy admin` (NIP-86 relay moderation) and `amy serve` (run a relay by @@ -341,6 +416,10 @@ Highlights: restore. - Adds an OutboxDispatcher to fetch profile/contact events via each author's outbox relays, and richer NIP-89 app-handler parsing. +- Adds the Buzz protocol surface (~78 event kinds spanning NIP-OA owner + attestation, NIP-AM agent turn metrics, personas/teams/managed agents, streams, + DMs, forums, presence, and moderation), verified against vectors generated by + Buzz's own Rust reference. ## Geode (standalone relay) @@ -358,6 +437,9 @@ Highlights: catch-up IDs instead of materializing them. - Adds relayBench, a head-to-head benchmark harness (geode vs strfry vs any relay) measuring ingest, query, and NIP-77 sync over a shared corpus. +- Enables a combined tag+kind+pubkey index, a cost-based filesystem-driver pick, + and runtime index materialization, with new tag∩author and driver-selection + relayBench shapes. ## Build & Documentation @@ -366,6 +448,11 @@ Highlights: mirror in the web sandbox. - Adds NIP-29 group-chat test coverage across every assembler, filter shape, and relay integration. +- Targets JVM 17 for Quartz, upgrades AGP, and replaces the abandoned + android-test-report action with `mikepenz/action-junit-report`. +- Adds a coding standard banning raw invisible/bidirectional Unicode in source + (Trojan-Source, CVE-2021-42574) and replaces existing occurrences with `\u` + escapes. ## Contributors diff --git a/geode/Dockerfile b/geode/Dockerfile new file mode 100644 index 0000000000..4486190a11 --- /dev/null +++ b/geode/Dockerfile @@ -0,0 +1,61 @@ +# Multi-stage image for the geode Nostr relay. +# +# Build from the REPO ROOT (the build needs the whole Gradle project), pointing +# at this file: +# +# docker build -f geode/Dockerfile -t geode:local . +# docker run --rm -p 7447:7447 geode:local +# +# That runs the relay on 0.0.0.0:7447 with the built-in in-memory store — events +# vanish on restart. For a persistent, configured deployment, mount a config and +# a data volume: +# +# docker run -d --name geode -p 7447:7447 \ +# -v $PWD/geode.toml:/etc/geode/geode.toml:ro \ +# -v geode-data:/var/lib/geode \ +# geode:local --config /etc/geode/geode.toml +# +# with `file = "/var/lib/geode/events.db"` and `in_memory = false` in geode.toml. +# +# Published to GHCR on every release by .github/workflows/create-release.yml +# (docker-geode job) as ghcr.io/vitorpamplona/geode: and :latest. + +# --- build stage ------------------------------------------------------------ +FROM eclipse-temurin:21-jdk AS build +WORKDIR /src +# Copy the whole repo (geode depends only on :quartz, but the Gradle build needs +# settings.gradle.kts, the version catalog, and the wrapper). .dockerignore keeps +# build outputs and VCS metadata out of the context. +COPY . . +# installDist emits bin/geode + lib/*.jar under geode/build/install/geode. +RUN ./gradlew --no-daemon :geode:installDist + +# --- runtime stage ---------------------------------------------------------- +FROM eclipse-temurin:21-jre +LABEL org.opencontainers.image.source="https://github.com/vitorpamplona/amethyst" +LABEL org.opencontainers.image.description="geode — a standalone Nostr relay" +LABEL org.opencontainers.image.licenses="MIT" + +# Run as a non-root user that owns the data dir. +RUN groupadd --system geode \ + && useradd --system --gid geode --home-dir /var/lib/geode --shell /usr/sbin/nologin geode \ + && mkdir -p /var/lib/geode \ + && chown geode:geode /var/lib/geode + +COPY --from=build /src/geode/build/install/geode /opt/geode +# Ship the example config for reference inside the image. +COPY --from=build /src/geode/config.example.toml /opt/geode/share/geode/config.example.toml +ENV PATH="/opt/geode/bin:${PATH}" + +VOLUME /var/lib/geode +EXPOSE 7447 +USER geode + +# Liveness: the default WebSocket port accepts a TCP connection. Uses bash's +# /dev/tcp (the temurin image is Ubuntu-based) so no curl/nc is needed. Override +# the port with --port and this HEALTHCHECK together if you remap it. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/7447' || exit 1 + +ENTRYPOINT ["/opt/geode/bin/geode"] +CMD ["--host", "0.0.0.0", "--port", "7447"] diff --git a/geode/README.md b/geode/README.md new file mode 100644 index 0000000000..231d1c5794 --- /dev/null +++ b/geode/README.md @@ -0,0 +1,122 @@ +# geode + +A standalone [Nostr](https://github.com/nostr-protocol/nips) relay for the JVM, +built on Quartz's relay-server code (Ktor CIO). It speaks the core relay +protocol plus NIP-11 (info doc), NIP-42 (AUTH), NIP-45 (COUNT), NIP-50 +(full-text search), NIP-77 (Negentropy sync), and NIP-86 (relay management), +stores events in SQLite (or a filesystem backend), and can mirror upstream +relays strfry-router style. + +geode depends only on `:quartz` — no Android, no Compose. `amy serve` (the +Amethyst CLI) embeds the same engine in-process. + +## Install + +Pick the channel that matches how you deploy. For a real relay, the **Docker +image** or the **`.deb`/`.rpm` + systemd** are the two production paths; +Homebrew and the portable tarball are handy for local testing. + +### Docker (recommended for servers) + +Images are published to GHCR on every release: + +```bash +# Ephemeral — in-memory store, events vanish on restart: +docker run --rm -p 7447:7447 ghcr.io/vitorpamplona/geode:latest + +# Persistent + configured: +docker run -d --name geode -p 7447:7447 \ + -v $PWD/geode.toml:/etc/geode/geode.toml:ro \ + -v geode-data:/var/lib/geode \ + ghcr.io/vitorpamplona/geode:latest --config /etc/geode/geode.toml +``` + +with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your +`geode.toml`. Pin a version (`:1.12.6`) instead of `:latest` for reproducible +deploys. To build the image yourself, from the repo root: + +```bash +docker build -f geode/Dockerfile -t geode:local . +``` + +### Debian/Ubuntu (`.deb`) and Fedora/RHEL (`.rpm`) + +Download `geode--linux-x64.deb` (or `.rpm`) from the +[release page](https://github.com/vitorpamplona/amethyst/releases) and install +it — a minimal JRE is bundled, so no system Java is required. It installs to +`/opt/geode/` with the launcher at `/opt/geode/bin/geode`. + +```bash +sudo dpkg -i geode-1.12.6-linux-x64.deb # or: sudo rpm -i geode-1.12.6-linux-x64.rpm +``` + +To run it as a managed service, wire up the shipped systemd unit +(`/opt/geode/share/geode/geode.service`) — the unit file's header comment has +the copy-paste steps (create the `geode` user, drop your config in +`/etc/geode/geode.toml`, `systemctl enable --now geode`). + +### Homebrew + +```bash +brew install vitorpamplona/tap/geode # from the tap, once published +``` + +The formula depends on `openjdk` and installs the no-JRE jar bundle. Reference +formula: [`packaging/homebrew/geode.rb`](packaging/homebrew/geode.rb). + +### Portable tarball (any Linux/macOS, no system Java) + +Download `geode---.tar.gz`, unpack, and run: + +```bash +tar xzf geode-1.12.6-linux-x64.tar.gz +./geode/bin/geode --config geode/share/geode/config.example.toml +``` + +The tarball bundles a jlink'd JRE plus `share/geode/config.example.toml` and +`share/geode/geode.service`. + +### From source + +```bash +./gradlew :geode:run --args="--config /etc/geode.toml" +``` + +## Configure + +geode runs with zero config (binds `0.0.0.0:7447`, in-memory SQLite). For +anything real, copy [`config.example.toml`](config.example.toml) and edit it — +the sections mirror nostr-rs-relay's `config.toml` so existing configs port +almost verbatim. Precedence is **CLI flags > TOML > built-in defaults**. + +```bash +geode --config /etc/geode/geode.toml +geode --help # full flag list +geode --version +``` + +Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools), +`[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search), +`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), and +`[admin]` (NIP-86 management). See the example file for every knob. + +## Verbs + +``` +geode [relay] [flags] serve the relay (default) +geode import [flags] [FILE…] bulk-load NDJSON events into the store +geode export [flags] dump the store as NDJSON to stdout +``` + +`import`/`export` are geode's `strfry import` / `strfry export` — one JSON event +per line, for seeding, migrating, or backing up a relay. Both stream, so a +multi-million-event corpus round-trips in roughly constant memory. + +## Release process + +geode ships on the same tag-driven pipeline as the rest of Amethyst: pushing a +`v*` tag runs `.github/workflows/create-release.yml`, whose `build-geode` job +produces the tarball + `.deb`/`.rpm` + Homebrew jvm bundle, `docker-geode` +builds and pushes the GHCR image, and `bump-homebrew-geode-formula.yml` syncs +the reference formula. Design notes: +[`plans/2026-07-24-geode-release.md`](plans/2026-07-24-geode-release.md). diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index ee636965c7..70254af99c 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -137,3 +137,208 @@ dependencies { testImplementation(libs.secp256k1.kmp.jni.jvm) testImplementation(libs.okhttp) } + +// --------------------------------------------------------------------------- +// Native distribution (jlink + jpackage) +// +// geode is a long-running server daemon, so unlike a desktop app the useful +// artifacts are a bundled-runtime tarball and native Linux packages an operator +// can drop onto a box. This mirrors the amy CLI's pipeline (cli/build.gradle.kts) +// — geode is the same kind of `application`-plugin JVM module — but geode drags +// in no Compose UI (it depends only on :quartz), so there's no render-stack to +// exclude and no "no Compose" CI assertion to guard. +// +// Outputs land under geode/build/: +// - geode-image/geode/ portable, flat directory (bin/ + lib/ + runtime/ + +// share/ with config.example.toml + geode.service). +// tar this on every OS → geode---.tar.gz +// - jpackage/geode_*.deb Debian/Ubuntu package (Linux runners only) +// - jpackage/geode-*.rpm Fedora/RHEL package (Linux runners only) +// +// We build our own flat app-image instead of `jpackage --type app-image` because +// on macOS jpackage buries the binary inside an `.app` bundle — wrong for a CLI- +// launched daemon. The flat tree matches the Linux jpackage layout on every OS. +// +// Release wiring lives in .github/workflows/create-release.yml (build-geode job) +// and geode/Dockerfile (the GHCR image). See the plan at +// geode/plans/2026-07-24-geode-release.md. +// --------------------------------------------------------------------------- + +val appVersion: String = project.version.toString() + +// RPM rejects dashes in version strings — replace with tilde (~), which RPM +// treats as prerelease-lower-than: 1.08.0~rc1 < 1.08.0. +val rpmVersion: String = appVersion.replace("-", "~") + +val mainJarName: String = "geode-$appVersion.jar" +val mainClassName: String = "com.vitorpamplona.geode.MainKt" + +// Minimal JDK 21 module set for geode. Keep this tight — every module adds +// megabytes. geode needs more than the CLI: java.management (Ktor CIO / JVM +// metrics), java.sql (the bundled SQLite driver), java.net.http + +// java.security.jgss (okhttp mirror WebSockets over TLS), jdk.crypto.ec (EC +// used by the TLS handshake), jdk.unsupported (sun.misc.Unsafe, touched by +// coroutines/SQLite/CIO). If a transitive dep needs more, `jlink` succeeds at +// link time but the server fails at runtime with NoClassDefFound — the CI +// dry-run (workflow_dispatch) boots the image to catch that early. +val jlinkModules: String = listOf( + "java.base", + "java.logging", + "java.management", + "java.naming", + "java.net.http", + "java.security.jgss", + "java.sql", + "java.xml", + "jdk.crypto.ec", + "jdk.unsupported", +).joinToString(",") + +fun javaToolBin(name: String): Provider = + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }.map { + val exe = if (org.gradle.internal.os.OperatingSystem.current().isWindows) "$name.exe" else name + it.metadata.installationPath.file("bin/$exe").asFile.absolutePath + } + +val jlinkRuntimeDir = layout.buildDirectory.dir("jlink-runtime") +val geodeImageDir = layout.buildDirectory.dir("geode-image/geode") +val installLibDir = layout.buildDirectory.dir("install/geode/lib") +val jpackageOutDir = layout.buildDirectory.dir("jpackage") + +val jlinkRuntime = + tasks.register("jlinkRuntime") { + group = "distribution" + description = "Build a minimal JRE for geode via jlink." + + outputs.dir(jlinkRuntimeDir) + + val jlinkBin = javaToolBin("jlink") + val outDir = jlinkRuntimeDir + val modules = jlinkModules + + doFirst { + // jlink refuses to write into an existing directory. + outDir.get().asFile.deleteRecursively() + executable = jlinkBin.get() + args( + "--add-modules", modules, + "--no-header-files", + "--no-man-pages", + "--strip-debug", + // JDK 21+: --compress is deprecated; use zip-. + "--compress", "zip-6", + "--output", outDir.get().asFile.absolutePath, + ) + } + } + +// Flat app-image: bin/geode launcher + lib/*.jar + runtime/ (the jlink'd JRE) + +// share/geode/ (config.example.toml + the systemd unit). Cross-platform — the +// release workflow tars this up on every OS. +val geodeImage = + tasks.register("geodeImage") { + group = "distribution" + description = "Assemble a portable geode app-image (bin/ + lib/ + runtime/ + share/)." + + dependsOn(tasks.named("installDist"), jlinkRuntime) + + into(geodeImageDir) + + // jars from installDist + from(installLibDir) { + into("lib") + } + // jlink'd JRE + from(jlinkRuntimeDir) { + into("runtime") + } + // Operator seed files: an example config and the systemd unit. + from(layout.projectDirectory.file("config.example.toml")) { + into("share/geode") + } + from(layout.projectDirectory.file("packaging/systemd/geode.service")) { + into("share/geode") + } + + val mainJar = mainJarName + val mainClass = mainClassName + val unixLauncher = + """ + #!/bin/sh + # geode launcher — uses the bundled jlink'd JRE so no system Java is required. + DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)" + exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" + """.trimIndent() + "\n" + + doLast { + val binDir = geodeImageDir.get().asFile.resolve("bin") + binDir.mkdirs() + val launcher = binDir.resolve("geode") + launcher.writeText(unixLauncher) + launcher.setExecutable(true, false) + } + } + +fun registerJpackage( + taskName: String, + type: String, + extraArgs: List = emptyList(), +) = tasks.register(taskName) { + group = "distribution" + description = "Run jpackage --type $type for geode." + + dependsOn(tasks.named("installDist"), jlinkRuntime) + + inputs.dir(installLibDir) + inputs.dir(jlinkRuntimeDir) + outputs.dir(jpackageOutDir) + + val jpackageBin = javaToolBin("jpackage") + val inDir = installLibDir + val runtimeDir = jlinkRuntimeDir + val outDir = jpackageOutDir + val versionArg = if (type == "rpm") rpmVersion else appVersion + val extra = extraArgs + + doFirst { + outDir.get().asFile.mkdirs() + executable = jpackageBin.get() + args( + "--type", type, + "--name", "geode", + "--app-version", versionArg, + "--vendor", "Amethyst Contributors", + "--description", "geode — a standalone Nostr relay.", + "--input", inDir.get().asFile.absolutePath, + "--runtime-image", runtimeDir.get().asFile.absolutePath, + "--main-jar", mainJarName, + "--main-class", mainClassName, + "--dest", outDir.get().asFile.absolutePath, + ) + args(extra) + } +} + +// .deb for Debian/Ubuntu. Installs under /opt/geode/ with /opt/geode/bin/geode +// as the launcher. No .desktop entry — geode is a headless daemon. The systemd +// unit + example config are shipped for the operator to wire up (see README). +registerJpackage( + "jpackageDeb", + "deb", + extraArgs = listOf( + "--linux-package-name", "geode", + "--linux-deb-maintainer", "vitor@vitorpamplona.com", + ), +) + +// .rpm for Fedora/RHEL/openSUSE. +registerJpackage( + "jpackageRpm", + "rpm", + extraArgs = listOf( + "--linux-package-name", "geode", + "--linux-rpm-license-type", "MIT", + ), +) diff --git a/geode/config.example.toml b/geode/config.example.toml index 8ae62edc10..2ba85e0ad4 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -41,8 +41,21 @@ path = "/" # call_group_size = 64 [database] +# Which IEventStore implementation backs the relay. Default "sqlite". +# - "sqlite": quartz's SQLite store — honours every knob below. Best for +# real traffic. CLI: --store sqlite. +# - "fs": quartz's filesystem store — one JSON file per event under the +# directory named by `file` (a directory, not a db file, in this mode). +# Human-inspectable with cat/jq; the SQLite-only knobs are ignored, and +# it always needs `file`. CLI: --store fs. +# Any other value is the fully-qualified class name of a custom IEventStore +# on the classpath, instantiated reflectively (needs a public constructor +# `(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`, or `()`). +# backend = "sqlite" + # True keeps an in-memory SQLite db (events vanish on restart). Useful -# for tests; set false + `file = "..."` for persistent storage. +# for tests; set false + `file = "..."` for persistent storage. Ignored +# by the "fs" backend, which always writes to the `file` directory. in_memory = false file = "/var/lib/geode/events.db" diff --git a/geode/packaging/homebrew/geode.rb b/geode/packaging/homebrew/geode.rb new file mode 100644 index 0000000000..bbb27ea6bc --- /dev/null +++ b/geode/packaging/homebrew/geode.rb @@ -0,0 +1,51 @@ +# Reference Homebrew formula for `geode`, the standalone Nostr relay. +# +# Submit this to Homebrew/homebrew-core (new-formula PR) or drop it into a +# personal tap (`Formula/geode.rb`) for an instant `brew install /geode`. +# +# The url + sha256 below are kept in sync automatically on every stable release +# by .github/workflows/bump-homebrew-geode-formula.yml (it downloads the +# published `geode--jvm.tar.gz`, recomputes the sha256, and opens a +# PR). To refresh by hand instead: +# curl -fsSL -o geode-jvm.tar.gz \ +# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/geode-X.Y.Z-jvm.tar.gz +# shasum -a 256 geode-jvm.tar.gz +# +# Why a pre-built jar bundle instead of building from source: +# homebrew-core builds inside a network sandbox, so a Gradle build cannot +# resolve its Maven dependencies there. The accepted pattern for JVM tools is +# to download a pre-built, no-JRE jar bundle and depend on the system openjdk. +# We publish exactly that as `geode--jvm.tar.gz` (bin/geode + +# lib/*.jar, no bundled runtime) from .github/workflows/create-release.yml. +# +# Note: geode is a long-running relay daemon. Homebrew is a convenient way to +# INSTALL it on a workstation for local testing; for a production deployment +# prefer the Docker image or the .deb/.rpm + systemd unit (see geode/README.md). +class Geode < Formula + desc "Standalone Nostr relay from the Amethyst project" + homepage "https://github.com/vitorpamplona/amethyst" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/geode-1.12.6-jvm.tar.gz" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + license "MIT" + + # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new + # stable GitHub release appears. + livecheck do + url :stable + strategy :github_latest + end + + depends_on "openjdk" + + def install + # Tarball top level is bin/ and lib/ (the Gradle installDist layout). + libexec.install Dir["*"] + # Wrapper on PATH that pins JAVA_HOME to Homebrew's openjdk so geode runs + # regardless of the user's own Java setup. + (bin/"geode").write_env_script libexec/"bin/geode", JAVA_HOME: Formula["openjdk"].opt_prefix + end + + test do + assert_match "geode", shell_output("#{bin}/geode --version 2>&1") + end +end diff --git a/geode/packaging/macos/geode.entitlements b/geode/packaging/macos/geode.entitlements new file mode 100644 index 0000000000..5898ba94ab --- /dev/null +++ b/geode/packaging/macos/geode.entitlements @@ -0,0 +1,27 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/geode/packaging/systemd/geode.service b/geode/packaging/systemd/geode.service new file mode 100644 index 0000000000..92cf35d321 --- /dev/null +++ b/geode/packaging/systemd/geode.service @@ -0,0 +1,50 @@ +# systemd unit for the geode Nostr relay. +# +# Install (paths assume the .deb/.rpm or the portable tarball unpacked to +# /opt/geode — adjust ExecStart if you put it elsewhere): +# +# sudo useradd --system --home /var/lib/geode --shell /usr/sbin/nologin geode +# sudo mkdir -p /var/lib/geode /etc/geode +# sudo cp /opt/geode/share/geode/config.example.toml /etc/geode/geode.toml # then edit +# sudo cp /opt/geode/share/geode/geode.service /etc/systemd/system/geode.service +# sudo chown -R geode:geode /var/lib/geode +# sudo systemctl daemon-reload +# sudo systemctl enable --now geode +# +# Logs: journalctl -u geode -f + +[Unit] +Description=geode Nostr relay +Documentation=https://github.com/vitorpamplona/amethyst/blob/main/geode/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=geode +Group=geode +# Point --config at your edited copy. Drop the flag to run on built-in +# defaults (0.0.0.0:7447, in-memory store — events vanish on restart). +ExecStart=/opt/geode/bin/geode --config /etc/geode/geode.toml +Restart=on-failure +RestartSec=5 + +# A relay holds one file descriptor per WebSocket; the distro default of 1024 +# caps it well below 1k connections. Raise to match [network] tuning. +LimitNOFILE=65536 + +# systemd creates + owns /var/lib/geode (0750, User:Group above). Point +# [database].file there, e.g. file = "/var/lib/geode/events.db". +StateDirectory=geode +WorkingDirectory=/opt/geode + +# Hardening — geode needs no elevated access. Loosen only if a custom +# IEventStore writes outside its StateDirectory. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/geode + +[Install] +WantedBy=multi-user.target diff --git a/geode/plans/2026-07-24-geode-release.md b/geode/plans/2026-07-24-geode-release.md new file mode 100644 index 0000000000..d27541a360 --- /dev/null +++ b/geode/plans/2026-07-24-geode-release.md @@ -0,0 +1,76 @@ +# geode release & distribution + +**Status:** shipped (2026-07-24) + +## Goal + +Give geode a real release process so people can run it without cloning the repo +and invoking Gradle. Reuse the amy CLI's proven pipeline wherever possible — +geode is the same kind of module (a Gradle `application`-plugin JVM app) — and +add the pieces a long-running **server daemon** needs that a one-shot CLI does +not (a container image, a systemd unit). + +## Why the CLI pipeline maps onto geode + +`cli/build.gradle.kts` already models everything geode needs for the +tarball/deb/rpm channels: `jlinkRuntime` (minimal JRE) → an image task +(portable flat `bin/`+`lib/`+`runtime/`) → `jpackageDeb`/`jpackageRpm`, driven +by the `build-cli` matrix in `create-release.yml` and named through +`scripts/asset-name.sh`. geode is `application`-plugin + JVM 21 too, so the same +task shapes drop in. + +Two simplifications vs. the CLI: + +- **No Compose to exclude.** amy drags the Compose UI render stack in + transitively via `:commons` and has to exclude skiko/compose.ui from the + runtime classpath (plus a CI assertion). geode depends only on `:quartz`, so + there's nothing to strip and no assertion to add. +- **A wider jlink module set.** amy is compute-only; geode is a network server + with a SQLite store, so its module list adds `java.management`, `java.sql`, + `java.net.http`, and `java.security.jgss`. A too-tight list links fine but + fails at runtime, so the `build-geode` job **boots the image** (`--version` + plus a serve + NIP-11 curl) as a smoke test before shipping. + +## What shipped + +| Piece | File | +| --- | --- | +| `--version` / `--help` (testable exit-0 command) | `geode/src/main/kotlin/.../Main.kt` | +| jlink + jpackage + `geodeImage` tasks | `geode/build.gradle.kts` | +| Portable app-image seeds (config + unit under `share/geode/`) | `geode/build.gradle.kts` (geodeImage) | +| Multi-stage Docker image | `geode/Dockerfile`, `.dockerignore` | +| systemd unit | `geode/packaging/systemd/geode.service` | +| macOS hardened-runtime entitlements | `geode/packaging/macos/geode.entitlements` | +| Reference Homebrew formula | `geode/packaging/homebrew/geode.rb` | +| Asset naming (`geode---`) | `scripts/asset-name.sh` | +| Release matrix + GHCR push | `.github/workflows/create-release.yml` (`build-geode`, `docker-geode`) | +| Homebrew formula auto-sync | `.github/workflows/bump-homebrew-geode-formula.yml` | +| geode tests in CI (dedicated `test-geode` job) | `.github/workflows/build.yml` | +| Operator docs | `geode/README.md` | + +## Distribution channels + +1. **Docker → GHCR** (`ghcr.io/vitorpamplona/geode:` + `:latest`) — + the primary channel for relay operators. Built from `geode/Dockerfile`. +2. **`.deb` / `.rpm`** with a bundled JRE, install to `/opt/geode`, plus the + shipped systemd unit for `systemctl enable --now geode`. +3. **Portable tarball** with a jlink'd JRE — no system Java needed. +4. **Homebrew** (no-JRE jar bundle + `depends_on openjdk`) for local testing. + +All four (except the container) are named by `scripts/asset-name.sh` and +uploaded to the GitHub Release on `v*` tags. + +## Follow-ups / not done + +- **Multi-arch Docker.** `docker-geode` builds `linux/amd64` only. arm64 under + QEMU emulation would run a full Gradle build per arch (slow / timeout-prone); + add a native arm64 runner leg when one is available. +- **jpackage doesn't auto-install the systemd unit or create the `geode` + user.** The `.deb`/`.rpm` install the binary; service wiring is a documented + manual step (unit ships in the package under `share/geode/`). A post-install + scriptlet via jpackage `--resource-dir` could automate it later. +- **homebrew-core bootstrap.** The first `geode` submission to homebrew-core is + a manual new-formula PR; the auto-bump workflow only keeps the in-repo + reference formula synced until then (same state as `amy`). +- **CI size budget** is set to 200 MB per asset (matches amy). geode should be + well under that — tighten once the first release confirms the real size. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 5c30fc2f60..729b95b3a3 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPoli import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.NdjsonImportExport import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings @@ -94,6 +95,9 @@ import java.io.File * --path

ws path (default from config or /) * --info NIP-11 doc file (overrides [info] section) * --db sqlite db path (overrides [database].file) + * --store event-store backend (overrides [database].backend): + * "sqlite" (default), "fs", or the fully-qualified + * class name of a custom IEventStore on the classpath. * --auth require NIP-42 AUTH (sets options.require_auth = true) * --optional-auth advertise NIP-42 AUTH but don't require it (sets * options.optional_auth = true; ignored when --auth is set) @@ -107,6 +111,18 @@ import java.io.File * per-event tokenization cost on ingest. */ fun main(args: Array) { + // Terminal flags handled before verb dispatch so a packaged binary has a + // fast, exit-0 command that never opens a store or binds a port — the smoke + // test the Homebrew formula, the .deb/.rpm post-install check, and the + // Docker image healthcheck all invoke. + if (args.any { it == "--version" || it == "-V" }) { + println("geode ${BuildConfig.VERSION}") + return + } + if (args.any { it == "--help" || it == "-h" }) { + printHelp() + return + } when (args.firstOrNull()?.takeUnless { it.startsWith("--") }) { "import" -> runImport(args.copyOfRange(1, args.size)) "export" -> runExport(args.copyOfRange(1, args.size)) @@ -117,27 +133,70 @@ fun main(args: Array) { } } +private fun printHelp() { + println( + """ + geode ${BuildConfig.VERSION} - a standalone Nostr relay. + + Usage: + geode [relay] [flags] serve the relay (default when no verb is given) + geode import [flags] [FILE...] bulk-load NDJSON events into the store + geode export [flags] dump the store as NDJSON to stdout + + Common flags: + --config TOML config (see config.example.toml) + --host bind address (default 0.0.0.0) + --port tcp port (default 7447, 0 to autobind) + --path

websocket path (default /) + --db sqlite db path (persistent storage) + --store event-store backend: sqlite (default), fs, or a + fully-qualified IEventStore class name + --auth require NIP-42 AUTH + --optional-auth advertise NIP-42 AUTH but don't require it + --no-verify do NOT verify event signatures (trusted input only) + --no-search disable NIP-50 full-text search + --version, -V print the version and exit + --help, -h print this help and exit + + Docs: https://github.com/vitorpamplona/amethyst/blob/main/geode/README.md + """.trimIndent(), + ) +} + /** * Opens the store the `import`/`export` verbs operate on, honoring the same * `--db`/`--config`/`--no-search` selection the relay uses so a verb targets the * exact store the server would. */ private class StoreContext( + /** Human-readable location of the store's bytes, for the verb's summary line. */ val dbFile: String?, - val store: EventStore, + val store: IEventStore, ) private fun openStore(a: Args): StoreContext { - val config = a.opt(CONFIG_FLAG)?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig() - val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } + val config = + (a.opt(CONFIG_FLAG)?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig()) + .withStoreOverride(a.opt(STORE_FLAG)) val fullTextSearch = !a.flag(NO_SEARCH_FLAG) && config.options.full_text_search + // The verbs operate on the same store the server would, honoring the same + // `[database].backend` selection — unscoped (relay = null) because bulk + // load/dump has no single relay to attribute NIP-62 cascades to. val store = - EventStore( - dbName = dbFile, - indexStrategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index), - numReaders = config.database.readers ?: 4, + StoreFactory.open( + config = config, + relay = null, + fullTextSearch = fullTextSearch, + dbOverride = a.opt("--db"), ) - return StoreContext(dbFile, store) + // Where the bytes land, for the summary line. Only the SQLite in-memory + // case has no location; the FS store always has a directory. + val sqlite = + config.database.backend + .trim() + .lowercase() in StoreFactory.SQLITE_BACKEND_KEYWORDS + val location = a.opt("--db") ?: config.database.file?.takeUnless { sqlite && config.database.in_memory } + return StoreContext(location, store) } private fun runImport(args: Array) { @@ -189,10 +248,12 @@ private fun serve(args: Array) { val a = parseArgs(args) val config: StaticConfig = - a - .opt(CONFIG_FLAG) - ?.let { StaticConfig.fromFile(File(it)) } - ?: StaticConfig() + ( + a + .opt(CONFIG_FLAG) + ?.let { StaticConfig.fromFile(File(it)) } + ?: StaticConfig() + ).withStoreOverride(a.opt(STORE_FLAG)) config.validate() val host = a.opt("--host") ?: config.network.host @@ -200,7 +261,6 @@ private fun serve(args: Array) { val path = a.opt("--path") ?: config.network.path val cliInfoFile = a.opt("--info")?.let { File(it) } - val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } val requireAuth = a.flag("--auth") || config.options.require_auth // Optional AUTH advertises the challenge without gating commands on it. // Mandatory AUTH already sends the challenge, so it wins when both are set. @@ -239,11 +299,11 @@ private fun serve(args: Array) { if (config.database.temp_store_memory) add("PRAGMA temp_store = MEMORY;") } val store = - EventStore( - dbName = dbFile, + StoreFactory.open( + config = config, relay = advertisedUrl, - indexStrategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index), - numReaders = config.database.readers ?: 4, + fullTextSearch = fullTextSearch, + dbOverride = a.opt("--db"), extraPragmas = extraPragmas, ) @@ -371,20 +431,24 @@ private fun serve(args: Array) { // Periodic query-planner statistics refresh (`PRAGMA optimize`). // Incremental and usually a no-op; failures are swallowed — a missed // refresh only means slightly staler planner stats until the next tick. + // SQLite-only: the `optimize()` PRAGMA has no meaning for other backends, + // so the loop only runs when the configured store is the SQLite one. val maintenanceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - config.database.optimize_interval_seconds?.let { secs -> - maintenanceScope.launch { - while (true) { - delay(secs * 1000) - try { - store.optimize() - } catch (e: CancellationException) { - throw e // shutdown cancelled us; don't swallow it - } catch (e: Exception) { - // A missed refresh only means slightly staler planner - // stats until the next tick — log and keep the loop. - // Errors (OOM, etc.) are NOT swallowed. - println("PRAGMA optimize failed: ${e.message}") + (store as? EventStore)?.let { sqlite -> + config.database.optimize_interval_seconds?.let { secs -> + maintenanceScope.launch { + while (true) { + delay(secs * 1000) + try { + sqlite.optimize() + } catch (e: CancellationException) { + throw e // shutdown cancelled us; don't swallow it + } catch (e: Exception) { + // A missed refresh only means slightly staler planner + // stats until the next tick — log and keep the loop. + // Errors (OOM, etc.) are NOT swallowed. + println("PRAGMA optimize failed: ${e.message}") + } } } } @@ -469,6 +533,14 @@ private class Args( private const val CONFIG_FLAG = "--config" private const val NO_VERIFY_FLAG = "--no-verify" private const val NO_SEARCH_FLAG = "--no-search" +private const val STORE_FLAG = "--store" + +/** + * Apply a `--store ` CLI override on top of the parsed + * `[database].backend`. `null` (flag absent) leaves the config untouched, so + * the TOML value — or its `"sqlite"` default — stands. + */ +private fun StaticConfig.withStoreOverride(backend: String?): StaticConfig = if (backend == null) this else copy(database = database.copy(backend = backend)) /** * Boolean flags that never take a value. Listing them explicitly is what lets a diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/StoreFactory.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/StoreFactory.kt new file mode 100644 index 0000000000..95615cb02c --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/StoreFactory.kt @@ -0,0 +1,177 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy +import java.lang.reflect.InvocationTargetException +import kotlin.io.path.Path + +/** + * Builds the relay's [IEventStore] from the parsed [StaticConfig]. + * + * `[database].backend` selects the implementation (see + * [StaticConfig.DatabaseSection.backend]); everything else the store needs + * — the advertised relay URL for NIP-62 scoping, the relay-tuned + * [relayIndexingStrategy], the SQLite deployment pragmas — is threaded + * through here so both the `serve` path and the `import`/`export` verbs open + * the *same* store from the *same* config. + * + * Two backends ship in quartz and are named by keyword; anything else is + * treated as a fully-qualified class name and loaded reflectively, which is + * what makes "pick any `IEventStore` implementation" true rather than a + * fixed menu of two. + */ +object StoreFactory { + /** `[database].backend` values that select the SQLite [EventStore] (the default). */ + val SQLITE_BACKEND_KEYWORDS = setOf("sqlite", "sqlite3", "") + + /** `[database].backend` values that select the filesystem [FsEventStore]. */ + val FS_BACKEND_KEYWORDS = setOf("fs", "file", "files", "filesystem") + + /** + * Open the configured store. + * + * @param config parsed operator config (owns `[database]`, `[negentropy]`). + * @param relay advertised relay URL, or `null` for an unscoped store + * (the `import`/`export` verbs pass `null`). + * @param fullTextSearch whether to build/maintain the NIP-50 index — + * already resolved from `[options].full_text_search` and `--no-search`. + * @param dbOverride the `--db` CLI flag when present; wins over + * `[database].file`. For SQLite it names the db file (`null` → + * in-memory); for `fs` it names the root directory. + * @param extraPragmas SQLite-only deployment pragmas (mmap/temp_store); + * ignored by non-SQLite backends. + */ + fun open( + config: StaticConfig, + relay: NormalizedRelayUrl?, + fullTextSearch: Boolean, + dbOverride: String? = null, + extraPragmas: List = emptyList(), + ): IEventStore { + val strategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index) + val key = + config.database.backend + .trim() + .lowercase() + return when { + key in SQLITE_BACKEND_KEYWORDS -> + EventStore( + // `--db` wins; else the configured file unless the operator + // asked for an in-memory db (events vanish on restart). + dbName = dbOverride ?: config.database.file?.takeUnless { config.database.in_memory }, + relay = relay, + indexStrategy = strategy, + numReaders = config.database.readers ?: 4, + extraPragmas = extraPragmas, + ) + + key in FS_BACKEND_KEYWORDS -> { + // `in_memory` is a SQLite concept — the FS store always needs + // a directory. `--db` overrides the configured path. + val root = + dbOverride ?: config.database.file + ?: throw IllegalArgumentException( + "[database].backend = \"fs\" needs a directory: set [database].file " + + "(or pass --db

). The filesystem store cannot run in-memory.", + ) + FsEventStore( + root = Path(root), + indexingStrategy = strategy, + relay = relay, + ) + } + + else -> loadCustom(config.database.backend.trim(), relay, strategy) + } + } + + /** + * Reflectively instantiate a custom [IEventStore] named by its + * fully-qualified class name. The class must implement `IEventStore` + * and expose one of these public constructors, tried most-specific + * first: + * + * 1. `(NormalizedRelayUrl?, IndexingStrategy)` + * 2. `(NormalizedRelayUrl?)` + * 3. `()` + * + * Enough to hand a custom store the same relay scoping and index + * strategy the built-in backends receive, while still accepting a + * dependency-free no-arg store. + */ + private fun loadCustom( + className: String, + relay: NormalizedRelayUrl?, + strategy: IndexingStrategy, + ): IEventStore { + val clazz = + try { + Class.forName(className) + } catch (e: ClassNotFoundException) { + throw IllegalArgumentException( + "[database].backend = \"$className\" is neither a known backend " + + "(\"sqlite\", \"fs\") nor a class on the classpath. Give the fully-qualified " + + "name of an ${IEventStore::class.simpleName} implementation.", + e, + ) + } + require(IEventStore::class.java.isAssignableFrom(clazz)) { + "[database].backend class $className does not implement ${IEventStore::class.qualifiedName}." + } + + newInstance(clazz, arrayOf(NormalizedRelayUrl::class.java, IndexingStrategy::class.java), arrayOf(relay, strategy)) + ?.let { return it } + newInstance(clazz, arrayOf(NormalizedRelayUrl::class.java), arrayOf(relay)) + ?.let { return it } + newInstance(clazz, emptyArray(), emptyArray()) + ?.let { return it } + + throw IllegalArgumentException( + "$className has no supported public constructor. Provide one of: " + + "(NormalizedRelayUrl?, IndexingStrategy), (NormalizedRelayUrl?), or ().", + ) + } + + /** + * Invoke the `paramTypes` constructor with `args`, or return `null` if + * the class has no such (public) constructor. A throw from inside the + * constructor is unwrapped and rethrown — the store failed to build, + * which is not a signal to fall back to a different constructor shape. + */ + private fun newInstance( + clazz: Class<*>, + paramTypes: Array>, + args: Array, + ): IEventStore? = + try { + clazz.getConstructor(*paramTypes).newInstance(*args) as IEventStore + } catch (e: NoSuchMethodException) { + null + } catch (e: InvocationTargetException) { + throw e.cause ?: e + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 1145a36a1c..dffe4e50e3 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -103,6 +103,31 @@ data class StaticConfig( ) data class DatabaseSection( + /** + * Which [com.vitorpamplona.quartz.nip01Core.store.IEventStore] + * implementation backs the relay. Recognised keywords (case- + * insensitive): + * + * - `"sqlite"` (default): quartz's SQLite-backed + * [com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore] — + * honours every `[database]` knob below ([in_memory]/[file], + * [readers], [mmap_size], …). The right choice for real traffic. + * - `"fs"`: quartz's filesystem + * [com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore], + * one JSON file per event under the directory named by [file] + * (which becomes a directory, not a db file, for this backend). + * Human-inspectable with `cat`/`jq`; the SQLite-only knobs are + * ignored. + * + * Any other value is treated as the fully-qualified class name of a + * custom `IEventStore` on the classpath, instantiated reflectively — + * see [com.vitorpamplona.geode.StoreFactory]. This is the "plug in + * any implementation" escape hatch: the class must implement + * `IEventStore` and expose one of the public constructors + * `(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`, + * or `()`. + */ + val backend: String = "sqlite", /** True keeps an in-memory SQLite db (default — events vanish on restart). */ val in_memory: Boolean = true, val file: String? = null, diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/StoreFactoryTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/StoreFactoryTest.kt new file mode 100644 index 0000000000..cf0939a96d --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/StoreFactoryTest.kt @@ -0,0 +1,122 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * A stand-in "any other IEventStore" — the escape hatch's target. It only + * needs to implement [IEventStore] and expose the + * `(NormalizedRelayUrl?, IndexingStrategy)` constructor [StoreFactory] looks + * for; delegation to an in-memory [EventStore] keeps it a couple of lines. + */ +class FakeCustomStore( + relay: NormalizedRelayUrl?, + strategy: IndexingStrategy, +) : IEventStore by EventStore(dbName = null, relay = relay, indexStrategy = strategy) + +class StoreFactoryTest { + private fun config(toml: String) = StaticConfig.fromToml(toml) + + @Test + fun defaultBackendIsSqlite() { + val store = StoreFactory.open(config(""), relay = null, fullTextSearch = true) + store.use { assertTrue(it is EventStore, "default backend should be the SQLite EventStore") } + } + + @Test + fun sqliteKeywordSelectsEventStore() { + val store = StoreFactory.open(config("[database]\nbackend = \"SQLite\""), relay = null, fullTextSearch = true) + store.use { assertTrue(it is EventStore) } + } + + @Test + fun fsBackendSelectsFsEventStore() { + val dir = Files.createTempDirectory("geode-fs-store-").toFile() + try { + val toml = "[database]\nbackend = \"fs\"\nfile = \"${dir.absolutePath}\"" + val store = StoreFactory.open(config(toml), relay = null, fullTextSearch = true) + store.use { assertTrue(it is FsEventStore, "fs backend should be the filesystem FsEventStore") } + } finally { + dir.deleteRecursively() + } + } + + @Test + fun fsBackendViaDbOverride() { + val dir = Files.createTempDirectory("geode-fs-store-").toFile() + try { + // No `file` in the config — the --db override supplies the root. + val store = + StoreFactory.open( + config("[database]\nbackend = \"fs\""), + relay = null, + fullTextSearch = true, + dbOverride = dir.absolutePath, + ) + store.use { assertTrue(it is FsEventStore) } + } finally { + dir.deleteRecursively() + } + } + + @Test + fun fsBackendWithoutPathFailsLoud() { + assertFailsWith { + StoreFactory.open(config("[database]\nbackend = \"fs\""), relay = null, fullTextSearch = true) + } + } + + @Test + fun customClassNameIsLoadedReflectively() { + val toml = "[database]\nbackend = \"com.vitorpamplona.geode.FakeCustomStore\"" + val store = StoreFactory.open(config(toml), relay = null, fullTextSearch = true) + store.use { assertTrue(it is FakeCustomStore, "a FQCN backend should be instantiated reflectively") } + } + + @Test + fun unknownClassNameFailsLoud() { + assertFailsWith { + StoreFactory.open( + config("[database]\nbackend = \"com.example.NoSuchStore\""), + relay = null, + fullTextSearch = true, + ) + } + } + + @Test + fun classThatIsNotAnEventStoreFailsLoud() { + // java.lang.String resolves but does not implement IEventStore. + assertFailsWith { + StoreFactory.open(config("[database]\nbackend = \"java.lang.String\""), relay = null, fullTextSearch = true) + } + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index d7698300d7..2ba0823441 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -77,6 +77,19 @@ class StaticConfigTest { assertEquals(null, d.database.optimize_interval_seconds) } + @Test + fun backendDefaultsToSqliteAndParses() { + // Unset → SQLite, so existing configs keep the current backend. + assertEquals("sqlite", StaticConfig.fromToml("").database.backend) + // Explicit values round-trip verbatim (case/keyword resolution is + // StoreFactory's job, not the parser's). + assertEquals("fs", StaticConfig.fromToml("[database]\nbackend = \"fs\"").database.backend) + assertEquals( + "com.example.MyStore", + StaticConfig.fromToml("[database]\nbackend = \"com.example.MyStore\"").database.backend, + ) + } + @Test fun mirrorSectionDefaultsToEmpty() { assertTrue(StaticConfig.fromToml("").mirror.isEmpty()) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 8d5cb59f9d..00a86c8969 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -299,6 +299,7 @@ class NappletBrowserActivity : ComponentActivity() { wv.settings.apply { javaScriptEnabled = true domStorageEnabled = true + @Suppress("DEPRECATION") databaseEnabled = false allowFileAccess = false allowContentAccess = false diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 27456bcc10..fea6262e94 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -245,6 +245,7 @@ class NappletBrowserService : Service() { } /** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */ + @Suppress("DEPRECATION") // androidx.privacysandbox.ui toCoreLibInfo is deprecated with no successor. private fun replyWithAdapter(tab: BrowserTab) { val adapter = NappletBrowserUiAdapter(this, tab.sessionId) val coreLibInfo = adapter.toCoreLibInfo(this) @@ -306,6 +307,7 @@ class NappletBrowserService : Service() { wv.settings.apply { javaScriptEnabled = true domStorageEnabled = true + @Suppress("DEPRECATION") databaseEnabled = false allowFileAccess = false allowContentAccess = false diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt index bbb2ed68fa..2ffb2b097e 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt @@ -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.napplethost import android.content.Context diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index ef27bed471..d061653f72 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -504,6 +504,7 @@ class NappletHostActivity : ComponentActivity() { // per-applet origin (a napplet.local subdomain) — storage is scoped to that origin, isolated // from other applets and from the shell. SPAs need it at boot; without it they crash-loop. domStorageEnabled = true + @Suppress("DEPRECATION") databaseEnabled = false allowFileAccess = false allowContentAccess = false diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 51c8064a3b..ceaf53b6e2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -270,6 +270,7 @@ class NappletHostService : Service() { } /** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */ + @Suppress("DEPRECATION") // androidx.privacysandbox.ui toCoreLibInfo is deprecated with no successor. private fun replyWithAdapter(tab: NappletTab) { val adapter = NappletHostUiAdapter(this, tab.sessionId) val coreLibInfo = adapter.toCoreLibInfo(this) @@ -330,6 +331,7 @@ class NappletHostService : Service() { wv.settings.apply { javaScriptEnabled = true domStorageEnabled = true + @Suppress("DEPRECATION") databaseEnabled = false allowFileAccess = false allowContentAccess = false diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt index 1fe4c18942..356cb58027 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt @@ -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.napplethost import android.content.Context diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/transport/AndroidBleTransport.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/transport/AndroidBleTransport.kt index 40962e564f..5aa3e11e61 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/transport/AndroidBleTransport.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/transport/AndroidBleTransport.kt @@ -205,6 +205,10 @@ class AndroidBleTransport( readCharacteristic = null } + // The 5-arg connectGatt(context, autoConnect, callback, transport, phy) overload is deprecated + // in favor of the variant that also takes a Handler; keep this one to avoid changing BLE + // callback threading. + @Suppress("DEPRECATION") override fun connectToPeer(peer: BlePeer) { val device = peer.platformHandle as? BluetoothDevice ?: run { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 871666677b..e77709ecdf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -98,10 +99,16 @@ class ConcordEntryResidue( * - [tombstones] — the document's tombstones, verbatim. We derive liveness from them but * never author one, so dropping them on write would both lose another client's unknown * keys and resurrect communities that client deliberately removed. + * - [unparsedEntries] — entries we could not decode into the typed model, kept verbatim. A + * single entry carrying a wire field this version still requires but a peer omitted would + * otherwise throw and empty the *entire* list; instead we skip just that entry on read and + * re-emit it untouched on write, so a read-modify-write never deletes a membership this + * version merely failed to understand — the same guarantee [tombstones]/[extras] give. */ class ConcordListResidue( val extras: JsonObject = NoExtras, val tombstones: List = emptyList(), + val unparsedEntries: List = emptyList(), ) { companion object { val EMPTY = ConcordListResidue() @@ -229,7 +236,11 @@ object ConcordCommunityList { @Serializable private class WireChannel( val id: String, - val key: String, + // A public channel carries no delivered key, so a writer lists it with only {id, epoch, + // name}. `key` MUST default rather than be required: a single keyless channel would + // otherwise throw MissingFieldException and make the whole document decode to empty, + // silently dropping *every* joined community from the list. + val key: String = "", val epoch: Long, val name: String = "", @SerialName(EXTRAS) val extras: JsonObject = NoExtras, @@ -373,7 +384,15 @@ object ConcordCommunityList { tombstones = residue.tombstones, extras = residue.extras, ) - return ConcordJson.instance.encodeToString(CommunityListDocSerializer, doc) + if (residue.unparsedEntries.isEmpty()) { + return ConcordJson.instance.encodeToString(CommunityListDocSerializer, doc) + } + // Re-attach the entries we couldn't parse, verbatim, so a read-modify-write never deletes + // a membership this version failed to understand. They ride alongside the typed entries. + val encoded = ConcordJson.instance.encodeToJsonElement(CommunityListDocSerializer, doc).jsonObject + val allEntries = ((encoded["entries"] as? JsonArray)?.toList() ?: emptyList()) + residue.unparsedEntries + val merged = JsonObject(encoded + ("entries" to JsonArray(allEntries))) + return ConcordJson.instance.encodeToString(JsonObject.serializer(), merged) } /** @@ -385,38 +404,70 @@ object ConcordCommunityList { /** * Parses the decrypted plaintext JSON document into its live entries plus the - * document-level residue (unknown keys and tombstones) that [encode] must hand back. - * Returns an empty document on failure. + * document-level residue (unknown keys, tombstones, and any entries we couldn't decode) + * that [encode] must hand back. + * + * Entries are decoded **one at a time**: an entry this version can't parse (a wire field we + * still require that a peer omitted) is skipped and kept verbatim in + * [ConcordListResidue.unparsedEntries], never allowed to fail the whole document and empty + * the user's entire community list. Returns an empty document only when the outer structure + * itself is unreadable. */ fun decodeDocument(json: String): ConcordCommunityListDocument = try { - val doc = ConcordJson.instance.decodeFromString(CommunityListDocSerializer, json) + val root = ConcordJson.instance.parseToJsonElement(json).jsonObject + val tombstones = (root["tombstones"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: emptyList() + val latestRemoval = HashMap() - for (t in doc.tombstones) { + for (t in tombstones) { val id = (t["community_id"] as? JsonPrimitive)?.contentOrNull ?: continue val removedAt = (t["removed_at"] as? JsonPrimitive)?.longOrNull ?: 0L val prev = latestRemoval[id] if (prev == null || removedAt > prev) latestRemoval[id] = removedAt } - val entries = - doc.entries.mapNotNull { e -> - val removedAt = latestRemoval[e.communityId] - if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null - val current = e.current - val seed = e.seed?.let { ConcordJson.instance.decodeFromJsonElement(JoinMaterialWireSerializer, it) } - // Hydrating from `seed` mints a fresh `current`; its unknown keys stay safe in - // the verbatim seed, so they are not copied into the new snapshot. - val residue = - ConcordEntryResidue( - entryExtras = e.extras, - seed = e.seed, - currentExtras = if (current != null) current.extras else NoExtras, - ) - (current ?: seed)?.toEntry(e.addedAt, e.inviteRef, e.excludedAtEpoch, residue) - } + + val entries = ArrayList() + val unparsed = ArrayList() + for (element in (root["entries"] as? JsonArray ?: emptyList())) { + val wireObj = element as? JsonObject ?: continue + val e = + try { + ConcordJson.instance.decodeFromJsonElement(CommunityListEntryWireSerializer, wireObj) + } catch (_: Exception) { + // Skip just this entry, but keep it verbatim so a later write re-emits it + // instead of deleting a membership this version failed to understand. + unparsed.add(wireObj) + continue + } + val removedAt = latestRemoval[e.communityId] + if (removedAt != null && e.addedAt <= removedAt) continue + val current = e.current + // Hydrating from `seed` mints a fresh `current`; its unknown keys stay safe in the + // verbatim seed, so they are not copied into the new snapshot. `seed` decode is + // guarded too: it only hydrates when `current` is absent, and survives in residue. + val seed = + e.seed?.let { + try { + ConcordJson.instance.decodeFromJsonElement(JoinMaterialWireSerializer, it) + } catch (_: Exception) { + null + } + } + val residue = + ConcordEntryResidue( + entryExtras = e.extras, + seed = e.seed, + currentExtras = if (current != null) current.extras else NoExtras, + ) + (current ?: seed)?.toEntry(e.addedAt, e.inviteRef, e.excludedAtEpoch, residue)?.let { entries.add(it) } + } + + // Doc-level unknown keys: everything at the root that is not a field we model. + val docExtras = JsonObject(root.filterKeys { it != "entries" && it != "tombstones" }) + ConcordCommunityListDocument( entries = entries, - residue = ConcordListResidue(extras = doc.extras, tombstones = doc.tombstones), + residue = ConcordListResidue(extras = docExtras, tombstones = tombstones, unparsedEntries = unparsed), ) } catch (_: Exception) { ConcordCommunityListDocument(emptyList()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 1752ab9172..42ae4f03ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -102,6 +102,42 @@ object ChannelChat { extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags, ) + /** + * Builds an unsigned kind-3302 [ConcordChatEditEvent] rumor that edits an existing + * channel message [targetId] with [newText], bound to [channelId]/[epoch]. + * + * This is the dedicated Concord edit kind (CORD-02 Appendix B) the reference client + * (Soapbox Armada's `KIND_EDIT`) emits — a separate rumor naming the target with a + * single `["e", …]` tag, carrying the replacement text, wrapped and published on the + * same channel plane as any other Chat Plane rumor. Armada omits a `k` tag on edits + * (only deletes carry one), so we do too, for wire parity. On the receiving side it + * decrypts to a kind-3302 that the fold overlays onto the target — latest edit wins, + * and only edits authored by the *original* message's author are applied, so a member + * can't rewrite someone else's message. It's non-destructive: the original keeps its + * id, so reactions/replies/quotes stay attached. + */ + fun edit( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + targetId: HexKey, + newText: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = ConcordChatEditEvent.KIND, + tags = + arrayOf( + ChannelTag.assemble(channelId), + EpochTag.assemble(epoch), + arrayOf("e", targetId), + ) + extraTags, + content = newText, + ) + /** * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to * [parent], bound to [channelId]/[epoch]. @@ -133,6 +169,37 @@ object ChannelChat { }, ) + /** + * Builds an unsigned kind-1111 **thread reply carrying encrypted image** attachments ([imetas]) to + * [parent], bound to [channelId]/[epoch]. Combines [reply]'s NIP-22 thread pointers with + * [imageMessage]'s attachment handling: each ciphertext URL not already in [text] is appended to the + * content (so the shared feed renders it) and each rides as a NIP-92 `imeta` tag + * ([encryptedImageImeta]). This is the minichat counterpart of [imageMessage] — an image sent as a + * thread reply instead of a top-level channel message. + */ + fun imageReply( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + imetas: List, + parent: Event, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } + val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") + return reply( + authorPubKey = authorPubKey, + channelId = channelId, + epoch = epoch, + text = finalText, + parent = parent, + createdAt = createdAt, + extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, + ) + } + /** * Builds an unsigned kind-7 [ReactionEvent] rumor bound to [channelId]/[epoch] * against the target message ([targetId]/[targetAuthor]/[targetKind]). [content] diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt new file mode 100644 index 0000000000..40cd7bbd8c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt @@ -0,0 +1,71 @@ +/* + * 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.quartz.concord.cord03Channels + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent + +/** + * A Concord Chat Plane **message edit** (CORD-02 Appendix B, `kind:3302`). + * + * A dedicated edit rumor — NOT a kind-1010 modification and NOT a delete + + * republish — matching the Concord v2 reference client (Soapbox Armada's + * `KIND_EDIT`, `src/concord-v2/lib/kinds.ts`). It names the target message with a + * single `["e", ]` tag and carries the replacement text as its content; the + * usual channel/epoch binding tags scope it to its plane. Receivers overlay the + * newest edit **authored by the original message's author** onto that message + * (latest wins), non-destructively — the original keeps its id, so reactions, + * replies, and quotes stay attached (see `LocalCache.findLatestConcordEditForNote`). + */ +@Immutable +class ConcordChatEditEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The id of the message this edit replaces (its `e` tag), or null if malformed. */ + fun editedMessageId(): HexKey? = firstTaggedEvent()?.eventId + + /** + * The full-precision send time in epoch-milliseconds: `createdAt * 1000` plus the `["ms", <0..999>]` + * remainder tag (CORD-02 §4). Used to order competing edits at sub-second precision, matching the + * reference client (an absent/malformed `ms` tag reads as 0). "Latest edit wins" compares this. + */ + fun orderingMs(): Long { + val remainder = + tags + .firstOrNull { it.size > 1 && it[0] == "ms" } + ?.get(1) + ?.toIntOrNull() + ?.takeIf { it in 0..999 } + ?: 0 + return createdAt * 1000 + remainder + } + + companion object { + const val KIND = 3302 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index 04fa0176c2..bff6b54070 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never * touch the owner can never bootstrap themselves. */ +@ConsistentCopyVisibility data class AuthorityResolver private constructor( private val ownerLower: String, private val roles: Map, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt index ef85bd6852..27495bc878 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -86,7 +86,7 @@ object ConcordInviteLink { if (useStock) { out.add(FLAG_STOCK_RELAYS.toByte()) } else { - require(relays!!.size <= MAX_RELAYS) { "at most $MAX_RELAYS relays, was ${relays.size}" } + require(relays.size <= MAX_RELAYS) { "at most $MAX_RELAYS relays, was ${relays.size}" } out.add(0) out.add(relays.size.toByte()) for (r in relays) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt index bee08fc159..4cd54f7594 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt @@ -210,11 +210,11 @@ class FilterIndex { is AuthorKey -> authors = authors.removeSub(key.author, subscriber) is KindKey -> kinds = kinds.removeSub(key.kind, subscriber) is TagKey -> tags = tags.removeTagSub(key.letter, key.value, subscriber) - Unindexed -> unindexed = unindexed.remove(subscriber) + Unindexed -> unindexed = unindexed.removing(subscriber) } } val next = - State(ids, authors, tags, kinds, unindexed, current.assignments.remove(subscriber)) + State(ids, authors, tags, kinds, unindexed, current.assignments.removing(subscriber)) if (state.compareAndSet(current, next)) return } } @@ -275,12 +275,12 @@ class FilterIndex { is AuthorKey -> authors = authors.addSub(key.author, subscriber) is KindKey -> kinds = kinds.addSub(key.kind, subscriber) is TagKey -> tags = tags.addTagSub(key.letter, key.value, subscriber) - Unindexed -> unindexed = unindexed.add(subscriber) + Unindexed -> unindexed = unindexed.adding(subscriber) } } val existing = current.assignments[subscriber] - val merged = existing?.addAll(keySet) ?: keySet - val next = State(ids, authors, tags, kinds, unindexed, current.assignments.put(subscriber, merged)) + val merged = existing?.addingAll(keySet) ?: keySet + val next = State(ids, authors, tags, kinds, unindexed, current.assignments.putting(subscriber, merged)) if (state.compareAndSet(current, next)) return } } @@ -292,8 +292,8 @@ class FilterIndex { sub: S, ): PersistentMap> { val cur = this[key] ?: persistentHashSetOf() - val next = cur.add(sub) - return if (next === cur) this else put(key, next) + val next = cur.adding(sub) + return if (next === cur) this else putting(key, next) } private fun PersistentMap>.removeSub( @@ -301,11 +301,11 @@ class FilterIndex { sub: S, ): PersistentMap> { val cur = this[key] ?: return this - val next = cur.remove(sub) + val next = cur.removing(sub) return when { next === cur -> this - next.isEmpty() -> remove(key) - else -> put(key, next) + next.isEmpty() -> removing(key) + else -> putting(key, next) } } @@ -316,7 +316,7 @@ class FilterIndex { ): PersistentMap>> { val inner = this[letter] ?: persistentHashMapOf() val newInner = inner.addSub(value, sub) - return if (newInner === inner) this else put(letter, newInner) + return if (newInner === inner) this else putting(letter, newInner) } private fun PersistentMap>>.removeTagSub( @@ -328,8 +328,8 @@ class FilterIndex { val newInner = inner.removeSub(value, sub) return when { newInner === inner -> this - newInner.isEmpty() -> remove(letter) - else -> put(letter, newInner) + newInner.isEmpty() -> removing(letter) + else -> putting(letter, newInner) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt deleted file mode 100644 index 05d99b69e3..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip03Timestamp - -import androidx.collection.LruCache -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.utils.TimeUtils - -class VerificationStateCache( - val otsResolverBuilder: OtsResolverBuilder, -) { - private val cache = LruCache(200) - - suspend fun verify(event: OtsEvent): VerificationState { - cache.put(event.id, VerificationState.Verifying) - return event.verifyState(otsResolverBuilder.build()).also { cache.put(event.id, it) } - } - - fun justCache(event: OtsEvent): VerificationState? = cache[event.id] - - suspend fun cacheVerify(event: OtsEvent): VerificationState = - when (val verif = cache[event.id]) { - is VerificationState.Verifying -> { - verif - } - - is VerificationState.Verified -> { - verif - } - - is VerificationState.NetworkError -> { - // try again in 5 mins - if (verif.time < TimeUtils.fiveMinutesAgo()) { - verify(event) - } else { - verif - } - } - - is VerificationState.Error -> { - verif - } - - else -> { - verify(event) - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index bec4ccc5fb..459d981981 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -98,6 +98,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent 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.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent @@ -426,6 +427,7 @@ class EventFactory { ): T = when (kind) { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) + ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig) AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) AgentTurnMetricEvent.KIND -> AgentTurnMetricEvent(id, pubKey, createdAt, tags, content, sig) EngramEvent.KIND -> EngramEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt index ff379f5d19..b6ccaa8f81 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -133,6 +133,108 @@ class ConcordCommunityListTest { assertEquals(1L, e.heldRoots[0].epoch) } + @Test + fun aKeylessChannelDoesNotNukeTheWholeList() { + // A public channel carries no delivered key, so a writer lists it under `channels` + // with only {id, epoch, name} and no `key`. The whole document must still decode — + // a single keyless channel entry must not throw MissingFieldException and take every + // joined community down with it (the "Concord communities won't load" regression). + val json = + """ + { + "entries": [ + { + "community_id": "${"11".repeat(32)}", + "current": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 0, + "channels": [ + { "id": "${"ee".repeat(32)}", "epoch": 0, "name": "general" } + ], + "relays": ["wss://relay.example"], + "name": "Has A Public Channel" + }, + "added_at": 1700000000000 + } + ], + "tombstones": [] + } + """.trimIndent() + + val entries = ConcordCommunityList.decode(json) + assertEquals(1, entries.size, "a keyless (public) channel must not drop the entry") + assertEquals("Has A Public Channel", entries[0].name) + assertEquals(1, entries[0].privateChannels.size) + assertEquals("", entries[0].privateChannels[0].key) // absent key ⇒ empty, not a throw + } + + // A good entry, and one whose `current` is missing the required `owner` field so it cannot be + // decoded into the typed model at all. + private fun docWithOneUnparseableEntry() = + """ + { + "entries": [ + { + "community_id": "${"11".repeat(32)}", + "current": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 0, + "relays": ["wss://relay.example"], + "name": "Healthy" + }, + "added_at": 1700000000000 + }, + { + "community_id": "${"22".repeat(32)}", + "current": { + "community_id": "${"22".repeat(32)}", + "owner_salt": "${"bb".repeat(32)}", + "community_root": "${"dd".repeat(32)}", + "root_epoch": 0, + "name": "Missing Owner" + }, + "added_at": 1700000000001 + } + ], + "tombstones": [] + } + """.trimIndent() + + @Test + fun oneUnparseableEntryDoesNotDropItsGoodNeighbors() { + val doc = ConcordCommunityList.decodeDocument(docWithOneUnparseableEntry()) + // The healthy entry survives; the broken one is set aside, not fatal to the whole list. + assertEquals(1, doc.entries.size) + assertEquals("Healthy", doc.entries[0].name) + assertEquals("11".repeat(32), doc.entries[0].id) + assertEquals(1, doc.residue.unparsedEntries.size) + assertEquals( + "22".repeat(32), + doc.residue.unparsedEntries[0]["community_id"]!! + .jsonPrimitive.content, + ) + } + + @Test + fun anUnparseableEntrySurvivesAWriteRoundTrip() { + // A write (follow/unfollow) must re-emit the entry we couldn't parse, verbatim — dropping + // it would delete another client's membership just because this version can't model it. + val doc = ConcordCommunityList.decodeDocument(docWithOneUnparseableEntry()) + val out = ConcordCommunityList.encode(doc.entries, doc.residue).asJson() + val ids = + out["entries"]!!.jsonArray.map { it.jsonObject["community_id"]!!.jsonPrimitive.content }.toSet() + assertTrue(ids.contains("11".repeat(32)), "the healthy entry is written back") + assertTrue(ids.contains("22".repeat(32)), "the unparseable entry is preserved verbatim") + // And a second decode still surfaces exactly the one healthy entry (idempotent). + assertEquals(1, ConcordCommunityList.decode(out.toString()).size) + } + @Test fun tombstoneAfterAddDropsEntry() { val jm = """{"community_id":"${"11".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"aa".repeat(32)}","community_root":"${"bb".repeat(32)}","root_epoch":0,"channels":[],"relays":[],"name":"Gone"}""" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index bf814a59a7..5e9e99de77 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -100,6 +100,35 @@ class ChannelChatEndToEndTest { assertTrue(ChannelChat.isBoundTo(thread, channelIdHex, 0L)) } + @Test + fun editIsAChannelBoundKind3302ThatPointsAtTheTargetAndRoundTrips() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + + val original = ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "helo", createdAt = 1L) + val edit = ChannelChat.edit(alice.pubKey, channelIdHex, rootEpoch, original.id, "hello", createdAt = 2L) + + // The dedicated Concord edit kind (Armada KIND_EDIT), e-tagging the original, + // still bound to the channel/epoch. Armada omits a `k` tag on edits, so we do too. + assertEquals(ConcordChatEditEvent.KIND, edit.kind) + assertEquals(3302, edit.kind) + assertEquals(original.id, edit.tags.first { it[0] == "e" }[1]) + assertTrue(edit.tags.none { it[0] == "k" }) + assertEquals("hello", edit.content) + assertTrue(ChannelChat.isBoundTo(edit, channelIdHex, rootEpoch)) + assertFalse(ChannelChat.isBoundTo(edit, channelIdHex, 1L)) // wrong epoch can't be replayed + + // Wraps + opens on the shared plane like any other Chat Plane rumor; the opened + // rumor is typed as a ConcordChatEditEvent that names the edited message. + val wrap = ConcordStreamEnvelope.wrap(edit, channel, alice, encrypted = true) + val opened = ConcordStreamEnvelope.open(wrap, channel) + assertEquals(3302, opened.rumor.kind) + assertEquals("hello", opened.rumor.content) + assertEquals(alice.pubKey, opened.author) + assertEquals(original.id, (opened.rumor as ConcordChatEditEvent).editedMessageId()) + } + @Test fun typingHeartbeatIsAnEphemeralWrapReadableByAnotherMember() = runTest { @@ -173,6 +202,40 @@ class ChannelChatEndToEndTest { assertTrue(ChannelChat.encryptedImagesOf(ChannelChat.message(author, channelIdHex, 0L, "hi", 1L)).isEmpty()) } + @Test + fun encryptedImageReplyIsAKind1111CommentCarryingTheEncryptedAttachment() { + val author = KeyPair().pubKey.toHexKey() + val parent = ChannelChat.message(author, channelIdHex, 0L, "root", createdAt = 1L) + val cipher = AESGCM(ByteArray(32) { 0x33 }, ByteArray(16) { 0x44 }) + val url = "https://blossom.example/reply-ciphertext.bin" + + val imeta = + ChannelChat.encryptedImageImeta( + url = url, + mimeType = "image/png", + dim = "640x480", + blurhash = null, + cipher = cipher, + originalHash = "bb".repeat(32), + ) + val reply = ChannelChat.imageReply(author, channelIdHex, 0L, "here", listOf(imeta), parent, createdAt = 6L) + + // A thread reply keeps the kind-1111 NIP-22 shape (E root + e parent), stays channel-bound, + // and appends the ciphertext url to content — exactly like [imageMessage] but on a comment. + assertEquals(1111, reply.kind) + assertEquals(parent.id, reply.tags.first { it[0] == "E" }[1]) + assertEquals(parent.id, reply.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(reply, channelIdHex, 0L)) + assertEquals("here\n$url", reply.content) + + // The encrypted attachment round-trips with the same key/nonce so the receiver can decrypt. + val parsed = ChannelChat.encryptedImagesOf(reply) + assertEquals(1, parsed.size) + assertEquals(url, parsed.first().url) + assertTrue(parsed.first().key.contentEquals(ByteArray(32) { 0x33 })) + assertTrue(parsed.first().nonce.contentEquals(ByteArray(16) { 0x44 })) + } + @Test fun nonMembersCannotDeriveThePlane() = runTest { diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index 908e51d565..d419b10c0d 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Single source of truth for Amethyst release asset naming. # -# Two product lines share this contract: +# Three product lines share this contract: # amethyst-desktop---. (Compose Desktop app) # amy---. (Amy CLI — cli/ module) +# geode---. (geode relay — geode/ module) # # = tag stripped of leading 'v' (e.g. "1.08.0") # = macos | windows | linux @@ -33,13 +34,18 @@ # amy-1.08.0-linux-x64.tar.gz # amy-1.08.0-linux-x64.deb # amy-1.08.0-linux-x64.rpm +# geode-1.08.0-macos-arm64.tar.gz +# geode-1.08.0-linux-x64.tar.gz +# geode-1.08.0-linux-x64.deb +# geode-1.08.0-linux-x64.rpm # -# One CLI asset breaks the family/arch shape on purpose: the no-JRE jar bundle -# for Homebrew-core is pure JVM bytecode (no bundled runtime), so a single +# Two assets break the family/arch shape on purpose: the no-JRE jar bundles for +# Homebrew-core are pure JVM bytecode (no bundled runtime), so a single # platform-independent artifact serves every OS: # amy-1.08.0-jvm.tar.gz -# It is packaged inline in create-release.yml (linux leg), not via the helpers -# below, since it has no / dimension. +# geode-1.08.0-jvm.tar.gz +# They are packaged inline in create-release.yml (linux leg), not via the helpers +# below, since they have no / dimension. set -euo pipefail @@ -57,6 +63,13 @@ cli_asset_name() { printf 'amy-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" } +# geode relay variant of asset_name — same shape, different product prefix. +# Usage: geode_asset_name +geode_asset_name() { + local family="$1" arch="$2" version="$3" ext="$4" + printf 'geode-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" +} + # Copy + rename build outputs into using the canonical naming scheme. # Usage: collect_assets # Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives). @@ -136,3 +149,48 @@ collect_cli_assets() { done shopt -u nullglob } + +# Copy + rename geode relay build outputs into using the canonical +# geode---. scheme. +# +# Expected inputs: +# geode/build/geode-image/geode/ flat app-image built by :geode:geodeImage +# (bin/geode + lib/*.jar + runtime/ + share/) +# geode/build/jpackage/*.deb from :geode:jpackageDeb (Linux only) +# geode/build/jpackage/*.rpm from :geode:jpackageRpm (Linux only) +# +# Usage: collect_geode_assets +collect_geode_assets() { + local family="$1" arch="$2" version="$3" dest="$4" + mkdir -p "$dest" + local abs_dest + abs_dest="$(cd "$dest" && pwd)" + shopt -s nullglob + + # 1. Tar the flat app-image into geode---.tar.gz. + # This is the portable-across-OS asset — macOS runners produce only + # this one. + local app_image="geode/build/geode-image/geode" + if [ -d "$app_image" ]; then + local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)" + ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) + echo "Collected: $tarball" + fi + + # 2. Linux native installers (.deb, .rpm). jpackage writes them directly + # to geode/build/jpackage/ with its own naming, so we re-copy under the + # canonical scheme. + local src base ext dst + for src in \ + geode/build/jpackage/*.deb \ + geode/build/jpackage/*.rpm \ + ; do + [ -f "$src" ] || continue + base="$(basename "$src")" + ext="${base##*.}" + dst="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" "$ext")" + cp "$src" "$dst" + echo "Collected: $dst" + done + shopt -u nullglob +} diff --git a/tools/notification-e2e/README.md b/tools/notification-e2e/README.md new file mode 100644 index 0000000000..f6b687938a --- /dev/null +++ b/tools/notification-e2e/README.md @@ -0,0 +1,99 @@ +# Notification end-to-end test (Layer 3) + +Drives the whole push-notification pipeline on a real device/emulator: a second +identity publishes each notification kind through `amy` to the account logged +into the phone, and the script reads the system notification shade back over +`adb` to assert the right notification landed on the right channel — then tests +the two behaviors unit tests can't reach: **cold-push metadata enrichment** and +**dismiss-on-read**. + +It's written to be run unattended by an agent: every check prints +`PASS` / `WARN` / `FAIL`, and the script exits non-zero if any hard assertion +fails. + +## What it covers + +| Check | How it's triggered | Asserted from the shade | +| --- | --- | --- | +| Mention (+ liveness) | `amy event --kind 1` p-tagging A | `MentionsID` + "mentioned you" | +| Reply | kind 1 with `e`+`p` tags to A's note | `RepliesID` + "replied" | +| Reaction | kind 7 to A's note | `ReactionsID` + "reacted" | +| Buzz bare reaction | kind 7 with `e` tag, **no `p` tag** | `ReactionsID` (routed via `tagsAnEventByUser`) | +| Repost *(new kind)* | kind 6 to A's note | `RepostsID` + "reposted" | +| Picture *(new kind)* | kind 20 with `imeta` + `p` | `MediaID` + "shared a photo" | +| DM | `amy dm send` | `PrivateMessagesID` + the body | +| **Enrichment** | cold-start, then publish sender kind:0 | title flips from raw pubkey → display name in place | +| **Dismiss-on-read** | deep-link the note, then re-check | notification gone | + +Zaps (need a Lightning wallet), git/badge/nutzap/onchain (need repo / badge +definition / cashu proofs) aren't automated here — they follow the same +`amy event --kind …` pattern with the appropriate tags if you want to extend it. + +### Buzz DM (manual) + +Buzz DM notifications (`StreamMessageV2Event` k40002 / `ChatEvent` k9 in a `t=dm` +relay-group channel) are participant-routed off the channel's kind-39000 +metadata, so they can't be triggered in one `amy event` call. To verify by hand: + +1. Create a Buzz DM channel whose kind-39000 metadata has `["t","dm"]` and a + `["p", ]` participant tag (use the Buzz UI, or `amy relaygroup` / + `amy event --kind 39000` as the relay/group admin), on a relay A is connected + to. Let A load the channel (open it once) so its 39000 metadata is cached — + push only fires when the participant list is known. +2. As B, post a message into it: `amy event --kind 9 --content "buzz dm hi" + --tags '[["h",""]]' --relay ` (or kind 40002). +3. Background the app; expect a `PrivateMessagesID` notification titled with the + channel name and body `B: buzz dm hi`, tapping it opens the chatroom. + +The gate is intentionally strict (participant metadata must be loaded), matching +the in-app feed — a cold push with no cached channel won't notify. + +## Prerequisites + +- A device/emulator on `adb` with the **debug** app installed and an account + logged in — this is identity **A**, the receiver. +- `amy` built: `./gradlew :cli:installDist` (binary at + `cli/build/install/amy/bin/amy`). +- `jq` on `PATH`. +- **A relay both sides use.** It must be in A's inbox/read relays **and** A's + always-on notification service must be enabled, so events arrive while the app + is backgrounded. For an emulator with a local relay: + + ```bash + amy serve --port 7777 & # embeds the geode relay + adb reverse tcp:7777 tcp:7777 # phone's 127.0.0.1:7777 → host + # then add ws://127.0.0.1:7777 to the account's relays in the app + ``` + +## Run + +```bash +A_NPUB=npub1… RELAY=wss://relay.example ./tools/notification-e2e/run.sh +``` + +Reply/reaction/repost need a note **authored by A** on the relay; if A has none +the script warns and skips those three — post one from the phone first for full +coverage. + +## Knobs + +| Env | Default | | +| --- | --- | --- | +| `A_NPUB` | *(required)* | receiver npub (the phone's account) | +| `RELAY` | *(required)* | relay both sides use | +| `APP_PKG` | `com.vitorpamplona.amethyst.debug` | app id | +| `ADB_SERIAL` | first device | `adb -s` target | +| `AMY` | `cli/build/install/amy/bin/amy` | amy binary | +| `TIMEOUT` | `40` | seconds to wait for a notification | +| `IMG_URL` | a small public jpg | image for the media test | + +## Notes / limitations + +- Verification greps `dumpsys notification --noredact` for the channel id **and** + a content string; it doesn't bind them to the same record, so run it against a + reasonably quiet device. +- The dismiss-on-read step depends on the note deep-link format; if it can't + confirm the clear it `WARN`s rather than failing, so eyeball it once. +- It exercises the **always-on relay** delivery path (no FCM/UnifiedPush server + needed). To test the real push transport, send through your push server and + keep the rest of the flow identical. diff --git a/tools/notification-e2e/run.sh b/tools/notification-e2e/run.sh new file mode 100755 index 0000000000..527bed554a --- /dev/null +++ b/tools/notification-e2e/run.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2025 Vitor Pamplona — MIT (see repo LICENSE). +# +# Layer-3 end-to-end test for Amethyst push notifications. +# +# Drives the WHOLE pipeline on a real device/emulator: a second identity (run +# through `amy`) publishes each notification kind to the account logged into the +# phone, and the script reads back the system notification shade over `adb` to +# assert the right notification appeared on the right channel — then exercises +# the two behaviors unit tests can't reach: cold-push metadata enrichment and +# dismiss-on-read. +# +# It is written to be run by an agent: every check prints PASS / WARN / FAIL and +# the script exits non-zero if any hard assertion fails. +# +# --------------------------------------------------------------------------- +# PREREQUISITES (the harness checks what it can and aborts with guidance) +# - A device/emulator on `adb` with the DEBUG app installed and an account +# logged in (this is identity A, the receiver). +# - `amy` built: ./gradlew :cli:installDist (binary at +# cli/build/install/amy/bin/amy) +# - `jq` on PATH. +# - A relay BOTH sides use: it must be in A's inbox/read relays AND A's +# always-on notification service must be enabled, so backgrounded events +# arrive. For an emulator + a local relay, `amy serve` + `adb reverse +# tcp:7777 tcp:7777` and point the account at ws://127.0.0.1:7777. +# +# USAGE +# A_NPUB=npub1… RELAY=wss://relay.example ./tools/notification-e2e/run.sh +# +# ENV (override as needed) +# A_NPUB (required) receiver npub — the account logged into the phone +# RELAY (required) ws/wss relay URL both sides use +# APP_PKG app id (default com.vitorpamplona.amethyst.debug) +# ADB_SERIAL adb -s target (default: the only connected device) +# AMY amy binary path (default cli/build/install/amy/bin/amy) +# SENDER_HOME amy data dir for B (default /tmp/amy-e2e-sender) +# FRESH_HOME amy data dir for C (default /tmp/amy-e2e-fresh) +# IMG_URL image for the media test (default a small public jpg) +# TIMEOUT seconds to wait for a notification to appear (default 40) +# --------------------------------------------------------------------------- +set -uo pipefail + +A_NPUB="${A_NPUB:?set A_NPUB to the npub logged into the phone}" +RELAY="${RELAY:?set RELAY to a relay both the phone and amy use}" +APP_PKG="${APP_PKG:-com.vitorpamplona.amethyst.debug}" +AMY="${AMY:-cli/build/install/amy/bin/amy}" +SENDER_HOME="${SENDER_HOME:-/tmp/amy-e2e-sender}" +FRESH_HOME="${FRESH_HOME:-/tmp/amy-e2e-fresh}" +IMG_URL="${IMG_URL:-https://image.nostr.build/nostr.build_1x1.jpg}" +TIMEOUT="${TIMEOUT:-40}" +MAIN_ACTIVITY="com.vitorpamplona.amethyst.ui.MainActivity" + +ADB=(adb) +[ -n "${ADB_SERIAL:-}" ] && ADB=(adb -s "$ADB_SERIAL") + +pass=0 fail=0 warn=0 +ok() { echo " PASS $*"; pass=$((pass + 1)); } +bad() { echo " FAIL $*"; fail=$((fail + 1)); } +meh() { echo " WARN $*"; warn=$((warn + 1)); } +hdr() { echo; echo "== $* =="; } + +amyB() { "$AMY" --data-dir "$SENDER_HOME" "$@"; } +amyC() { "$AMY" --data-dir "$FRESH_HOME" "$@"; } +dump() { "${ADB[@]}" shell dumpsys notification --noredact 2>/dev/null; } + +# Wait until BOTH strings appear somewhere in the shade (proxy for "a +# notification on carrying "). Returns 0 on success. +wait_for() { + local channel="$1" text="$2" deadline=$((SECONDS + TIMEOUT)) + while [ $SECONDS -lt $deadline ]; do + local d; d="$(dump)" + if grep -qF "$channel" <<<"$d" && grep -qF "$text" <<<"$d"; then return 0; fi + sleep 2 + done + return 1 +} + +# Publish a raw signed event as sender B, targeting A. Echoes the event id. +pubB() { # kind content tagsJSON + amyB event --kind "$1" --content "$2" --tags "$3" --relay "$RELAY" --json \ + | jq -r '.event.id' +} + +hex_of() { "$AMY" decode "$1" 2>/dev/null | grep -oE '[0-9a-f]{64}' | head -1; } + +# --------------------------------------------------------------------------- +hdr "Preflight" +command -v jq >/dev/null || { echo "jq not found"; exit 2; } +[ -x "$AMY" ] || { echo "amy not found at $AMY — run ./gradlew :cli:installDist"; exit 2; } +"${ADB[@]}" get-state >/dev/null 2>&1 || { echo "no adb device (set ADB_SERIAL?)"; exit 2; } +"${ADB[@]}" shell pm path "$APP_PKG" >/dev/null 2>&1 || { echo "$APP_PKG not installed"; exit 2; } + +A_HEX="$(hex_of "$A_NPUB")" +[ -n "$A_HEX" ] || { echo "could not decode A_NPUB to hex"; exit 2; } +echo "receiver A = $A_NPUB ($A_HEX)" + +# Ensure POST_NOTIFICATIONS (Android 13+) and that notifications are on. +"${ADB[@]}" shell pm grant "$APP_PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true + +# Two sender identities: B (has a profile) and C (fresh, no profile → drives the +# enrichment test). +if [ ! -f "$SENDER_HOME/current" ] && [ -z "$(ls -A "$SENDER_HOME" 2>/dev/null)" ]; then + amyB create >/dev/null + amyB profile edit --name "E2E Sender" --relay "$RELAY" >/dev/null 2>&1 || \ + amyB event --kind 0 --content '{"name":"E2E Sender"}' --relay "$RELAY" >/dev/null +fi +B_NPUB="$(amyB whoami 2>/dev/null | grep -oE 'npub1[0-9a-z]+' | head -1)" +B_HEX="$(hex_of "$B_NPUB")" +echo "sender B = ${B_NPUB:-?} ($B_HEX)" + +# Background the app so notifications aren't suppressed (MainActivity.isResumed). +"${ADB[@]}" shell input keyevent KEYCODE_HOME +sleep 1 + +# A target note authored by A, for reply/reaction/repost (must exist on RELAY). +TARGET_ID="$(amyB fetch --author "$A_HEX" --kind 1 --limit 1 --relay "$RELAY" --json 2>/dev/null | jq -r '.events[0].id // empty')" +if [ -z "$TARGET_ID" ]; then + meh "A has no kind:1 note on $RELAY — reply/reaction/repost will be skipped." + meh "Post one from the phone (or 'amy' as A) and re-run for full coverage." +fi + +# --------------------------------------------------------------------------- +hdr "Liveness — mention (also confirms the phone actually receives from RELAY)" +pubB 1 "e2e mention nostr:$A_NPUB" "[[\"p\",\"$A_HEX\"]]" >/dev/null +if wait_for "MentionsID" "mentioned you"; then + ok "mention → Mentions channel" +else + bad "no mention notification within ${TIMEOUT}s." + echo " -> Is $RELAY in A's inbox relays and the always-on service enabled?" + echo " Aborting: nothing else can pass until events reach the phone." + exit 1 +fi + +# --------------------------------------------------------------------------- +hdr "Per-kind delivery" + +pubB 1 "e2e reply body" "[[\"p\",\"$A_HEX\"]]" >/dev/null # non-reply mention already covered + +if [ -n "$TARGET_ID" ]; then + pubB 1 "e2e reply to your note" "[[\"e\",\"$TARGET_ID\",\"\",\"root\"],[\"p\",\"$A_HEX\"]]" >/dev/null + wait_for "RepliesID" "replied" && ok "reply → Replies channel" || bad "reply notification missing" + + pubB 7 "🤙" "[[\"e\",\"$TARGET_ID\"],[\"p\",\"$A_HEX\"]]" >/dev/null + wait_for "ReactionsID" "reacted" && ok "reaction → Reactions channel" || bad "reaction notification missing" + + # Buzz-style bare like: a NIP-25 kind-7 with an `e` tag but NO `p` tag (Buzz + # relays emit these). Must still notify — routed via tagsAnEventByUser, not the + # p-tag gate. A distinct emoji separates it from the 🤙 reaction above. + pubB 7 "❤️" "[[\"e\",\"$TARGET_ID\"]]" >/dev/null + wait_for "ReactionsID" "❤️" && ok "Buzz bare reaction (no p-tag) → Reactions channel" || bad "Buzz bare reaction missing" + + pubB 6 "" "[[\"e\",\"$TARGET_ID\"],[\"p\",\"$A_HEX\"]]" >/dev/null + wait_for "RepostsID" "reposted" && ok "repost → Reposts channel (new kind)" || bad "repost notification missing" +else + meh "skipped reply/reaction/repost (no target note)" +fi + +pubB 20 "e2e photo caption" "[[\"p\",\"$A_HEX\"],[\"imeta\",\"url $IMG_URL\",\"m image/jpeg\"]]" >/dev/null +wait_for "MediaID" "shared a photo" && ok "picture → Media channel (BigPicture, new kind)" || bad "media notification missing" + +amyB dm send "$A_NPUB" "e2e direct message" --relay "$RELAY" >/dev/null 2>&1 \ + && { wait_for "PrivateMessagesID" "e2e direct message" && ok "DM → Private Messages (MessagingStyle)" || meh "DM notification missing (check A's kind:10050 DM relays include $RELAY)"; } \ + || meh "amy dm send failed (DM relays / wallet not configured) — skipped" + +# Buzz DM (kind 40002 / kind 9 in a `t=dm` relay-group channel) is participant- +# routed off the channel's 39000 metadata, which this script can't set up in one +# shot. Not auto-triggered — see README "Buzz DM (manual)". Expect it to land on +# PrivateMessagesID with the channel name as title and ": " body. +meh "Buzz DM not auto-triggered (needs a t=dm relay-group channel) — see README" + +# --------------------------------------------------------------------------- +hdr "Observability — cold-push metadata enrichment" +# Fresh sender C with NO profile: the notification must first show a raw pubkey, +# then update in place to C's display name once its kind:0 arrives. +if [ -z "$(ls -A "$FRESH_HOME" 2>/dev/null)" ]; then amyC create >/dev/null; fi +C_NPUB="$(amyC whoami 2>/dev/null | grep -oE 'npub1[0-9a-z]+' | head -1)" +C_HEX="$(hex_of "$C_NPUB")" + +"${ADB[@]}" shell am force-stop "$APP_PKG" # cold process: empty LocalCache +sleep 2 +"$AMY" --data-dir "$FRESH_HOME" event --kind 1 --content "e2e enrichment nostr:$A_NPUB" \ + --tags "[[\"p\",\"$A_HEX\"]]" --relay "$RELAY" --json >/dev/null +if wait_for "MentionsID" "mentioned you"; then + before="$(dump)" + if grep -qiE "npub1|${C_HEX:0:12}" <<<"$before"; then + ok "cold notification shows the raw pubkey first (pre-enrichment)" + else + meh "could not confirm the raw-pubkey placeholder (title may already be resolved)" + fi + # Now publish C's profile; the SAME notification should re-render to the name. + amyC profile edit --name "E2E Fresh" --relay "$RELAY" >/dev/null 2>&1 || \ + amyC event --kind 0 --content '{"name":"E2E Fresh"}' --relay "$RELAY" >/dev/null + if wait_for "MentionsID" "E2E Fresh"; then + ok "notification enriched in place to the display name after kind:0 arrived" + else + bad "notification never picked up C's name — enrichment window may not be firing" + fi +else + meh "cold enrichment mention didn't arrive — skipped" +fi + +# --------------------------------------------------------------------------- +hdr "Dismiss-on-read — opening the post clears its tray notification" +"${ADB[@]}" shell input keyevent KEYCODE_HOME; sleep 1 +MENTION_ID="$(pubB 1 "e2e dismiss target nostr:$A_NPUB" "[[\"p\",\"$A_HEX\"]]")" +if [ -n "$MENTION_ID" ] && wait_for "MentionsID" "e2e dismiss target"; then + NEVENT="$(amyB encode nevent "$MENTION_ID" --author "$B_HEX" --relay "$RELAY" --json 2>/dev/null | grep -oE 'nevent1[0-9a-z]+' | head -1)" + # Simulate the tap: open the note deep-link the notification would have used. + "${ADB[@]}" shell am start -n "$APP_PKG/$MAIN_ACTIVITY" \ + -a android.intent.action.VIEW -d "nostr:$NEVENT?account=$A_NPUB" >/dev/null 2>&1 + sleep 4 + "${ADB[@]}" shell input keyevent KEYCODE_HOME; sleep 2 + if grep -qF "e2e dismiss target" <<<"$(dump)"; then + meh "notification still present after opening the note (deep-link format may need adjusting)" + else + ok "reading the post cleared its notification from the tray" + fi +else + meh "dismiss-on-read target didn't arrive — skipped" +fi + +# --------------------------------------------------------------------------- +hdr "Result" +echo " $pass passed, $warn warnings, $fail failed" +[ "$fail" -eq 0 ] || exit 1