diff --git a/.github/actions/resolve-release/action.yml b/.github/actions/resolve-release/action.yml new file mode 100644 index 0000000000..01e92a0df6 --- /dev/null +++ b/.github/actions/resolve-release/action.yml @@ -0,0 +1,73 @@ +name: Resolve Release +description: >- + Resolve a bump workflow's target tag and that release's real published state. + Companion to assert-stable-release: this one FETCHES the facts, that one + ENFORCES them. Split so the enforcement stays a pure function of its inputs. + + Handles both entry points of the bump workflows: + - workflow_run -> tag comes from the triggering run's head_branch + - workflow_dispatch (manual recovery) -> tag comes from the input + In both cases draft/prerelease are read back from the GitHub API rather than + inferred, so a draft or prerelease can never slip through to a third-party + package repo just because the trigger payload lacked the flags. + +inputs: + tag: + description: "Release tag to resolve (e.g. vX.Y.Z)" + required: true + github_token: + description: "Token used to read the release via the GH API" + required: true + +outputs: + tag: + description: "The resolved tag, verbatim (e.g. v1.13.1)" + value: ${{ steps.resolve.outputs.tag }} + ver: + description: "The tag with the leading 'v' stripped (e.g. 1.13.1)" + value: ${{ steps.resolve.outputs.ver }} + is_prerelease: + description: "'true' if the GH Release is flagged prerelease" + value: ${{ steps.resolve.outputs.is_prerelease }} + is_draft: + description: "'true' if the GH Release is still a draft" + value: ${{ steps.resolve.outputs.is_draft }} + +runs: + using: composite + steps: + - name: Resolve tag and release state + id: resolve + shell: bash + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ inputs.github_token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + if [[ -z "$TAG" ]]; then + echo "::error::No tag to resolve (neither workflow_run.head_branch nor the dispatch input was set)" + exit 1 + fi + + # A missing release here is a real fault, not something to paper over: + # every caller is about to publish this version to an external package + # manager. Fail loudly and let the caller's Report-failure step file it. + if ! META=$(gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease 2>&1); then + echo "::error::No GH Release found for tag $TAG in $REPO -- refusing to bump" + echo "$META" + exit 1 + fi + + IS_DRAFT=$(echo "$META" | jq -r '.isDraft') + IS_PRERELEASE=$(echo "$META" | jq -r '.isPrerelease') + + { + echo "tag=$TAG" + echo "ver=${TAG#v}" + echo "is_draft=$IS_DRAFT" + echo "is_prerelease=$IS_PRERELEASE" + } >> "$GITHUB_OUTPUT" + + echo "resolved tag=$TAG ver=${TAG#v} draft=$IS_DRAFT prerelease=$IS_PRERELEASE" diff --git a/.github/workflows/bump-homebrew-formula.yml b/.github/workflows/bump-homebrew-formula.yml index d229d22eea..ecdc431d54 100644 --- a/.github/workflows/bump-homebrew-formula.yml +++ b/.github/workflows/bump-homebrew-formula.yml @@ -19,9 +19,14 @@ name: Bump Homebrew Formula (amy CLI) # auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the # "TODO(bootstrap)" note at the bottom of this file. +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the full note in bump-homebrew.yml. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: @@ -38,44 +43,49 @@ permissions: concurrency: # Serialize per tag; do not cancel in-progress runs. - group: bump-homebrew-formula-${{ github.event.release.tag_name || inputs.tag }} + group: bump-homebrew-formula-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: sync-formula: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + # See bump-homebrew.yml for why these three conditions: successful, tag-push + # (not a dry-run dispatch), v-prefixed. Exact format enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - 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" + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} - name: Download jvm bundle and compute sha256 id: asset run: | set -euo pipefail - TAG="${{ steps.ver.outputs.tag }}" - VER="${{ steps.ver.outputs.ver }}" + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${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). + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (mirrors the repo's push/pull retry ethos). ok=0 for i in 1 2 3 4 5; do if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi @@ -110,13 +120,13 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} base: main - branch: chore/bump-amy-formula-${{ steps.ver.outputs.tag }} + branch: chore/bump-amy-formula-${{ steps.rel.outputs.tag }} add-paths: cli/packaging/homebrew/amy.rb - commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}' - title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}' + commit-message: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}' body: | Auto-synced `cli/packaging/homebrew/amy.rb` to the - `${{ steps.ver.outputs.tag }}` release: + `${{ steps.rel.outputs.tag }}` release: - `url` -> `${{ steps.asset.outputs.url }}` - `sha256` -> `${{ steps.asset.outputs.sha256 }}` @@ -124,19 +134,21 @@ jobs: Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep the reference formula ready for the homebrew-core submission/bump. - # TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add a - # step here that opens the homebrew-core bump PR automatically — symmetric to - # the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump- - # formula, or `brew bump-formula-pr amy --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. + # TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add + # a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs `brew + # bump-formula-pr amy --url= --sha256=` from a maintainer's + # machine. Do NOT wire that into CI: bump-formula-pr forks homebrew-core + # into the token owner's account, which needs a classic `repo`-scoped PAT, + # and that scope grants write to every repo the account can reach — + # including this one — to anyone with push access here. See + # BUILDING.md § Homebrew cask for the full reasoning. - 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 tag = context.payload.workflow_run?.head_branch || 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, diff --git a/.github/workflows/bump-homebrew-geode-formula.yml b/.github/workflows/bump-homebrew-geode-formula.yml index b55d926245..bb2e965b42 100644 --- a/.github/workflows/bump-homebrew-geode-formula.yml +++ b/.github/workflows/bump-homebrew-geode-formula.yml @@ -17,9 +17,14 @@ name: Bump Homebrew Formula (geode relay) # 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. +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the full note in bump-homebrew.yml. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: @@ -36,44 +41,49 @@ permissions: concurrency: # Serialize per tag; do not cancel in-progress runs. - group: bump-homebrew-geode-formula-${{ github.event.release.tag_name || inputs.tag }} + group: bump-homebrew-geode-formula-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: sync-formula: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + # See bump-homebrew.yml for why these three conditions: successful, tag-push + # (not a dry-run dispatch), v-prefixed. Exact format enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - 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" + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} - name: Download jvm bundle and compute sha256 id: asset run: | set -euo pipefail - TAG="${{ steps.ver.outputs.tag }}" - VER="${{ steps.ver.outputs.ver }}" + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.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). + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (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 @@ -108,13 +118,13 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} base: main - branch: chore/bump-geode-formula-${{ steps.ver.outputs.tag }} + branch: chore/bump-geode-formula-${{ steps.rel.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 }}' + commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}' body: | Auto-synced `geode/packaging/homebrew/geode.rb` to the - `${{ steps.ver.outputs.tag }}` release: + `${{ steps.rel.outputs.tag }}` release: - `url` -> `${{ steps.asset.outputs.url }}` - `sha256` -> `${{ steps.asset.outputs.sha256 }}` @@ -122,19 +132,19 @@ jobs: 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. + # TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core, + # add a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs + # `brew bump-formula-pr geode --url= --sha256=` from a + # maintainer's machine. Do NOT wire that into CI — it needs a classic + # `repo`-scoped PAT, which as a CI secret would hand write access to this + # repo to anyone with push access. See BUILDING.md § Homebrew cask. - 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 tag = context.payload.workflow_run?.head_branch || 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, diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index 257b83d5a2..483afcdead 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -1,75 +1,181 @@ -name: Bump Homebrew Cask +name: Sync Homebrew Cask Reference -# Fires when a GH Release is published (not draft, not prerelease). -# `release.types: [released]` event fires only for stable releases — still -# double-checked by .github/actions/assert-stable-release for defense-in-depth. +# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml +# (geode). Same mechanism, third artifact: +# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG) +# +# What it does: after a stable release, download the published macOS DMG, assert +# it is notarized + stapled, compute its sha256, and open a PR syncing +# `desktopApp/packaging/homebrew/amethyst-nostr.rb` to that release. +# +# What it does NOT do: open a PR against Homebrew/homebrew-cask. That step is +# deliberately MANUAL and runs on a maintainer's machine — +# `scripts/bump-homebrew-cask.sh`. Reason: `brew bump-cask-pr` forks +# homebrew-cask into the token owner's account, which requires a CLASSIC PAT +# with the `repo` scope; that scope grants write to every repository the account +# can reach, and stored as a CI secret it would be usable by anyone with push +# access to this repo. Keeping it in a maintainer's shell instead of a CI secret +# removes that blast radius entirely, at the cost of one command per release. +# See BUILDING.md § Homebrew cask. +# +# Consequence: this workflow needs NO external token. GITHUB_TOKEN is enough, +# exactly like the two formula workflows. +# +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the note in bump-homebrew-formula.yml. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: - description: 'Release tag to bump (for manual recovery)' + description: 'Release tag to sync (for manual recovery)' required: true type: string permissions: - contents: read - # The "Report failure" step below opens a [release-ops] issue via - # github.rest.issues.create; that needs issues:write. Without it the failure - # reporter itself 403s and no alert is ever filed. + 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 bumps per tag; do not cancel in-progress bumps. - group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }} + # Serialize per tag; do not cancel in-progress runs. + group: bump-homebrew-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: - bump: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false - runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota - timeout-minutes: 30 + sync-cask: + # See bump-homebrew-formula.yml for why these three conditions: successful, + # tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) + # macOS runner: `xcrun stapler` is the only way to verify the notarization + # ticket, and shipping an unnotarized DMG to the cask is the failure mode + # this whole channel is most exposed to. + runs-on: macos-latest + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - 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' }} + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} - - name: Bump cask (push-or-update PR) - uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + - name: Download DMG, verify notarization, compute sha256 + id: asset + run: | + set -euo pipefail + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" + URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-macos-arm64.dmg" + echo "Fetching $URL" + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (mirrors the repo's push/pull retry ethos). + ok=0 + for i in 1 2 3 4 5; do + if curl -fsSL -o amethyst.dmg "$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 amethyst.dmg + + # A cask must point at a notarized+stapled DMG or every user hits a + # Gatekeeper block. Refuse to advertise one that is not. + if ! xcrun stapler validate amethyst.dmg; then + echo "::error::${TAG} DMG has no stapled notarization ticket -- refusing to sync the cask. Check the notarizeReleaseDmg step in create-release.yml." + exit 1 + fi + + SHA=$(shasum -a 256 amethyst.dmg | awk '{print $1}') + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + echo "amethyst-desktop-${VER}-macos-arm64.dmg -> $SHA" + + - name: Update reference cask + run: | + set -euo pipefail + CASK=desktopApp/packaging/homebrew/amethyst-nostr.rb + VER="${{ steps.rel.outputs.ver }}" + SHA="${{ steps.asset.outputs.sha256 }}" + # Anchor on the 2-space indent so the header comment's example lines + # are never touched. + sed -i '' -E "s|^( version ).*|\1\"${VER}\"|" "$CASK" + sed -i '' -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$CASK" + echo "----- $CASK -----" + grep -E "^ (version|sha256) " "$CASK" + + - name: Open or update the cask-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.HOMEBREW_TOKEN }} - tap: homebrew/cask - cask: amethyst-nostr - tag: ${{ github.event.release.tag_name || inputs.tag }} + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: chore/bump-amethyst-cask-${{ steps.rel.outputs.tag }} + add-paths: desktopApp/packaging/homebrew/amethyst-nostr.rb + commit-message: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}' + body: | + Auto-synced `desktopApp/packaging/homebrew/amethyst-nostr.rb` to the + `${{ steps.rel.outputs.tag }}` release: + + - `version` -> `${{ steps.rel.outputs.ver }}` + - `sha256` -> `${{ steps.asset.outputs.sha256 }}` + + The DMG was verified notarized + stapled before this PR was opened. + + **Merge this, then push it upstream from a maintainer machine:** + + ```bash + scripts/bump-homebrew-cask.sh ${{ steps.rel.outputs.tag }} + ``` + + That step is manual on purpose — it needs a classic PAT with the + `repo` scope, which is deliberately NOT stored as a CI secret. See + BUILDING.md § Homebrew cask. - 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 tag = context.payload.workflow_run?.head_branch || 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 failed for ${tag}`, + title: `[release-ops] sync-homebrew-cask failed for ${tag}`, body: [ - `Homebrew cask bump failed for release \`${tag}\`.`, + `amethyst-nostr cask sync failed for release \`${tag}\`.`, ``, `- Run: ${runUrl}`, `- Channel: Homebrew Cask (\`amethyst-nostr\`)`, ``, `Recovery options:`, - `1. Re-run the workflow once underlying issue is fixed`, - `2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``, - `3. File PR directly against Homebrew/homebrew-cask` + `1. Re-run the workflow once the underlying issue is fixed`, + `2. Check the DMG is notarized: \`xcrun stapler validate\` on the release asset`, + `3. Manually update \`desktopApp/packaging/homebrew/amethyst-nostr.rb\` (version + sha256)` ].join('\n'), labels: ['release-ops', 'bug'] }); diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml index 0c00610d56..ef19cfd79a 100644 --- a/.github/workflows/bump-winget.yml +++ b/.github/workflows/bump-winget.yml @@ -1,72 +1,214 @@ -name: Bump Winget Manifest +name: Sync Winget Manifest Reference +# Fourth sibling of the three Homebrew sync workflows, same shape: +# bump-homebrew-formula.yml -> Formula `amy` +# bump-homebrew-geode-formula.yml -> Formula `geode` +# bump-homebrew.yml -> Cask `amethyst-nostr` +# this workflow -> Winget `VitorPamplona.Amethyst` +# +# What it does: after a stable release, download the published Windows MSI, +# compute its sha256, read its ProductCode, and open a PR syncing +# desktopApp/packaging/winget/*.yaml to that release. +# +# What it does NOT do: open a PR against microsoft/winget-pkgs. That step is +# deliberately MANUAL and runs on a maintainer's machine — +# `scripts/bump-winget.sh`. Reason: submitting requires push access to a fork of +# winget-pkgs. The previous design stored a classic `public_repo` PAT as +# WINGET_TOKEN and handed it to a third-party action; that scope grants write to +# every public repo the account can reach, and as an Actions secret it was +# usable by anyone with push access to this repo. The local script uses the +# maintainer's existing `gh` auth instead, so no PAT is created at all. +# See BUILDING.md § Winget. +# +# Consequence: this workflow needs NO external token and no third-party action. +# +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the note in bump-homebrew-formula.yml. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: - description: 'Release tag to submit (for manual recovery)' + description: 'Release tag to sync (for manual recovery)' required: true type: string permissions: - contents: read - # The "Report failure" step below opens a [release-ops] issue via - # github.rest.issues.create; that needs issues:write. Without it the failure - # reporter itself 403s and no alert is ever filed. + 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: - group: bump-winget-${{ github.event.release.tag_name || inputs.tag }} + group: bump-winget-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: - bump: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false - runs-on: windows-latest - timeout-minutes: 30 + sync-manifest: + # See bump-homebrew-formula.yml for why these three conditions: successful, + # tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) + # Linux, not Windows: msitools reads the MSI Property table just as well, and + # this leg is billed 1x instead of 2x. + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - 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' }} + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} - - name: Submit manifest to winget-pkgs - uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + - name: Install msitools + run: sudo apt-get update -qq && sudo apt-get install -y -qq msitools + + - name: Download MSI, compute sha256 + ProductCode + id: asset + run: | + set -euo pipefail + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" + URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-windows-x64.msi" + echo "Fetching $URL" + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (mirrors the repo's push/pull retry ethos). + ok=0 + for i in 1 2 3 4 5; do + if curl -fsSL -o amethyst.msi "$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 amethyst.msi + + SHA=$(sha256sum amethyst.msi | awk '{print $1}' | tr '[:lower:]' '[:upper:]') + + # ProductCode is the ARP key winget uses to detect an existing install. + # jpackage regenerates it per build, so read it rather than pin it. + PRODUCT_CODE=$(msiinfo export amethyst.msi Property \ + | awk -F'\t' '$1 == "ProductCode" { print $2 }' | tr -d '\r') + if [[ ! "$PRODUCT_CODE" =~ ^\{[0-9A-Fa-f-]{36}\}$ ]]; then + echo "::error::could not read a valid ProductCode from the MSI (got: '${PRODUCT_CODE}')" + exit 1 + fi + + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + echo "product_code=$PRODUCT_CODE" >> "$GITHUB_OUTPUT" + echo "sha256=$SHA" + echo "ProductCode=$PRODUCT_CODE" + + - name: Update reference manifests + run: | + set -euo pipefail + DIR=desktopApp/packaging/winget + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" + SHA="${{ steps.asset.outputs.sha256 }}" + URL="${{ steps.asset.outputs.url }}" + PC="${{ steps.asset.outputs.product_code }}" + + # Anchored substitutions so the header comments are never touched. + sed -i -E "s|^(PackageVersion: ).*|\1${VER}|" \ + "$DIR/VitorPamplona.Amethyst.yaml" \ + "$DIR/VitorPamplona.Amethyst.installer.yaml" \ + "$DIR/VitorPamplona.Amethyst.locale.en-US.yaml" + sed -i -E "s|^( InstallerUrl: ).*|\1${URL}|" "$DIR/VitorPamplona.Amethyst.installer.yaml" + # Quoted: an all-digit 64-char digest would otherwise parse as a YAML + # integer and fail the schema's `string` type. + sed -i -E "s|^( InstallerSha256: ).*|\1'${SHA}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml" + sed -i -E "s|^( ProductCode: ).*|\1'${PC}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml" + sed -i -E "s|^(ReleaseNotesUrl: ).*|\1https://github.com/${{ github.repository }}/releases/tag/${TAG}|" \ + "$DIR/VitorPamplona.Amethyst.locale.en-US.yaml" + + echo "----- synced -----" + grep -hE "^(PackageVersion| InstallerUrl| InstallerSha256| ProductCode|ReleaseNotesUrl): " "$DIR"/*.yaml + + - name: Sanity-check the manifests still parse + run: | + set -euo pipefail + python3 - <<'PY' + import glob, sys, yaml + for f in sorted(glob.glob('desktopApp/packaging/winget/*.yaml')): + d = yaml.safe_load(open(f)) + assert d['PackageIdentifier'] == 'VitorPamplona.Amethyst', f + assert d['PackageVersion'], f + print('OK', f, d['ManifestType']) + PY + + - name: Open or update the manifest-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: - identifier: VitorPamplona.Amethyst - version: ${{ github.event.release.tag_name || inputs.tag }} - # Asset naming contract: scripts/asset-name.sh - installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$' - token: ${{ secrets.WINGET_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: chore/bump-winget-manifest-${{ steps.rel.outputs.tag }} + add-paths: desktopApp/packaging/winget + commit-message: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}' + body: | + Auto-synced `desktopApp/packaging/winget/` to the + `${{ steps.rel.outputs.tag }}` release: + + - `PackageVersion` -> `${{ steps.rel.outputs.ver }}` + - `InstallerSha256` -> `${{ steps.asset.outputs.sha256 }}` + - `ProductCode` -> `${{ steps.asset.outputs.product_code }}` + + **Merge this, then push it upstream from a maintainer machine:** + + ```bash + scripts/bump-winget.sh ${{ steps.rel.outputs.tag }} + ``` + + That step is manual on purpose — it needs push access to a fork of + `microsoft/winget-pkgs`, which is deliberately NOT stored as a CI + secret. The script uses your existing `gh` auth. See + BUILDING.md § Winget. - 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 tag = context.payload.workflow_run?.head_branch || 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-winget failed for ${tag}`, + title: `[release-ops] sync-winget-manifest failed for ${tag}`, body: [ - `Winget manifest submission failed for release \`${tag}\`.`, + `Winget manifest sync failed for release \`${tag}\`.`, ``, `- Run: ${runUrl}`, `- Channel: Winget (\`VitorPamplona.Amethyst\`)`, ``, `Recovery options:`, - `1. Re-run the workflow once underlying issue is fixed`, - `2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``, - `3. File PR directly against microsoft/winget-pkgs` + `1. Re-run the workflow once the underlying issue is fixed`, + `2. Check the release actually published \`amethyst-desktop-${tag.replace(/^v/, '')}-windows-x64.msi\``, + `3. Manually update \`desktopApp/packaging/winget/*.yaml\` (version, sha256, ProductCode)` ].join('\n'), labels: ['release-ops', 'bug'] }); diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 3a10ca3e3c..19ca1146e8 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -155,8 +155,44 @@ jobs: AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} with: max_attempts: 2 - timeout_minutes: 15 - command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }} + # macOS needs far longer: notarizeReleaseDmg blocks on `notarytool + # submit --wait`, which is minutes-to-tens-of-minutes on Apple's side. + timeout_minutes: ${{ matrix.family == 'macos' && 45 || 15 }} + # Append notarization on the macOS leg. `packageReleaseDmg` only SIGNS + # the DMG — notarization is a separate Compose task, and because it was + # never invoked every release up to v1.13.1 shipped a signed but + # UNNOTARIZED DMG that Gatekeeper blocks on first launch. The task runs + # `notarytool submit --wait` and then `stapler staple`, in place, so the + # asset-collection step below still finds the same file. + # + # Gated on the cert AND all three notary secrets being present, so forks + # and credential-less runs keep producing a plain unsigned DMG exactly as + # before instead of failing. + command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}${{ (matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && ' :desktopApp:notarizeReleaseDmg' || '' }} + + # Regression guard. The missing-notarization bug was invisible for many + # releases precisely because nothing ever asserted the outcome; assert it + # now so a silently-dropped notarization step can never ship again. + - name: Verify the DMG is notarized and stapled (macOS leg) + if: matrix.family == 'macos' + env: + EXPECT_NOTARIZED: ${{ (steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && 'true' || 'false' }} + run: | + set -euo pipefail + DMG=$(find desktopApp/build/compose/binaries -name "*.dmg" -print -quit) + [[ -n "$DMG" ]] || { echo "::error::no DMG produced"; exit 1; } + echo "Checking $DMG" + + if [[ "$EXPECT_NOTARIZED" != "true" ]]; then + echo "::warning::Apple signing/notary credentials are not configured; this DMG is unsigned and unnotarized. Gatekeeper will block it, and it is not eligible for the Homebrew cask." + exit 0 + fi + + if ! xcrun stapler validate "$DMG"; then + echo "::error::$DMG has no stapled notarization ticket -- notarizeReleaseDmg did not run or failed" + exit 1 + fi + echo "notarization ticket stapled OK" # jpackage pins libicu to the build host's version (libicu74 on # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. diff --git a/BUILDING.md b/BUILDING.md index e719e3aad2..5a04173603 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -325,15 +325,29 @@ Quartz library in one pipeline. 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -4. **Verify**: - - GH Release contains 8 desktop assets + 12 Android assets +4. **Verify** — the GH Release should hold **31 assets**: + - **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`, + `AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS + DMG** — `jpackage` cannot cross-compile and no Intel runner leg is + configured, so macOS ships arm64-only. + - **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the + F-Droid `.apks` set built for Accrescent. + - **5 amy** + **5 geode** bundles. - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - - Intel + ARM DMGs both present - Android flow unchanged + Quick diff against the previous release, which catches a silently-dropped + matrix leg better than any count: + + ```bash + diff <(gh release view v1.13.0 --json assets --jq '.assets[].name' | sed 's/1\.13\.0/VER/g' | sort) \ + <(gh release view v1.13.1 --json assets --jq '.assets[].name' | sed 's/1\.13\.1/VER/g' | sort) + ``` + 5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`, - or `-snapshot` is auto-classified as prerelease. Stable tags trigger the - Homebrew + Winget bump workflows. + or `-snapshot` is auto-classified as prerelease. Only stable tags run the + Homebrew + Winget bump workflows (and those are no-ops until the one-time + bootstrap PRs land — see § Bootstrap). ### Dry-run (no tag push) @@ -389,8 +403,8 @@ provided automatically; everything else you set yourself.) | `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) | | `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same | | `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same | -| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) | -| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) | +| ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — | +| ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) | Note the **three distinct signing identities** people often conflate: @@ -473,11 +487,11 @@ distributes; the official Amethyst rollout for each is in | Channel | How it ships | Push or pull | |---|---|---| | **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) | -| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) | +| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | Automatic (CI) | | **Google Play** | Download the signed `amethyst-googleplay-.aab` from the GH Release and upload it in Play Console | **Manual push** | | **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** | | **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** | -| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) | +| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags — **currently no-ops**: neither package has been bootstrapped upstream yet (§ Bootstrap) | Automatic (CI), inactive | Two channels need the build to stay split into product flavors (see `amethyst/build.gradle.kts` → `productFlavors`): @@ -498,20 +512,88 @@ reads an optional per-release changelog from ## Bootstrap runbook (one-time) -### Secrets to provision in GitHub repo settings +> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.** +> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and +> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so +> **Amethyst does not currently ship through either channel.** The bump +> workflows detect this and skip with a `::warning::` instead of failing, so a +> green release run does *not* mean Homebrew/Winget shipped. The two subsections +> below are the work that activates them; until then treat the desktop app as +> GitHub-Releases-only on macOS and Windows. + +### Package-manager credentials (and why there are none) The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs). -The two that need the most setup care are the package-manager PATs, because of -their token type and scope: +Neither package-manager channel adds anything to it: -| Secret | Purpose | Scope | -|---|---|---| -| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry | -| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | +**There are deliberately no package-manager PATs in CI.** Both the Homebrew +cask and the Winget manifest bumps run on a maintainer's machine. The reasoning +is worth keeping, because it is the reason this repo has no third secret to +rotate: -Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md` -or equivalent issue tracker. On rotation, paste new token and run -`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify. +`brew bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's +account** (`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that +fork, then opens the PR upstream. That shape forces a **classic** PAT with the +`repo` scope: + +- A fine-grained PAT cannot express it. Its "Repository access" selector only + lists repos owned by the resource owner, so `Homebrew/homebrew-cask` can never + be selected — and Homebrew's API layer authorises against classic OAuth scopes + (`x-oauth-scopes`), which fine-grained tokens do not emit. +- Homebrew declares the requirement in source as + `CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`). + +And `repo` cannot be narrowed: it grants write to *every* repository the owning +account can reach — including `vitorpamplona/amethyst` itself. Stored as an +Actions secret it would be usable by **anyone with push access to this repo**, +since a pushed branch containing a workflow runs with repo secrets. That is a +strict escalation for a channel that ships one DMG a month. + +So the split is: + +- **CI** (`bump-homebrew.yml`, `GITHUB_TOKEN` only) does the error-prone + bookkeeping: downloads the DMG, asserts it is notarized + stapled, computes + the sha256, and opens an in-repo PR syncing + `desktopApp/packaging/homebrew/amethyst-nostr.rb`. +- **A maintainer** merges that PR and runs `scripts/bump-homebrew-cask.sh`, + which re-verifies the sha256 and the notarization ticket against the live + asset before calling `brew bump-cask-pr`. + +The token then lives only in that maintainer's shell: + +```bash +export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope +scripts/bump-homebrew-cask.sh v1.13.2 +``` + +Create one at +. +Prefer a dedicated bot account whose only asset is a fork of `homebrew-cask`, so +a leak reaches nothing else. + +### Winget + +Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives +`gh`, which a maintainer is already authenticated with, and it does not need +`wingetcreate` (Windows-only) because winget manifests are plain YAML — so it +runs fine from macOS or Linux: + +```bash +scripts/bump-winget.sh v1.13.2 +``` + +CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the +MSI, computes the sha256, reads the `ProductCode` out of the MSI Property table +with `msitools`, and opens an in-repo PR syncing +`desktopApp/packaging/winget/*.yaml`. The script re-verifies the sha256 against +the live asset, then forks `microsoft/winget-pkgs`, commits the three manifests +to `manifests/v/VitorPamplona/Amethyst//`, and opens the PR. + +The previous design stored a classic `public_repo` PAT as `WINGET_TOKEN` and +passed it to the third-party `vedantmgoyal9/winget-releaser` action — a token +with write access to every public repo the account owns, handed to code we do +not control, in a place any push-access collaborator could read it from. None of +that is needed. ### Homebrew cask (one-time initial PR) @@ -636,12 +718,19 @@ by any other channel. | OS | App location | State directories | |---|---|---| -| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`
`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`
`~/Library/Caches/Amethyst` | +| macOS | `/Applications/Amethyst.app` | `~/.amethyst` (accounts + keys)
`~/Library/Application Support/Amethyst` (Tor)
`~/Library/Caches/AmethystDesktop` (image cache)
`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) | | Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`
`%LOCALAPPDATA%\Amethyst` | | Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`
`~/.local/share/amethyst`
`~/.cache/amethyst` | | Linux (AppImage/tar.gz) | user-chosen | Same as above | | Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` | +**macOS preferences are in a SHARED file.** `DesktopPreferences` uses the Java +Preferences API, which on macOS writes into +`~/Library/Preferences/com.apple.java.util.prefs.plist` — one plist for *every* +Java application on the machine, not a per-app file. Never delete it to "reset +Amethyst": that wipes unrelated apps' settings. This is why the Homebrew cask's +`zap` stanza deliberately omits it. + Uninstall: - Homebrew: `brew uninstall --cask amethyst-nostr && brew zap amethyst-nostr` diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index 1f0975423e..5b4b6eef39 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -14,7 +14,7 @@ specific to shipping the official Amethyst artifacts. ## At a glance -A release is one tag push that fans out to five distribution channels: +A release is one tag push that fans out to four live distribution channels: | Channel | Mechanism | Who pushes | |---|---|---| @@ -22,10 +22,11 @@ A release is one tag push that fans out to five distribution channels: | **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer | | **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) | | **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer | -| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI | +| **Homebrew + Winget** | ⚠️ **Not shipping.** The bump workflows run, but skip: neither package exists upstream yet | Nobody (see § 3) | Maven Central (the `quartz` library) also publishes automatically from the same -workflow. +workflow — as a *step* at the end of the `deploy-android` job, not a job of its +own, so don't expect to find it in the run's job list. --- @@ -61,8 +62,10 @@ workflow. `scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` / `CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.) -3. **Publish the release-notes note on Nostr** with Amethyst's account and paste - its event id into `amethyst/build.gradle.kts`: +3. **Publish the release-notes note on Nostr** — *minor releases only.* In + practice this id has only ever been bumped on `x.y.0` (1.11.0, 1.12.0, + 1.13.0); patch releases leave it pointing at their minor's note. Publish with + Amethyst's account and paste the event id into `amethyst/build.gradle.kts`: ```kotlin buildConfigField("String", "RELEASE_NOTES_ID", "\"\"") ``` @@ -85,17 +88,33 @@ workflow. Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook) for the exact commands. The tag must equal `app` from the catalog (the workflow asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is -classified **stable** and triggers the Homebrew/Winget bumps; anything with a -`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them. +classified **stable** and runs the Homebrew/Winget bump workflows; anything with +a `-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them. + +Heads-up on the `git push`: this repo has `git-credential-manager` configured as +a credential helper, and it blocks on an interactive prompt (a plain +`GIT_TERMINAL_PROMPT=0` does **not** stop it — the push just hangs). If that +happens, push using `gh`'s helper for the one command: + +```bash +git -c credential.helper= -c credential.helper='!gh auth git-credential' push upstream main +``` When the `Create Release Assets` workflow finishes (~25–30 min) the GH Release -holds, per the asset-name contract: +holds **31 assets**, per the asset-name contract: -- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs - (`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`) -- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz) -- **CLI:** the `amy` artifacts -- **Maven Central:** `com.vitorpamplona.quartz:quartz:` published +- **Android (13):** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid + `.apks` set for Accrescent + (`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab` / `.apks`) +- **Desktop (8):** DMG (macOS **arm64 only** — there is no Intel DMG), + MSI + zip, DEB, RPM, AppImage, flatpak, tar.gz +- **CLI (5):** the `amy` artifacts +- **Relay (5):** the `geode` artifacts, plus the geode Docker image +- **Maven Central:** `com.vitorpamplona.quartz:quartz:` published. + `repo1.maven.org` lags the publish by tens of minutes — a 404 right after the + run is normal. Confirm the step's log says "Deployment is being published to + Maven Central", and compare against the *previous* version's POM before + concluding anything is broken. --- @@ -165,11 +184,53 @@ RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vi Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app itself reads from. -### Homebrew + Winget — automatic -`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs -against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and -`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails — -then see BUILDING.md § Bootstrap and § Incident response. +### Homebrew + Winget — ⚠️ not shipping yet + +`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against +`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs` +(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted +upstream**, so both workflows detect that and skip with a `::warning::`. As of +**v1.13.1** these two channels deliver nothing; macOS and Windows users get the +desktop app from GitHub Releases only. + +Two separate faults kept this invisible until v1.13.1, both now fixed: + +1. **The workflows never ran at all** — for *any* release. They triggered on + `release: types: [released]`, and GitHub does not raise workflow-triggering + events for a release created by `GITHUB_TOKEN`, which is exactly how + `create-release.yml` creates it. They now trigger on `workflow_run` after + `Create Release Assets` succeeds, which also fixes a latent race — the old + event fired while assets were still uploading. +2. **Nothing exists upstream to bump.** `brew bump-cask-pr` and + `winget-releaser` can only *update* an existing package. The first + submission is a manual, human-reviewed PR: BUILDING.md § Homebrew cask + (one-time initial PR) and § Winget (one-time initial submission). + +Until someone does that bootstrap, a green release run means the bump workflows +*skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings +if you want to confirm which case you're in. + +**Both bumps are half-manual by design.** CI does the bookkeeping with +`GITHUB_TOKEN` only — verifying the artifact, computing hashes, and opening an +in-repo PR syncing the reference packaging files. Pushing upstream needs +credentials that would be dangerous as CI secrets (a `repo`-scoped PAT is +readable by anyone with push access here), so a maintainer runs the last step: + +```bash +# after merging the sync PRs +export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope +scripts/bump-homebrew-cask.sh v1.13.2 + +scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth +``` + +Both scripts re-verify the published artifact's sha256 before submitting, and +the Homebrew one additionally refuses if the DMG is not notarized + stapled. +See BUILDING.md § Package-manager credentials. + +All four in-repo sync workflows (`amy` formula, `geode` formula, +`amethyst-nostr` cask, winget manifests) open PRs against *this* repo on every +release. Merge them to keep the reference packaging files current. --- @@ -211,7 +272,7 @@ ownership: | `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) | | `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise | | `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry | -| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) | +| *(none for Homebrew/Winget)* | — | Both bumps run on a maintainer's machine — `scripts/bump-homebrew-cask.sh` and `scripts/bump-winget.sh` — so neither channel's PAT ever becomes a CI secret. See BUILDING.md § Package-manager credentials | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise | Owner assignments and rotation reminders live with the team (issue tracker). @@ -222,13 +283,23 @@ Owner assignments and rotation reminders live with the team (issue tracker). ## 6. Post-release verification -- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane. -- [ ] Maven Central: `quartz:` resolves (allow propagation time). +- [ ] GH Release: 31 assets, sizes sane, and the asset-name set matches the + previous release (see the `diff` one-liner in BUILDING.md § Release + runbook). macOS is arm64-only — do **not** look for an Intel DMG. +- [ ] Maven Central: `quartz:` resolves (allow tens of minutes of + propagation; the publish step's log is the authoritative signal). - [ ] Play Console: rollout started, no policy rejection. - [ ] Zapstore: release event visible. - [ ] F-Droid: new version detected (may lag days). -- [ ] Homebrew + Winget bump PRs opened (stable only). -- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`. +- [ ] Four sync PRs opened against this repo (`amy` formula, `geode` formula, + `amethyst-nostr` cask, winget manifests) — merge them. +- [ ] Cask pushed upstream: `scripts/bump-homebrew-cask.sh vX.Y.Z` (manual, needs + `HOMEBREW_GITHUB_API_TOKEN` in your shell). +- [ ] Winget pushed upstream: `scripts/bump-winget.sh vX.Y.Z` (manual, no token — + uses your `gh` auth). + Both scripts error clearly until the one-time bootstrap PRs land (§ 3). +- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID` + (only bumped on minor releases — patches keep pointing at the x.y.0 note). - [ ] Push still works on a `play` build (only if the push contract changed — see § 4); UnifiedPush still works on an `fdroid` build. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f903682bfc..9f44436f55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences +import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -287,6 +288,11 @@ class AppModules( // Restore + persist the user's starred Buzz workspace channels across restarts (device-global). val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope) + // Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a + // deleted channel stays hidden across a restart even if the host relay re-announces a stale + // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). + val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() 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 6c7b0597de..68df7e7ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership @@ -3465,6 +3466,10 @@ class Account( val template = DeleteGroupEvent.build(channel.groupId.id) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } unfollow(channel) + // Remember the deletion so the channel leaves the community's browse list immediately and + // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a + // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. + RelayGroupDeletions.markDeleted(channel.groupId) } /** @@ -3671,6 +3676,10 @@ class Account( parent: String? = channel.parentGroupId(), children: List = channel.childGroupIds(), ) { + // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT + // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on + // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. + val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) val template = EditMetadataEvent.build( channel.groupId.id, @@ -3682,10 +3691,24 @@ class Account( geohashes = geohashes, parent = parent, children = children, + visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** + * Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The + * relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its + * history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces. + */ + suspend fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) { + val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt index d325b552e5..716fa3d986 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt @@ -40,12 +40,18 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -72,11 +78,15 @@ enum class HomeFeedType( TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)), REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)), COMMENTS("comments", listOf(CommentEvent.KIND)), + PICTURES("pictures", listOf(PictureEvent.KIND)), + VIDEOS("videos", listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND)), + SHORTS("shorts", listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND)), ARTICLES("articles", listOf(LongTextNoteEvent.KIND)), WIKI("wiki", listOf(WikiNoteEvent.KIND)), HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)), POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)), CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)), + TORRENTS("torrents", listOf(TorrentEvent.KIND)), VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)), LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)), EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)), 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 820cbcaa1a..bdad25dece 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter @@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent @@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { attachToRelayGroupIfScoped(event, relay) } + /** + * A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via + * [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that + * a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery + * events but emits no member-removed notification and never retracts the kind-44100 that seeds the + * browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced + * every restart, its metadata now blank so it shows optimistically). Recording the delete in + * [RelayGroupDeletions] filters it out of every list and persists it across restarts. + * + * Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed + * system message from a stray author can't hide a channel. Cross-device by construction: the relay + * replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too. + */ + private fun consume( + event: SystemMessageEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = + consumeBuzzTimelineEvent(event, relay, wasVerified).also { + if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) { + event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) } + } + } + /** Store-only consume for Buzz kinds that carry no channel timeline row. */ private fun consumeBuzzRegularEvent( event: Event, @@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) + is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt new file mode 100644 index 0000000000..99e1f99c01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.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.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]), + * so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps + * re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not + * per-account), loads the saved keys into the singleton on construction, then writes every later change + * back. Construct once, eagerly. + */ +@Stable +class RelayGroupDeletionPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + RelayGroupDeletions.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" } + } + } + + private suspend fun persist(keys: Set) { + try { + context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("nip29.deletedChannels") + } +} 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 14faf2d41c..d520612fcb 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 @@ -790,6 +790,7 @@ fun BuildNavigation( composableFromEndArgs { RelayGroupCreateScreen( relayUrl = it.relayUrl, + isForum = it.isForum, accountViewModel = accountViewModel, nav = 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 57b5af66e8..4392da65e9 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 @@ -741,6 +741,9 @@ sealed class Route { @Serializable data class RelayGroupCreate( val relayUrl: String, + // Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a + // `stream` (chat) one — set by the community screen's per-section "+" buttons. + val isForum: Boolean = false, ) : Route() @Serializable data class RelayGroupEdit( 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 995197944e..42ce00cb0b 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 @@ -327,10 +327,7 @@ import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent -import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -1514,19 +1511,9 @@ private fun RenderNoteRow( FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) } - is VideoHorizontalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) - } - - is VideoVerticalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) - } - - is VideoNormalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) - } - - is VideoShortEvent -> { + // Covers every NIP-71 video kind (21, 22, 34235, 34236) via the shared interface, + // so new video subtypes render automatically instead of falling through to text. + is VideoEvent -> { VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) } 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 b105993fc6..a247e1cf1e 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 @@ -1675,6 +1675,15 @@ class AccountViewModel( /** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */ fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) } + /** + * Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without + * destroying it, and is reversible. Owner/admin only; the relay enforces it. + */ + fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) = launchSigner { account.archiveRelayGroup(channel, archived) } + /** * Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops * showing, but send no kind-9022 — I stay in the relay roster and can still read/post, and re-joining @@ -1708,6 +1717,22 @@ class AccountViewModel( */ fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel) + /** + * Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay + * republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it. + */ + fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) } + + /** + * Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with + * the same [participants] (a kind-41010 resolving to the same canonical channel), which drops it + * from the 30622 hidden snapshot. + */ + fun unhideBuzzDm( + relay: NormalizedRelayUrl, + participants: List, + ) = launchSigner { account.openBuzzDm(relay, participants) } + /** * Keep the channel off Messages without touching membership. Local and reversible — I stay in the * roster and can still open and post; [leaveChannelInvite] is the one that actually removes me. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index a0d45791da..387a77b0eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -32,16 +32,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -83,9 +78,9 @@ private const val CARD_WARMUP_LIMIT = 10 * kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a * recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity * summary for system/diff/job rows), the relative time of that message, and an unread-count badge. - * Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the - * per-channel actions — Pin/Unpin and the Add/Remove-from-Messages toggle ([isAdded] says which half - * to show, and it must come from the live kind-10009 list) — so the row stays clean. + * Tapping the card opens the channel ([onOpen]); the row itself is a clean tap-to-open target — its + * per-channel actions (Pin/Unpin, Add/Remove-from-Messages) live in the opened channel's/forum's + * top-bar overflow, not on the row. A pinned channel still shows a pin marker here ([isStarred]). * * Reused by the relay group-list screen where Buzz membership discovery is folded in. * @@ -98,13 +93,9 @@ private const val CARD_WARMUP_LIMIT = 10 @Composable fun BuzzImportRow( groupId: GroupId, - isAdded: Boolean, - onAdd: () -> Unit, - onRemove: () -> Unit, accountViewModel: AccountViewModel, onOpen: (() -> Unit)? = null, isStarred: Boolean = false, - onToggleStar: (() -> Unit)? = null, showActivityPreview: Boolean = true, ) { val account = accountViewModel.account @@ -166,11 +157,7 @@ fun BuzzImportRow( faceAuthors = faceAuthors, unread = unread, hasUnread = hasUnread, - isAdded = isAdded, isStarred = isStarred, - onToggleStar = onToggleStar, - onAdd = onAdd, - onRemove = onRemove, accountViewModel = accountViewModel, ) } @@ -195,15 +182,11 @@ private fun BuzzImportRowContent( faceAuthors: List, unread: Int, hasUnread: Boolean, - isAdded: Boolean, isStarred: Boolean, - onToggleStar: (() -> Unit)?, - onAdd: () -> Unit, - onRemove: () -> Unit, accountViewModel: AccountViewModel, ) { Row( - modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { BuzzImportAvatar(name = name, seed = seed) @@ -254,13 +237,6 @@ private fun BuzzImportRowContent( ConcordUnreadBadge(unread) } } - BuzzChannelRowMenu( - isAdded = isAdded, - onAdd = onAdd, - onRemove = onRemove, - isStarred = isStarred, - onToggleStar = onToggleStar, - ) } } @@ -308,68 +284,6 @@ private fun BuzzChannelPreviewLine( ) } -/** - * The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a - * channel card reads as a clean Concord-style row, with its actions one tap behind the kebab. - */ -@Composable -private fun BuzzChannelRowMenu( - isAdded: Boolean, - onAdd: () -> Unit, - onRemove: () -> Unit, - isStarred: Boolean, - onToggleStar: (() -> Unit)?, -) { - var expanded by remember { mutableStateOf(false) } - Box { - IconButton(onClick = { expanded = true }) { - Icon( - symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.more_options), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if (onToggleStar != null) { - DropdownMenuItem( - leadingIcon = { - Icon( - symbol = MaterialSymbols.PushPin, - contentDescription = null, - tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, - onClick = { - expanded = false - onToggleStar() - }, - ) - } - // A toggle, not a one-way "Added" badge: a channel already on the kind-10009 list offers - // the way back off it. Neither half touches the relay roster, so the channel stays in this - // list (and readable) either way — only whether it shows on Messages changes. - DropdownMenuItem( - leadingIcon = { - Icon( - symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) }, - onClick = { - expanded = false - if (isAdded) onRemove() else onAdd() - }, - ) - } - } -} - /** A round monogram whose color is derived deterministically from the channel id. */ @Composable private fun BuzzImportAvatar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 92027d1cd2..59a0aa3247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -154,22 +156,49 @@ class BuzzRelayImportViewModel : ViewModel() { false } - // 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load. + // 2. Fetch each channel's NIP-29 metadata (39000-39003, `#d`-scoped) so its name + Buzz + // `t` type load, AND the relay's kind-40099 system messages (`#h`-scoped) so a + // `channel_deleted` one is seen. The relay soft-deletes a deleted channel's 39000 + // and never retracts the kind-44100 that seeded `channelIds`, so without pulling the + // 40099 a deleted channel — its metadata now blank — would show optimistically here + // forever. LocalCache records the delete into RelayGroupDeletions on consume. if (channelIds.isNotEmpty()) { account.client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))), + filters = + mapOf( + relay to + listOf( + Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())), + Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())), + ), + ), timeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } - // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived - // (type unknown) is optimistically shown as a workspace channel. + // 3. Keep only the non-DM workspace channels the relay still serves metadata for. + // + // A deleted (or never-really-there) channel keeps its kind-44100 — the relay never + // retracts it — but the relay soft-deletes its 39000, so it arrives here with NO + // metadata. That absence is the reliable "it's gone" signal (the relay does not serve + // a `channel_deleted` 40099 for an already-deleted channel, so the fetch above can't + // catch this case). Before, such a channel showed optimistically — as a bare UUID row + // with "No messages yet", which is exactly the leak reported. + // + // So drop channels with no metadata — but ONLY when the fetch clearly worked (at least + // one channel came back with a 39000). If none did, the read failed (auth/connectivity) + // and we keep the whole set rather than blank the community; a genuinely new channel + // whose 39000 is merely slow re-appears on the next bind once its metadata is cached. + val channels = channelIds.associateWith { LocalCache.getOrCreateRelayGroupChannel(GroupId(it, relay)) } + val fetchHadMetadata = channels.values.any { it.event != null } _channels.value = channelIds .mapNotNull { id -> val groupId = GroupId(id, relay) - val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null + val channel = channels.getValue(id) + if (fetchHadMetadata && channel.event == null) return@mapNotNull null if (channel.event?.isBuzzDm() == true) null else groupId }.sortedBy { it.id } _status.value = Status.Ready diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index dc6102138d..1ee06c0232 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -29,7 +29,10 @@ import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.horizontalDrag import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -47,20 +50,24 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState 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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight @@ -174,6 +181,12 @@ fun ChatBubbleLayout( var dragOffset by remember { mutableFloatStateOf(0f) } var settleJob by remember { mutableStateOf(null) } val swipeScope = rememberCoroutineScope() + // The caller passes a fresh onSwipeReply lambda every recomposition (it captures + // the note), so read it through updated-state and key pointerInput on the stable + // isLoggedInUser instead. Keying on the lambda would tear down and restart the + // detector on every recomposition — stranding an in-flight drag and churning the + // gesture coroutine on every idle row update. + val latestOnSwipeReply by rememberUpdatedState(onSwipeReply) val density = LocalDensity.current val swipeThresholdPx = remember(density) { with(density) { SwipeReplyThreshold.toPx() } } val swipeMaxPx = remember(density) { with(density) { SwipeReplyMaxDrag.toPx() } } @@ -187,13 +200,18 @@ fun ChatBubbleLayout( }, ) { if (onSwipeReply != null) { - val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f) - if (progress > 0f) { + // Gate composition on a boolean that flips only at the 0<->non-0 edges, so + // the icon is added/removed twice per gesture instead of recomposing every + // frame; the fade/scale reads dragOffset inside graphicsLayer (layer phase), + // so the whole swipe animates without recomposing ChatBubbleLayout. + val swiping by remember { derivedStateOf { dragOffset != 0f } } + if (swiping) { Box( modifier = Modifier .align(if (isLoggedInUser) Alignment.CenterEnd else Alignment.CenterStart) .graphicsLayer { + val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f) alpha = progress scaleX = 0.6f + 0.4f * progress scaleY = 0.6f + 0.4f * progress @@ -208,7 +226,7 @@ fun ChatBubbleLayout( if (onSwipeReply != null) { Modifier .graphicsLayer { translationX = dragOffset } - .pointerInput(onSwipeReply) { + .pointerInput(isLoggedInUser) { // Drag toward the screen center only; a haptic tick marks the // commit point, releasing past it fires the reply. var crossedThreshold = false @@ -220,20 +238,7 @@ fun ChatBubbleLayout( } } - detectHorizontalDragGestures( - onDragStart = { - settleJob?.cancel() - crossedThreshold = false - }, - onDragEnd = { - if (abs(dragOffset) >= swipeThresholdPx) { - onSwipeReply() - } - settleBack() - }, - onDragCancel = { settleBack() }, - ) { change, dragAmount -> - change.consume() + val applyDrag = { dragAmount: Float -> val newOffset = if (isLoggedInUser) { (dragOffset + dragAmount).coerceIn(-swipeMaxPx, 0f) @@ -246,6 +251,50 @@ fun ChatBubbleLayout( } dragOffset = newOffset } + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + + // Claim the pointer only once the motion is clearly more + // horizontal than vertical; a mostly-vertical drag leaves the + // pointer unconsumed so the enclosing scroll keeps it and the + // bubble never moves while you scroll. + var overSlop = Offset.Zero + val drag = + awaitTouchSlopOrCancellation(down.id) { change, over -> + if (abs(over.x) > abs(over.y)) { + change.consume() + overSlop = over + } + } + + if (drag != null) { + // Take over from any in-flight settle only now that we've + // committed to a horizontal drag. Cancelling earlier (on + // the down) would strand dragOffset when the gesture turns + // out to be a vertical scroll, since settleBack() below + // only runs on this path. + settleJob?.cancel() + crossedThreshold = false + applyDrag(overSlop.x) + try { + val completed = + horizontalDrag(drag.id) { change -> + applyDrag(change.positionChange().x) + change.consume() + } + if (completed && abs(dragOffset) >= swipeThresholdPx) { + latestOnSwipeReply?.invoke() + } + } finally { + // Settle even if the drag coroutine is cancelled (e.g. + // the bubble leaves composition mid-swipe); settleBack + // launches on swipeScope, not this pointer coroutine, + // so it still runs while that scope is alive. + settleBack() + } + } + } } } else { Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt new file mode 100644 index 0000000000..6240aca33a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel + * list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean + * tap-to-open target. Reads the live starred set so the label + icon reflect the current state. + */ +@Composable +fun BuzzPinDropdownItem( + groupId: GroupId, + closeMenu: () -> Unit, +) { + val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle() + val isStarred = groupId.id in starred + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, + onClick = { + closeMenu() + BuzzChannelStars.toggle(groupId.id) + }, + ) +} + +/** + * Add/Remove this relay-group [channel] from my kind-10009 list (whether it shows in Messages). A + * reversible toggle that never touches my relay membership — same split as "Leave" — so it stays + * readable either way. Reads the live kind-10009 list so it flips as the change lands. Moved off the + * per-channel list row into the opened screen's top-bar overflow. + */ +@Composable +fun RelayGroupMessagesDropdownItem( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + closeMenu: () -> Unit, +) { + val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds + .collectAsStateWithLifecycle() + val onMyList = channel.groupId in joinedGroupIds + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) }, + onClick = { + closeMenu() + if (onMyList) { + accountViewModel.removeRelayGroupFromMessages(channel) + } else { + accountViewModel.addRelayGroupToMessages(channel) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c4d62d47f9..7ff2e28dac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -36,8 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -73,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache @@ -172,6 +171,11 @@ fun RelayGroupChannelListScreen( } } + // Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group — + // but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so + // filter them out everywhere below. Collected as a StateFlow so a delete removes the row live. + val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle() + // Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`). // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if // NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while @@ -179,20 +183,22 @@ fun RelayGroupChannelListScreen( // its de-facto signer — so its relay-signed groups still show while a stray user-published 39000 // (a different author) stays filtered. val channels = - remember(allChannels, relayInfo) { + remember(allChannels, relayInfo, deletedChannels) { val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null - if (nip11Known) { - allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } - } else { - val dominantSigner = - allChannels - .mapNotNull { it.event?.pubKey } - .groupingBy { it } - .eachCount() - .maxByOrNull { it.value } - ?.key - if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels - } + val signed = + if (nip11Known) { + allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } + } else { + val dominantSigner = + allChannels + .mapNotNull { it.event?.pubKey } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels + } + signed.filterNot { it.groupId.toKey() in deletedChannels } } // Buzz relays expose no public group directory (membership is server-side), so `channels` above @@ -200,6 +206,15 @@ fun RelayGroupChannelListScreen( // (kind-44100) so browsing the relay lists the channels you already belong to — each addable to // your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse. val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true + + // A Buzz relay lets any community member create a channel/forum (the creator becomes its owner), + // and the relay rejects a non-member's kind-9007. We don't hard-gate the "+" on membership: the + // NIP-43 roster (kind 13534) isn't fetched on this screen, so gating on it hid the "+" even from + // admins. Instead we offer it on any Buzz community and let the relay enforce — the same approach + // as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them, + // the relay only serves the owner/admin ones." + val myPubkey = accountViewModel.account.signer.pubKey + val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}") LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) } val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle() @@ -226,9 +241,13 @@ fun RelayGroupChannelListScreen( // ids so nothing the old flat list showed disappears. val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } } val buzzGroupIds = - remember(buzzChannels, channels) { + remember(buzzChannels, channels, deletedChannels) { val seen = LinkedHashSet() - (buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) } + // `channels` is already delete-filtered; also drop deleted ids from the membership-scoped + // `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete. + (buzzChannels + channels.map { it.groupId }) + .filterNot { it.toKey() in deletedChannels } + .filter { seen.add(it.id) } } fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType() @@ -245,21 +264,34 @@ fun RelayGroupChannelListScreen( fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id + // Archived channels (relay-signed `archived` tag on the 39000) drop out of their normal section and + // gather in a collapsed "Archived" tail — the same hide-from-the-sidebar behavior the Buzz client + // has. They stay reachable there so an admin can open one and Unarchive it from the top bar. + fun isArchived(groupId: GroupId): Boolean = channelsById[groupId.id]?.isArchived() == true + val buzzChatChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds - .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } } + .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } && !isArchived(it) } .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } val buzzForumChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds - .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM } + .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM && !isArchived(it) } .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } + // Every archived non-DM channel (chat + forum together), newest section at the bottom. + val buzzArchivedChannels = + remember(buzzGroupIds, channelsById) { + buzzGroupIds + .filter { buzzTypeOf(it) != BUZZ_CHANNEL_TYPE_DM && isArchived(it) } + .sortedBy { buzzSortKey(it) } + } - // Which sections the user has collapsed (session-scoped). Keyed by section id below. - var collapsedSections by remember { mutableStateOf(emptySet()) } + // Which sections the user has collapsed (session-scoped). Keyed by section id below. Archived + // starts collapsed — it's the out-of-the-way tail, expanded only when someone goes looking. + var collapsedSections by remember { mutableStateOf(setOf("archived")) } fun toggleSection(key: String) { collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key @@ -326,17 +358,20 @@ fun RelayGroupChannelListScreen( } }, floatingActionButton = { - FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = - stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title), - modifier = Modifier.size(24.dp), - ) + // A Buzz community creates channels/forums from the per-section "+" in their labels (like + // Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no + // sections, so it keeps the FAB to create a group. + if (!isBuzz) { + FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_create_title), + modifier = Modifier.size(24.dp), + ) + } } }, ) { padding -> - val myPubkey = accountViewModel.userProfile().pubkeyHex // A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct // Messages, Agent Console) even before anything loads, rather than the generic "empty" text. if (channels.isEmpty() && !isBuzz) { @@ -366,9 +401,11 @@ fun RelayGroupChannelListScreen( // overlays content by design, so clearing it is the list's job. As contentPadding (not a // modifier) so rows scroll *through* that strip and only come to rest clear of it; the // modifier form would shrink the viewport and leave the FAB floating over dead space. + // Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section + // headers, so it needs no bottom clearance. LazyColumn( modifier = Modifier.padding(padding), - contentPadding = PaddingValues(bottom = FAB_CLEARANCE), + contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE), ) { if (showTorHint) { item(key = "tor-hint") { @@ -382,16 +419,15 @@ fun RelayGroupChannelListScreen( } if (isBuzz) { + // While the membership fetch is still running and nothing has loaded, show a + // "Loading…" line. The old "you're not a member — accept the invite in the browser" + // empty text is gone: the section labels below now each carry a "+" to create a + // channel/forum, so an empty community is a starting point, not a dead end. val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty() - if (noChannelsYet) { - item(key = "buzz-no-channels") { + if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) { + item(key = "buzz-loading") { Text( - text = - if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) { - stringRes(R.string.buzz_import_loading) - } else { - stringRes(R.string.buzz_import_empty_body) - }, + text = stringRes(R.string.buzz_import_loading), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(24.dp), @@ -399,57 +435,61 @@ fun RelayGroupChannelListScreen( } } - // -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu) - if (buzzChatChannels.isNotEmpty()) { + // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB + // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. + // The header always shows so the "+" is available even before any channel loads; + // the collapse toggle is offered only when there's something to collapse. + run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, - onToggle = { toggleSection("channels") }, - ) + onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } } - if (!channelsCollapsed) { + if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( groupId = groupId, - isAdded = groupId.id in buzzAdded, - onAdd = { buzzVm.add(groupId) }, - onRemove = { buzzVm.remove(groupId) }, accountViewModel = accountViewModel, onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) }, isStarred = groupId.id in starred, - onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, ) } } } - // -- FORUMS -- - if (buzzForumChannels.isNotEmpty()) { + // -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the + // create flow on a forum channel (threaded posts) instead of a chat one. + run { val forumsCollapsed = "forums" in collapsedSections item(key = "sec-forums") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, - onToggle = { toggleSection("forums") }, - ) + onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } } - if (!forumsCollapsed) { + if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( groupId = groupId, - isAdded = groupId.id in buzzAdded, - onAdd = { buzzVm.add(groupId) }, - onRemove = { buzzVm.remove(groupId) }, accountViewModel = accountViewModel, // A forum channel's primary content is its threads (kind-45001 posts), not a // kind-9 chat, so open the forum/threads view directly instead of the chat. onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) }, isStarred = groupId.id in starred, - onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, // Forum posts live in a separate thread store, not the chat notes the // activity preview reads — so don't warm a kind-9 sub that returns nothing. showActivityPreview = false, @@ -458,6 +498,38 @@ fun RelayGroupChannelListScreen( } } + // -- ARCHIVED -- Channels the relay has archived (chat + forum), tucked into a + // collapsed tail. Opening one and using the top-bar Unarchive brings it back. + if (buzzArchivedChannels.isNotEmpty()) { + val archivedCollapsed = "archived" in collapsedSections + item(key = "sec-archived") { + RelayGroupSectionHeader( + title = stringRes(R.string.relay_group_section_archived), + collapsed = archivedCollapsed, + onToggle = { toggleSection("archived") }, + ) + } + if (!archivedCollapsed) { + itemsIndexed(buzzArchivedChannels, key = { _, it -> "archived-${it.id}" }) { index, groupId -> + RowHairline(index) + val isForum = buzzTypeOf(groupId) == BUZZ_CHANNEL_TYPE_FORUM + BuzzImportRow( + groupId = groupId, + accountViewModel = accountViewModel, + onOpen = { + if (isForum) { + nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) + } else { + nav.nav(Route.RelayGroup(groupId.id, relay.url)) + } + }, + isStarred = groupId.id in starred, + showActivityPreview = !isForum, + ) + } + } + } + // -- DIRECT MESSAGES -- (this community's private conversations, most recent first) item(key = "sec-dms") { RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) { @@ -488,7 +560,6 @@ fun RelayGroupChannelListScreen( row = row, myPubkey = myPubkey, isHidden = false, - onToggleMessages = { dmVm.removeFromMessages(row) }, accountViewModel = accountViewModel, nav = nav, ) { @@ -520,7 +591,6 @@ fun RelayGroupChannelListScreen( row = row, myPubkey = myPubkey, isHidden = true, - onToggleMessages = { dmVm.addToMessages(row) }, accountViewModel = accountViewModel, nav = nav, ) { @@ -640,27 +710,44 @@ private fun RelayGroupSectionHeader( } } +/** + * The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's + * New-message icon: a primary-tinted Add glyph that creates a new item of that section's type. + */ +@Composable +private fun SectionAddButton( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } +} + /** * One inline Direct-Message conversation row inside the community view: the counterpart's avatar + - * name (or a "+N" cluster label for a group DM), a preview of the last message, a compact - * last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. The channel's - * recent content is warmed while the row is visible so the preview fills in ahead of a tap. Tapping - * opens the DM as its relay-group chat. + * name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact + * last-activity time. The channel's recent content is warmed while the row is visible so the preview + * fills in ahead of a tap. Tapping opens the DM as its relay-group chat; the Add/Remove-from-Messages + * (hide/unhide) action lives in that chat screen's top-bar overflow, not on this row. * - * [isHidden] renders the row faded and flips the overflow to "Add to Messages" — a hidden DM is a - * live conversation the viewer merely parked, so it stays openable and reversible. + * [isHidden] renders the row faded — a hidden DM is a live conversation the viewer merely parked, so + * it stays openable and reversible from the opened conversation. */ @Composable private fun BuzzDmInlineRow( row: BuzzDmListViewModel.DmRow, myPubkey: HexKey, isHidden: Boolean, - onToggleMessages: () -> Unit, accountViewModel: AccountViewModel, nav: INav, onClick: () -> Unit, ) { - var menuOpen by remember { mutableStateOf(false) } val others = row.others.ifEmpty { listOf(myPubkey) } val leadHex = others.first() val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) } @@ -689,7 +776,7 @@ private fun BuzzDmInlineRow( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp) + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 10.dp) .alpha(if (isHidden) 0.55f else 1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -718,33 +805,6 @@ private fun BuzzDmInlineRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Box { - IconButton(onClick = { menuOpen = true }) { - Icon( - symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.more_options), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( - leadingIcon = { - Icon( - symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) }, - onClick = { - menuOpen = false - onToggleMessages() - }, - ) - } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index c71641b669..b2528156c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -97,10 +97,15 @@ fun RelayGroupCreateScreen( relayUrl: String, accountViewModel: AccountViewModel, nav: INav, + // Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true). + isForum: Boolean = false, ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl") - LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) } + LaunchedEffect(relay) { + viewModel.initCreate(accountViewModel, relay) + if (isForum) viewModel.isForum = true + } // A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as // ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation @@ -197,8 +202,15 @@ private fun RelayGroupMetadataScaffold( Scaffold( topBar = { if (viewModel.isNewGroup) { + // The type is fixed by the caller (the community's per-section "+"), so the title names + // it — "New forum" vs "New channel" on Buzz — rather than offering a toggle to change it. CreatingTopBar( - titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title, + titleRes = + when { + !viewModel.isBuzzRelay -> R.string.relay_group_create_title + viewModel.isForum -> R.string.buzz_forum_create_title + else -> R.string.buzz_channel_create_title + }, isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) }, onCancel = nav::popBack, onPost = onSubmit, @@ -410,21 +422,11 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { viewModel.markTouched() } - if (viewModel.isBuzzRelay) { - // Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its - // 9002 handler has no `channel_type` key, so an existing channel cannot be converted. - if (viewModel.isNewGroup) { - LabeledSwitchRow( - label = stringRes(R.string.buzz_channel_flag_forum), - description = stringRes(R.string.buzz_channel_flag_forum_desc), - checked = viewModel.isForum, - ) { - viewModel.isForum = it - viewModel.markTouched() - } - } - return - } + // A Buzz channel's type (chat vs forum) is fixed by the caller — the community's per-section "+" — + // and isn't editable (the relay's 9002 has no `channel_type` key), so there's no toggle here. The + // remaining vanilla NIP-29 flags (invite-only / restricted below) don't apply to Buzz either, so + // stop here for a Buzz relay. + if (viewModel.isBuzzRelay) return LabeledSwitchRow( label = stringRes(R.string.relay_group_flag_invite_only), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index e015e48a7b..33354badb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -34,15 +34,20 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -166,6 +172,37 @@ private fun RelayGroupThreads( ) } }, + actions = { + // The forum's per-item actions, moved off the community-list row into this screen's + // top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle, plus the + // admin-only Archive/Unarchive (so an archived forum can be brought back from here). + // Buzz-only, which every forum channel is. + if (isBuzz) { + var menuOpen by remember { mutableStateOf(false) } + val isAdmin = channel.membershipOf(accountViewModel.userProfile().pubkeyHex) == RelayGroupMembership.ADMIN + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + BuzzPinDropdownItem(channel.groupId) { menuOpen = false } + RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false } + if (isAdmin) { + val archived = channel.isArchived() + DropdownMenuItem( + text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) }, + onClick = { + menuOpen = false + accountViewModel.archiveRelayGroup(channel, !archived) + }, + ) + } + } + } + }, popBack = nav::popBack, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 47a19bea25..4d725b781b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -117,6 +118,9 @@ fun RelayGroupTopBar( // My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below. val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds .collectAsStateWithLifecycle() + // A Buzz DM is hidden via its own per-viewer 30622 snapshot, not the kind-10009 list, so the + // Messages toggle branches on this for DMs. + val hiddenDms by BuzzDmRegistry.hidden.collectAsStateWithLifecycle() // Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu // callback — leaving a group shouldn't strand the user on the screen of a group they left. val canPop = nav.canPop() @@ -237,7 +241,9 @@ fun RelayGroupTopBar( // Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which // the relay's channel list already surfaces in their own section), so it read as a broken // feature on every chat. Demoted to the overflow, where the frequency of use actually is. - if (!isDm || naddr != null || showMembershipActions) { + // A Buzz DM always gets the overflow too — its hide/unhide (below) is the DM row's old + // action, and it must be reachable even where the membership actions aren't offered. + if (!isDm || naddr != null || showMembershipActions || (isDm && isBuzzRelay)) { IconButton(onClick = { menuOpen = true }) { Icon( symbol = MaterialSymbols.MoreVert, @@ -246,6 +252,33 @@ fun RelayGroupTopBar( ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // Pin/Unpin moved here off the community-list row. A local favorite, so it's offered + // for any Buzz channel/forum regardless of membership; DMs are never pinned. + if (isBuzzRelay && !isDm) { + BuzzPinDropdownItem(channel.groupId) { menuOpen = false } + } + // A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer + // 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is + // shown regardless of the membership gate below. + if (isBuzzRelay && isDm) { + val dmHidden = channel.groupId.id in (hiddenDms[myPubkey] ?: emptySet()) + DropdownMenuItem( + text = { Text(stringRes(if (dmHidden) R.string.add_to_messages else R.string.remove_from_messages)) }, + onClick = { + menuOpen = false + if (dmHidden) { + val participants = + channel.event + ?.buzzParticipants() + ?.filter { it != myPubkey } + .orEmpty() + accountViewModel.unhideBuzzDm(channel.groupId.relayUrl, participants.ifEmpty { listOf(myPubkey) }) + } else { + accountViewModel.hideBuzzDm(channel) + } + }, + ) + } if (!isDm) { DropdownMenuItem( text = { Text(stringRes(R.string.relay_group_threads_title)) }, @@ -308,6 +341,8 @@ fun RelayGroupTopBar( // Two distinct actions, never conflated: the Messages toggle adds/drops the group // on my kind-10009 list but keeps my relay membership either way; "Leave" sends // the kind-9022 that actually removes me. Same split as the channel-invite card. + // (A DM's Messages toggle is the hide/unhide item above — it rides a different + // mechanism and must show even when these membership actions don't.) // // Reads the live kind-10009 list rather than assuming the group is on it: this // bar also opens for channels reached from the workspace browse (a Buzz relay @@ -335,6 +370,19 @@ fun RelayGroupTopBar( if (canPop) nav.popBack() }, ) + // Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar, + // Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog. + // A DM is never archived (it has its own hide), so this is channels/forums only. + if (isBuzzRelay && !isDm && displayMembership == RelayGroupMembership.ADMIN) { + val archived = channel.isArchived() + DropdownMenuItem( + text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) }, + onClick = { + menuOpen = false + accountViewModel.archiveRelayGroup(channel, !archived) + }, + ) + } // Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's // shown ONLY to an admin/owner — the same authorization gate as Edit above — and // routed through a confirmation dialog rather than firing on tap. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 6a01d8dc07..aa557fe4da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -49,9 +49,15 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -77,6 +83,8 @@ class HomeNewThreadFeedFilter( LongTextNoteEvent.KIND, LiveChessGameEndEvent.KIND, AttestationEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, ) } @@ -153,6 +161,12 @@ class HomeNewThreadFeedFilter( noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || noteEvent is LiveChessGameEndEvent || + noteEvent is PictureEvent || + noteEvent is VideoNormalEvent || + noteEvent is VideoShortEvent || + noteEvent is VideoHorizontalEvent || + noteEvent is VideoVerticalEvent || + noteEvent is TorrentEvent || noteEvent is AttestationEvent || noteEvent is AttestationRequestEvent || noteEvent is AttestorRecommendationEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 2979f555f0..ad72b49bca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -39,12 +39,18 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -66,6 +72,11 @@ val HomePostsNewThreadKinds1 = WikiNoteEvent.KIND, AttestationEvent.KIND, NipTextEvent.KIND, + PictureEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, ) val HomePostsNewThreadKinds2 = @@ -76,6 +87,7 @@ val HomePostsNewThreadKinds2 = LiveChessGameEndEvent.KIND, BirdDetectionEvent.KIND, BirdexEvent.KIND, + TorrentEvent.KIND, ) val HomePostsConversationKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt index ecdfded7d0..0ec90ddbf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt @@ -131,11 +131,15 @@ private val HOME_FEED_TYPES = HomeFeedTypeUi(HomeFeedType.TEXT_NOTES, R.string.home_content_type_text_notes, MaterialSymbols.EditNote), HomeFeedTypeUi(HomeFeedType.REPOSTS, R.string.home_content_type_reposts, MaterialSymbols.Forward), HomeFeedTypeUi(HomeFeedType.COMMENTS, R.string.home_content_type_comments, MaterialSymbols.Chat), + HomeFeedTypeUi(HomeFeedType.PICTURES, R.string.home_content_type_pictures, MaterialSymbols.Image), + HomeFeedTypeUi(HomeFeedType.VIDEOS, R.string.home_content_type_videos, MaterialSymbols.Videocam), + HomeFeedTypeUi(HomeFeedType.SHORTS, R.string.home_content_type_shorts, MaterialSymbols.SmartDisplay), HomeFeedTypeUi(HomeFeedType.ARTICLES, R.string.home_content_type_articles, MaterialSymbols.AutoMirrored.Article), HomeFeedTypeUi(HomeFeedType.WIKI, R.string.home_content_type_wiki, MaterialSymbols.MenuBook), HomeFeedTypeUi(HomeFeedType.HIGHLIGHTS, R.string.home_content_type_highlights, MaterialSymbols.FormatQuote), HomeFeedTypeUi(HomeFeedType.POLLS, R.string.home_content_type_polls, MaterialSymbols.Poll), HomeFeedTypeUi(HomeFeedType.CLASSIFIEDS, R.string.home_content_type_classifieds, MaterialSymbols.Storefront), + HomeFeedTypeUi(HomeFeedType.TORRENTS, R.string.home_content_type_torrents, MaterialSymbols.Download), HomeFeedTypeUi(HomeFeedType.VOICE, R.string.home_content_type_voice, MaterialSymbols.Mic), HomeFeedTypeUi(HomeFeedType.LIVE_ACTIVITIES, R.string.home_content_type_live_activities, MaterialSymbols.Sensors), HomeFeedTypeUi(HomeFeedType.EPHEMERAL_CHAT, R.string.home_content_type_ephemeral_chat, MaterialSymbols.Forum), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 6e603ab36a..735e3a9175 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus @@ -512,7 +513,23 @@ fun RenderThreadFeed( if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null } - val onCollapse = remember(item) { { viewModel.toggleCollapsed(item.idHex) } } + // Tapping a reply collapses/expands its children, except for the user's own + // drafts: those open the edit-draft screen so the post can be resumed, matching + // the behavior everywhere else a draft is tapped. + val onClick = + remember(item) { + { + if (item.isDraft()) { + nav.nav { + withContext(Dispatchers.IO) { + routeEditDraftTo(item, accountViewModel.account) + } + } + } else { + viewModel.toggleCollapsed(item.idHex) + } + } + } NoteCompose( baseNote = item, @@ -523,7 +540,7 @@ fun RenderThreadFeed( parentBackgroundColor = background, accountViewModel = accountViewModel, nav = nav, - onClick = onCollapse, + onClick = onClick, ) } diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 6b087bfa27..9f2709970e 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -458,6 +458,8 @@ Obnovit Nový chatový profil: Opustit + Odebrat ze Zpráv + Přidat do Zpráv Přestat sledovat Kanál vytvořen "Informace kanálu změněna na" @@ -2216,6 +2218,30 @@ Směrovat přes Tor Sekce a kanály Viditelné karty + Obsah v kanálu + Textové poznámky + Sdílení + Komentáře a odpovědi + Obrázky + Videa + Krátká videa + Články + Wiki stránky + Zvýraznění + Ankety + Inzeráty + Torrenty + Hlasové zprávy + Živé aktivity + Dočasné chaty + Interaktivní příběhy + Šachové partie + Pozorování ptáků + Atestace + Návrhy NIP + Hudba a zvuk + Podcasty + Sbírky Připomenutí Připojení peněženky Jazyk @@ -2452,6 +2478,10 @@ Uložit Členové Upravit skupinu + Smazat skupinu + Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět. + Smazat kanál + Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět. Vlákna Připnout zprávu Odepnout zprávu @@ -2605,6 +2635,14 @@ Odeslat jako DM Odeslat… Nová zpráva + Nové zvýraznění + Nové zvýraznění + Zvýrazněný text + Co vás zaujalo? + URL zdroje + Vaše poznámka (volitelné) + Přidejte své myšlenky… + Zvýraznit Kopírovat URL do schránky Kopírovat ID poznámky do schránky Přidat média do galerie @@ -3392,6 +3430,12 @@ Připnout kanál Odepnout kanál Zatím žádné konverzace + + %1$d skrytá konverzace + %1$d skryté konverzace + %1$d skryté konverzace + %1$d skrytých konverzací + Zobrazit další %1$d konverzaci Zobrazit všechny %1$d konverzace @@ -3572,6 +3616,7 @@ Témata (oddělená čárkami) Uložit Git repozitáře + Zvýraznění Statický web: %1$s nApplet: %1$s Oprávnění: @@ -4653,6 +4698,7 @@ Kompatibilní s Bitchatem; zasáhne blízké uživatele na stejných relayích. Veřejné a dočasné — žádná historie, může to číst kdokoli v buňce. + Fronta úloh Běhy workflow Práce agenta Nový běh diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 82bae0b5ca..7f06367211 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -438,6 +438,8 @@ Aktualisieren Neues Chat-Profil: Verlassen + Aus Nachrichten entfernen + Zu Nachrichten hinzufügen Entfolgen Kanal erstellt "Kanalinformationen geändert in" @@ -2134,6 +2136,25 @@ Über Tor leiten Abschnitte & Feeds Sichtbare Tabs + Inhalte im Feed + Textnotizen + Kommentare & Antworten + Bilder + Kurzvideos + Artikel + Wiki-Seiten + Umfragen + Kleinanzeigen + Sprachnachrichten + Live-Aktivitäten + Ephemere Chats + Interaktive Geschichten + Schachpartien + Vogelbeobachtungen + Attestierungen + NIP-Entwürfe + Musik & Audio + Spendenaktionen Erinnerungen Wallet Verbindung Sprache @@ -2366,6 +2387,10 @@ Speichern Mitglieder Gruppe bearbeiten + Gruppe löschen + „%1$s“ löschen? Damit werden die Gruppe und ihr Verlauf für alle entfernt, und das lässt sich nicht rückgängig machen. + Kanal löschen + „%1$s“ löschen? Damit werden der Kanal und seine Nachrichten für alle entfernt, und das lässt sich nicht rückgängig machen. Threads Nachricht anheften Nachricht loslösen @@ -2509,6 +2534,14 @@ Als DM senden Senden an… Neue Nachricht + Neues Highlight + Neues Highlight + Hervorgehobener Text + Was ist dir aufgefallen? + Quell-URL + Deine Notiz (optional) + Füge deine Gedanken hinzu… + Hervorheben URL in die Zwischenablage kopieren Notiz-ID in die Zwischenablage kopieren Medien zur Galerie hinzufügen @@ -3276,6 +3309,10 @@ Kanal anheften Kanal nicht mehr anheften Noch keine Unterhaltungen + + %1$d ausgeblendete Unterhaltung + %1$d ausgeblendete Unterhaltungen + %1$d weitere Unterhaltung anzeigen Alle %1$d Unterhaltungen anzeigen @@ -4577,6 +4614,7 @@ System-Prompt Modell (optional) Anbieter (optional) + Laufzeitumgebung (optional) Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2f453bfef3..c2c6fa8353 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -438,6 +438,8 @@ Atualizar Novo perfil de chat: Sair + Remover das Mensagens + Adicionar às Mensagens Desseguir Canal criado "Informação do canal mudou para" @@ -2132,6 +2134,28 @@ Rotear pelo Tor Seções e feeds Abas visíveis + Conteúdo no feed + Notas de texto + Repostagens + Comentários e respostas + Imagens + Vídeos + Curtas + Artigos + Páginas wiki + Destaques + Enquetes + Classificados + Mensagens de voz + Atividades ao vivo + Chats efêmeros + Histórias interativas + Partidas de xadrez + Observações de aves + Atestações + Rascunhos de NIP + Música e áudio + Arrecadações Lembretes Conectar Carteira Linguagem @@ -2364,6 +2388,10 @@ Salvar Membros Editar grupo + Excluir grupo + Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito. + Excluir canal + Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito. Tópicos Fixar mensagem Desafixar mensagem @@ -2507,6 +2535,14 @@ Enviar como DM Enviar para… Nova mensagem + Novo destaque + Novo destaque + Texto destacado + O que chamou sua atenção? + URL de origem + Sua nota (opcional) + Adicione seus comentários… + Destacar Copiar URL para a área de transferência Copiar ID da nota para a área de transferência Adicionar Mídia à Galeria @@ -3274,6 +3310,10 @@ Fixar canal Desafixar canal Nenhuma conversa ainda + + %1$d conversa oculta + %1$d conversas ocultas + Ver mais %1$d conversa Ver todas as %1$d conversas @@ -3450,6 +3490,7 @@ Tópicos (separados por vírgula) Salvar Repositórios Git + Destaques Site Estático: %1$s nApplet: %1$s Permissões: diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e25860e071..6f5ee3cbd0 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -438,6 +438,8 @@ Uppdatera Ny chattprofil: Lämna + Ta bort från Meddelanden + Lägg till i Meddelanden Sluta följa Kanal skapad "Kanalinformation ändrad till" @@ -2132,6 +2134,30 @@ Dirigera via Tor Sektioner & flöden Synliga flikar + Innehåll i flödet + Textanteckningar + Återinlägg + Kommentarer & svar + Bilder + Videor + Kortfilmer + Artiklar + Wiki-sidor + Höjdpunkter + Omröstningar + Annonser + Torrenter + Röstmeddelanden + Liveaktiviteter + Tillfälliga chattar + Interaktiva berättelser + Schackpartier + Fågelobservationer + Attesteringar + NIP-utkast + Musik & ljud + Poddar + Insamlingar Påminnelser Plånboksanslut Språk @@ -2364,6 +2390,10 @@ Spara Medlemmar Redigera grupp + Radera grupp + Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras. + Radera kanal + Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras. Trådar Fäst meddelande Lossa meddelande @@ -2507,6 +2537,14 @@ Skicka som DM Skicka till… Nytt meddelande + Ny höjdpunkt + Ny höjdpunkt + Markerad text + Vad fastnade du för? + Käll-URL + Din anteckning (valfritt) + Lägg till dina tankar… + Markera Kopiera URL till urklipp Kopiera anteckningens ID till urklipp Lägg till media i galleriet @@ -3274,6 +3312,10 @@ Fäst kanal Lossa kanal Inga konversationer än + + %1$d dold konversation + %1$d dolda konversationer + Visa %1$d konversation till Visa alla %1$d konversationer @@ -3450,6 +3492,7 @@ Ämnen (kommaseparerade) Spara Git-repositories + Höjdpunkter Statisk webbplats: %1$s nApplet: %1$s Behörigheter: diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8cef251ec7..6228800866 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2334,11 +2334,15 @@ Text notes Reposts Comments & replies + Pictures + Videos + Shorts Articles Wiki pages Highlights Polls Classifieds + Torrents Voice messages Live activities Ephemeral chats @@ -2570,6 +2574,7 @@ Create a group New channel + New forum Private channel Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it. Forum channel @@ -2612,6 +2617,8 @@ Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone. Delete channel Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone. + Archive channel + Unarchive channel Threads Pin message Unpin message @@ -3603,6 +3610,7 @@ Channels Forums + Archived Working… Add member Add people to this workspace diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt index 9d17589fe9..0fe6373f4f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt @@ -21,6 +21,12 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -65,6 +71,24 @@ class HomeFeedTypeTest { assertNull(HomeFeedType.fromCode("future-kind")) } + @Test + fun picturesVideosShortsAndTorrentsOwnTheirKinds() { + assertEquals(listOf(PictureEvent.KIND), HomeFeedType.PICTURES.kinds) + // Long-form videos are the horizontal/normal kinds; vertical/short kinds belong to Shorts. + assertEquals(listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), HomeFeedType.VIDEOS.kinds) + assertEquals(listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), HomeFeedType.SHORTS.kinds) + assertEquals(listOf(TorrentEvent.KIND), HomeFeedType.TORRENTS.kinds) + } + + @Test + fun disablingVideosLeavesShortsUntouched() { + val disabled = HomeFeedType.disabledKinds(HomeFeedType.ALL - HomeFeedType.VIDEOS) + HomeFeedType.VIDEOS.kinds.forEach { assertTrue(it in disabled) } + // Shorts is a separate toggle, so its kinds stay live. + HomeFeedType.SHORTS.kinds.forEach { assertFalse(it in disabled) } + assertFalse(PictureEvent.KIND in disabled) + } + @Test fun disabledKindsEmptyWhenEverythingEnabled() { assertTrue(HomeFeedType.disabledKinds(HomeFeedType.ALL).isEmpty()) diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 0da6af4bdb..d92227363e 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -29,6 +29,17 @@ tasks.withType().configureEach { duplicatesStrategy = DuplicatesStrategy.INCLUDE } +// BuzzAgentWrapperSyncTest compares the bundled buzz-agent wrappers against their +// tools/ reference copies. The reference tree lives outside this module, so without +// declaring it Gradle calls :cli:test up-to-date after a tools/-only edit — exactly the +// drift the test exists to catch. +tasks.named("test") { + inputs + .dir(rootProject.layout.projectDirectory.dir("tools/buzz-agent")) + .withPropertyName("buzzAgentWrapperReference") + .withPathSensitivity(PathSensitivity.RELATIVE) +} + dependencies { implementation(project(":quartz")) implementation(project(":commons")) diff --git a/cli/packaging/homebrew/amy.rb b/cli/packaging/homebrew/amy.rb index ec89e2d0e3..3861c83a19 100644 --- a/cli/packaging/homebrew/amy.rb +++ b/cli/packaging/homebrew/amy.rb @@ -21,8 +21,8 @@ class Amy < Formula desc "Nostr client from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amy-1.12.6-jvm.tar.gz" - sha256 "209316d704a4622ddef1fd86b958b7619e9d049c20f3543dff60348ec73affd6" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.13.1/amy-1.13.1-jvm.tar.gz" + sha256 "5cdf5f30c52722a382de45b86919746a2fd733494294436963b6ec39a7220f45" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new 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 ab66424c51..43dcd861c0 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 @@ -46,7 +46,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -147,7 +150,7 @@ object BuzzCommands { ctx.prepare() val me = ctx.identity.pubKeyHex val relays = relaysFor(ctx, relaysFlag) - if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG) + if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag)) // The deployed Buzz relay does NOT emit kind-41001; instead it (a) confirms a DM's // channel id synchronously in the open OK, and (b) addresses each member a kind-44100 @@ -177,25 +180,9 @@ object BuzzCommands { .sortedByDescending { it.createdAt } .take(limit) .map { sys -> - // The relay's dm_created content carries a `participants` array our - // SystemMessagePayload model drops; read it from the raw content. - val participants = - runCatching { - jsonParser - .parseToJsonElement(sys.content) - .jsonObject["participants"] - ?.let { arr -> - arr - .toString() - .trim('[', ']') - .split(",") - .map { it.trim().trim('"') } - .filter { it.isNotBlank() } - } - }.getOrNull().orEmpty() mapOf( "dm_id" to sys.channel(), - "participants" to participants, + "participants" to participantsOf(sys.content), "created_at" to sys.createdAt, ) } @@ -518,7 +505,7 @@ object BuzzCommands { ctx.prepare() val me = ctx.identity.pubKeyHex val relays = relaysFor(ctx, relaysFlag) - if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG) + if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag)) val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me))) val decrypted = @@ -572,7 +559,7 @@ object BuzzCommands { ctx.prepare() val me = ctx.identity.pubKeyHex val relays = relaysFor(ctx, relaysFlag) - if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG) + if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag)) val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me)) val personas = @@ -600,8 +587,35 @@ object BuzzCommands { } } - /** Message for the `no_relays` error: neither `--relays` nor the account's outbox had one. */ - private const val NO_RELAYS_MSG = "no relays: pass --relays ws://…" + /** + * The relay's `dm_created` content carries a `participants` array our [SystemMessageEvent] + * payload model drops, so read it off the raw content. + * + * Decoded as JSON, not string-munged: anything that isn't a flat array of non-blank strings is + * dropped rather than stringified into a plausible-looking pubkey. A pubkey is hex so it can't + * itself contain a comma, but a nested object or a non-array `participants` used to come back + * as garbage entries the caller had no way to tell from real ones. + */ + internal fun participantsOf(content: String): List = + runCatching { + jsonParser + .parseToJsonElement(content) + .jsonObject["participants"] + ?.jsonArray + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) } + }.getOrNull().orEmpty() + + /** + * The `no_relays` detail. Passing `--relays` and having every entry rejected also lands here + * (the flag wins, so the outbox is never consulted), and telling that user to pass the flag + * they just passed is useless — name the real problem instead. + */ + private fun noRelaysMsg(relaysFlag: String?) = + if (relaysFlag == null) { + "no relays: pass --relays ws://…" + } else { + "no usable relays in --relays: expected comma-separated ws:// or wss:// urls, got '$relaysFlag'" + } /** The `--relays` set if given, else the account's outbox relays. */ private suspend fun relaysFor( diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzAgentWrapperSyncTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzAgentWrapperSyncTest.kt new file mode 100644 index 0000000000..44598405db --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzAgentWrapperSyncTest.kt @@ -0,0 +1,68 @@ +/* + * 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.cli + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The gated-runner wrappers exist twice: `cli/src/main/resources/buzz-agent/` is what + * `amy buzz agent up` extracts into `~/.amy/buzz-agent`, and `tools/buzz-agent/` is the + * readable reference `tools/buzz-agent/README.md` tells people to point `--exec` at. + * + * Nothing in the build copies one to the other, so a fix applied to only one half ships a + * runner that behaves differently from its own documentation. This pins them together. + */ +class BuzzAgentWrapperSyncTest { + @Test + fun bundledWrappersMatchTheToolsReference() { + val bundled = repoRoot.resolve("cli/src/main/resources/buzz-agent") + val reference = repoRoot.resolve("tools/buzz-agent") + + val scripts = + bundled + .listFiles() + ?.filter { it.isFile } + .orEmpty() + .sortedBy { it.name } + assertTrue(scripts.isNotEmpty(), "no bundled wrappers found in $bundled") + + scripts.forEach { script -> + val twin = reference.resolve(script.name) + assertTrue(twin.isFile, "${script.name} has no counterpart in tools/buzz-agent — add one") + assertEquals( + script.readText(), + twin.readText(), + "${script.name} drifted between cli/src/main/resources/buzz-agent and tools/buzz-agent — " + + "apply the change to both copies", + ) + } + } + + /** Walks up from the test's working directory to the checkout root (the dir holding both trees). */ + private val repoRoot: File + get() = + generateSequence(File(".").absoluteFile) { it.parentFile } + .firstOrNull { File(it, "tools/buzz-agent").isDirectory && File(it, "cli/src/main/resources/buzz-agent").isDirectory } + ?: error("could not locate the repo root from ${File(".").absolutePath}") +} diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzParticipantsTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzParticipantsTest.kt new file mode 100644 index 0000000000..59abf37963 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzParticipantsTest.kt @@ -0,0 +1,67 @@ +/* + * 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.cli + +import com.vitorpamplona.amethyst.cli.commands.BuzzCommands +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `buzz dm list` reads `participants` off the raw `dm_created` content. It used to do that by + * stringifying the element and splitting on commas, which turned every non-string shape into + * plausible-looking-but-fake pubkeys. These cases pin the decoded behaviour. + */ +class BuzzParticipantsTest { + private val a = "a".repeat(64) + private val b = "b".repeat(64) + + @Test + fun readsAFlatArrayOfStrings() { + assertEquals(listOf(a, b), BuzzCommands.participantsOf("""{"participants":["$a","$b"]}""")) + } + + @Test + fun emptyWhenAbsentOrEmptyOrUnparseable() { + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"type":"dm_created"}""")) + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":[]}""")) + assertEquals(emptyList(), BuzzCommands.participantsOf("not json at all")) + assertEquals(emptyList(), BuzzCommands.participantsOf("")) + } + + @Test + fun rejectsNonArrayShapesInsteadOfStringifyingThem() { + // The old split-on-comma parse yielded ["nope"] and ["x":1}] respectively. + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":"nope"}""")) + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":{"x":1}}""")) + } + + @Test + fun skipsNonStringElementsButKeepsTheRest() { + assertEquals(listOf(b), BuzzCommands.participantsOf("""{"participants":[{"nested":"$a"},"$b"]}""")) + assertEquals(listOf(a), BuzzCommands.participantsOf("""{"participants":["$a",null,""," "]}""")) + } + + @Test + fun doesNotSplitAStringThatContainsACommaIntoTwoEntries() { + // The regression the old `arr.toString().split(",")` parse produced. + assertEquals(listOf("$a,$b"), BuzzCommands.participantsOf("""{"participants":["$a,$b"]}""")) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 11c5bb23bd..9c029296f8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -178,6 +178,9 @@ class RelayGroupChannel( fun isPrivate(): Boolean = event?.isPrivate() ?: false + /** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */ + fun isArchived(): Boolean = event?.isArchived() ?: false + fun isRestricted(): Boolean = event?.isRestricted() ?: false fun isClosed(): Boolean = event?.isClosed() ?: false diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt new file mode 100644 index 0000000000..fc3df75e65 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The set of NIP-29 relay-group channels this device has **deleted** (kind-9008), keyed by + * [GroupId.toKey] (`id@relay`, so a group is scoped to its host relay — group ids are only + * unique per relay). + * + * A delete is terminal and destroys the group for everyone: the relay drops it and stops serving + * its kind-39000 metadata. But the client already holds that metadata in `LocalCache`, and a Buzz + * relay may keep re-announcing a stale kind-44100 member-added notification that would re-surface + * the channel in the community's browse list — after a restart too, since it's re-fetched from the + * relay. So the deletion has to be remembered client-side and the channel filtered out everywhere + * the list is built. + * + * Like [BuzzChannelStars], there is no personal Nostr event for "I deleted this from my view", so + * this is a process-wide singleton mirrored to a device-global store by the platform + * ([com.vitorpamplona.amethyst] `RelayGroupDeletionPreferences`) and restored at startup. Deleting + * is authoritative and terminal, so an entry is only ever added, never removed. + */ +object RelayGroupDeletions { + private val deleted = MutableStateFlow>(emptySet()) + + /** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */ + val flow: StateFlow> = deleted + + fun isDeleted(groupKey: String): Boolean = groupKey in deleted.value + + fun isDeleted(groupId: GroupId): Boolean = isDeleted(groupId.toKey()) + + /** Record [groupId] as deleted (idempotent). */ + fun markDeleted(groupId: GroupId) = markDeleted(groupId.toKey()) + + /** Record [groupKey] ([GroupId.toKey]) as deleted (idempotent). */ + fun markDeleted(groupKey: String) { + while (true) { + val current = deleted.value + if (groupKey in current) return + if (deleted.compareAndSet(current, current + groupKey)) return + } + } + + /** Replaces the whole set — used to restore from disk at startup. */ + fun restore(keys: Set) { + deleted.value = keys + } + + /** Test-only: clears the set so unit tests don't leak state into each other. */ + fun clearForTesting() { + deleted.value = emptySet() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt new file mode 100644 index 0000000000..6a60adc6e1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.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.commons.model.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The device-global "deleted channels" bookkeeping: a delete is relay-scoped (keyed by + * [GroupId.toKey]) and terminal (only ever added), so the same id on a different relay stays visible. + */ +class RelayGroupDeletionsTest { + private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com") + private val gid = "0123456789abcdef" + + @BeforeTest + fun reset() = RelayGroupDeletions.clearForTesting() + + @AfterTest + fun tearDown() = RelayGroupDeletions.clearForTesting() + + @Test + fun marksAChannelDeletedAndReflectsInTheFlow() { + val group = GroupId(gid, relayA) + assertFalse(RelayGroupDeletions.isDeleted(group)) + + RelayGroupDeletions.markDeleted(group) + + assertTrue(RelayGroupDeletions.isDeleted(group)) + assertTrue(RelayGroupDeletions.isDeleted(group.toKey())) + assertEquals(setOf(group.toKey()), RelayGroupDeletions.flow.value) + } + + @Test + fun deletionIsRelayScoped() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + // The same group id on a different host relay is a different group, so it stays visible. + assertTrue(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayB))) + } + + @Test + fun markingIsIdempotent() { + val group = GroupId(gid, relayA) + RelayGroupDeletions.markDeleted(group) + RelayGroupDeletions.markDeleted(group) + + assertEquals(1, RelayGroupDeletions.flow.value.size) + } + + @Test + fun restoreReplacesTheWholeSet() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + val restored = setOf(GroupId("aaaa", relayB).toKey(), GroupId("bbbb", relayB).toKey()) + RelayGroupDeletions.restore(restored) + + assertEquals(restored, RelayGroupDeletions.flow.value) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + } +} diff --git a/desktopApp/packaging/homebrew/amethyst-nostr.rb b/desktopApp/packaging/homebrew/amethyst-nostr.rb index c63eef6ec1..0e0ae12854 100644 --- a/desktopApp/packaging/homebrew/amethyst-nostr.rb +++ b/desktopApp/packaging/homebrew/amethyst-nostr.rb @@ -16,13 +16,12 @@ # https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amethyst-desktop-X.Y.Z-macos-arm64.dmg # shasum -a 256 amethyst.dmg cask "amethyst-nostr" do - version "1.12.6" - sha256 "69882e83ebcec6723e1ad5655ec2c9d1fa151b9d1a8ae51b869a9d62feabf093" + version "1.13.1" + sha256 "ead8f4f6263417d661b0d24be3853ec3284b330abc14748ebd77a2c2afcd2789" - url "https://github.com/vitorpamplona/amethyst/releases/download/v#{version}/amethyst-desktop-#{version}-macos-arm64.dmg", - verified: "github.com/vitorpamplona/amethyst/" + url "https://github.com/vitorpamplona/amethyst/releases/download/v#{version}/amethyst-desktop-#{version}-macos-arm64.dmg" name "Amethyst" - desc "Nostr client for desktop" + desc "Nostr client" homepage "https://github.com/vitorpamplona/amethyst" livecheck do @@ -30,9 +29,25 @@ cask "amethyst-nostr" do strategy :github_latest end + # The unrelated tiling window manager (cask `amethyst`, ianyh/Amethyst) also + # installs `Amethyst.app`, so the two cannot coexist in /Applications. + conflicts_with cask: "amethyst" depends_on arch: :arm64 + depends_on :macos app "Amethyst.app" - zap trash: "~/.amethyst" + # Verified against the source, not the docs: + # ~/.amethyst DesktopAccountStorage (accounts + keys) + # ~/Library/Application Support/Amethyst DesktopTorManager (tor/) + # ~/Library/Caches/AmethystDesktop Coil image cache + # + # Deliberately NOT zapped: ~/Library/Preferences/com.apple.java.util.prefs.plist. + # The app uses the Java Preferences API, which writes to that single SHARED + # plist — deleting it would wipe every other Java app's preferences too. + zap trash: [ + "~/.amethyst", + "~/Library/Application Support/Amethyst", + "~/Library/Caches/AmethystDesktop", + ] end diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml new file mode 100644 index 0000000000..97e894bc60 --- /dev/null +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml @@ -0,0 +1,32 @@ +# Reference Winget installer manifest for the Amethyst desktop app. +# +# InstallerUrl / InstallerSha256 / ProductCode are rewritten on each stable +# release by .github/workflows/bump-winget.yml, which downloads the published MSI, +# hashes it, and reads the ProductCode out of the MSI's Property table. +# +# InstallerType is `wix` because jpackage builds the MSI with the WiX Toolset. +# ProductCode matters: it is the ARP key winget uses to detect an existing +# install, so `winget upgrade` misbehaves without it. jpackage regenerates it per +# build, which is why CI re-reads it every release rather than pinning it here. +# (UpgradeCode is the stable one — see `upgradeUuid` in desktopApp/build.gradle.kts, +# which must never change.) +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json + +PackageIdentifier: VitorPamplona.Amethyst +PackageVersion: 1.13.1 +Platform: +- Windows.Desktop +MinimumOSVersion: 10.0.0.0 +InstallerType: wix +Scope: machine +UpgradeBehavior: install +Installers: +- Architecture: x64 + InstallerUrl: https://github.com/vitorpamplona/amethyst/releases/download/v1.13.1/amethyst-desktop-1.13.1-windows-x64.msi + # Quoted deliberately: an all-digit 64-char value is a YAML *integer*, which + # fails the schema's `string` type. Quoting makes it a string whatever the + # digest happens to be. + InstallerSha256: '6D93B945C14FBB08F0267266EF21212CCB64A40D870B7D9F68A815D674BABCC5' + ProductCode: '{CEE4E147-245F-3621-8CBD-CCA52C3578B6}' +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml new file mode 100644 index 0000000000..42a1bff5ff --- /dev/null +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml @@ -0,0 +1,36 @@ +# Reference Winget locale manifest for the Amethyst desktop app. +# +# Only PackageVersion and ReleaseNotesUrl are touched by +# .github/workflows/bump-winget.yml; everything else is hand-maintained. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json + +PackageIdentifier: VitorPamplona.Amethyst +PackageVersion: 1.13.1 +PackageLocale: en-US +Publisher: Vitor Pamplona +PublisherUrl: https://github.com/vitorpamplona +PublisherSupportUrl: https://github.com/vitorpamplona/amethyst/issues +Author: Vitor Pamplona +PackageName: Amethyst +PackageUrl: https://github.com/vitorpamplona/amethyst +License: MIT +LicenseUrl: https://github.com/vitorpamplona/amethyst/blob/main/LICENSE +Copyright: Copyright (c) Vitor Pamplona +CopyrightUrl: https://github.com/vitorpamplona/amethyst/blob/main/LICENSE +ShortDescription: The all-in-one Nostr client +Description: |- + A privacy-focused Nostr client. Built-in Tor support, the most configurable relay + system, encrypted messaging, zaps, marketplaces, live streams and complete data + sovereignty. +Moniker: amethyst +Tags: +- decentralized +- nostr +- social +- social-network +ReleaseNotesUrl: https://github.com/vitorpamplona/amethyst/releases/tag/v1.13.1 +Documentations: +- DocumentLabel: Building + DocumentUrl: https://github.com/vitorpamplona/amethyst/blob/main/BUILDING.md +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml new file mode 100644 index 0000000000..2e98e7de36 --- /dev/null +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml @@ -0,0 +1,15 @@ +# Reference Winget version manifest for the Amethyst desktop app. +# +# Not consumed by any build here. `scripts/bump-winget.sh` copies this directory +# into a fork of microsoft/winget-pkgs at +# manifests/v/VitorPamplona/Amethyst// and opens the PR. +# +# PackageVersion is kept in sync automatically on each stable release by +# .github/workflows/bump-winget.yml. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json + +PackageIdentifier: VitorPamplona.Amethyst +PackageVersion: 1.13.1 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index df73903688..0d09ee4b7d 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -78,16 +78,21 @@ "tag": "v1.13.1", "since": "2026-07-28T23:17:02-04:00", "translators": [ + { + "user": "vitorpamplona", + "languages": [ + "Czech", + "German", + "Portuguese, Brazilian", + "Swedish" + ] + }, { "user": "rajs19420616", "languages": [ "Hindi" ] }, - { - "user": "vitorpamplona", - "languages": [] - }, { "user": "greenart7c3", "languages": [] diff --git a/geode/packaging/homebrew/geode.rb b/geode/packaging/homebrew/geode.rb index bbb27ea6bc..1971dc9b69 100644 --- a/geode/packaging/homebrew/geode.rb +++ b/geode/packaging/homebrew/geode.rb @@ -24,8 +24,8 @@ 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" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.13.1/geode-1.13.1-jvm.tar.gz" + sha256 "9f6a73be83b2be7d183a035ae616da1a45ccad5b1b96e2c53636d549a3443d34" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index a05f2d7b9f..aeb4721570 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -57,6 +57,14 @@ class GroupMetadataEvent( fun picture() = tags.firstTagValue("picture") + /** + * Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar + * state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an + * `["archived","true"]` tag onto the 39000 for an archived channel and clients hide it; a plain + * NIP-29 relay has no such concept, so this is false there. + */ + fun isArchived() = tags.firstTagValue("archived") == "true" + /** * Topic hashtags (`t` tags) the relay advertises for this group, used by the * discovery feed's hashtag filter. NIP-29 doesn't define these; a group only diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index b503c830d3..61ccd41f3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -82,6 +82,12 @@ class EditMetadataEvent( parent: String? = null, children: List = emptyList(), previousEvents: List = emptyList(), + // Buzz-only channel-settings tags (a Buzz relay reads these on 9002; a plain NIP-29 relay + // ignores them). [visibility] is "open"/"private" — Buzz's own vocabulary, which it reads + // instead of the NIP-29 `private` status flag, so editing visibility on a Buzz channel needs + // this tag to take. [archived] toggles the channel's archived state ("true"/"false"). + visibility: String? = null, + archived: Boolean? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -90,6 +96,8 @@ class EditMetadataEvent( about?.let { add(arrayOf("about", it)) } picture?.let { add(arrayOf("picture", it)) } status.forEach { add(arrayOf(it.code)) } + visibility?.let { add(arrayOf("visibility", it)) } + archived?.let { add(arrayOf("archived", if (it) "true" else "false")) } addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt new file mode 100644 index 0000000000..080e477790 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt @@ -0,0 +1,79 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Buzz-only channel-settings tags on kind-9002 edit-metadata (`visibility`, `archived`) and the + * `archived` reflection on the relay-signed kind-39000. These ride the 9002 as tags — Buzz reads its + * own `visibility` vocabulary rather than the NIP-29 `private` status flag, and stamps `archived` onto + * the 39000 for an archived channel. + */ +class ChannelSettingsTagTest { + private val relaySelf = "aa".repeat(32) + private val sig = "bb".repeat(64) + private val id = "00".repeat(32) + private val gid = "0123456789abcdef" + + private fun tagValue( + tags: Array>, + name: String, + ): String? = tags.firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1) + + @Test + fun editEmitsVisibilityTagOnlyWhenSet() { + val priv = EditMetadataEvent.build(gid, visibility = "private") + assertEquals("private", tagValue(priv.tags, "visibility")) + + val open = EditMetadataEvent.build(gid, visibility = "open") + assertEquals("open", tagValue(open.tags, "visibility")) + + // Absent by default so an ordinary metadata edit doesn't reclassify visibility. + assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "visibility")) + } + + @Test + fun editEmitsArchivedTagAsTrueFalse() { + assertEquals("true", tagValue(EditMetadataEvent.build(gid, archived = true).tags, "archived")) + assertEquals("false", tagValue(EditMetadataEvent.build(gid, archived = false).tags, "archived")) + // Null archived means "don't touch it" — no tag emitted. + assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "archived")) + } + + @Test + fun metadataReflectsArchivedFlag() { + val archivedTemplate = GroupMetadataEvent.build(gid, name = gid) { add(arrayOf("archived", "true")) } + val archived = EventFactory.create(id, relaySelf, archivedTemplate.createdAt, GroupMetadataEvent.KIND, archivedTemplate.tags, "", sig) as GroupMetadataEvent + assertTrue(archived.isArchived()) + + val plainTemplate = GroupMetadataEvent.build(gid, name = gid) + val plain = EventFactory.create(id, relaySelf, plainTemplate.createdAt, GroupMetadataEvent.KIND, plainTemplate.tags, "", sig) as GroupMetadataEvent + assertFalse(plain.isArchived()) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt index 9647217a4b..1bad4685ba 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt @@ -24,12 +24,10 @@ import com.vitorpamplona.quic.frame.StreamFrame import com.vitorpamplona.quic.stream.StreamId import com.vitorpamplona.quic.tls.InProcessTlsServer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals @@ -123,8 +121,30 @@ class PeerUniStreamDrainTest { pipe.drive(maxRounds = 16) assertEquals(QuicConnection.Status.CONNECTED, client.status) - // Wire the explicit drainer BEFORE pushing the bytes. - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // Run the drainer on THIS runBlocking coroutine's own + // single-threaded event loop rather than the shared + // Dispatchers.Default pool. + // + // Why this matters for flakiness: the black-hole drainer only has + // to keep the 64-deep per-stream `incomingChannel` from filling. + // On Dispatchers.Default that is a race against the pool — when the + // rest of the JVM test suite saturates every Default worker thread, + // the drainer coroutine can be denied a scheduling slot long enough + // for the synchronous feed loop below to push more than 64 chunks + // ahead of it. That trips the audit-4 #3 slow-consumer teardown and + // the test fails under parallel load while passing in isolation — + // classic shared-pool starvation flakiness. + // + // Confining the drainer to the producer's event loop makes the + // hand-off deterministic: each cooperative `yield()` below parks the + // feed loop and lets the drainer empty the channel before more + // chunks are pushed, so the buffer never approaches its bound + // regardless of machine load. The drain contract under test (parser + // `trySend` -> `incomingChannel` -> collect -> discard) is exercised + // identically; only the scheduling is pinned. The private + // SupervisorJob keeps `scope.cancel()` in the finally block from + // ever touching the runBlocking job itself. + val scope = CoroutineScope(coroutineContext + SupervisorJob()) try { client.drainPeerInitiatedUniStreamsIntoBlackHole(scope) @@ -142,16 +162,16 @@ class PeerUniStreamDrainTest { offset += chunk.size.toLong() val packet = pipe.buildServerApplicationDatagram(listOf(frame))!! feedDatagram(client, packet, nowMillis = 0L) - // Yield occasionally so the drainer coroutine actually - // gets a chance to consume — feedDatagram is synchronous - // and the drainer launched with Dispatchers.Default needs - // a scheduling tick. - if (i % 16 == 15) { - withTimeout(2_000) { delay(1) } - } + // Hand the confined drainer a turn every few chunks so it + // drains the buffer well before it can reach the 64-chunk + // bound. feedDatagram is synchronous; yield() on the shared + // event loop deterministically runs the drainer's queued + // channel-receive continuation before the producer resumes. + if (i % 16 == 15) yield() } - // Ensure the drainer has caught up before we sample status. - withTimeout(2_000) { delay(50) } + // Let the drainer consume anything still buffered before we + // sample status. + yield() assertEquals( QuicConnection.Status.CONNECTED, diff --git a/scripts/bump-homebrew-cask.sh b/scripts/bump-homebrew-cask.sh new file mode 100755 index 0000000000..d0f68a4b42 --- /dev/null +++ b/scripts/bump-homebrew-cask.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# Push the amethyst-nostr cask upstream to Homebrew/homebrew-cask. +# +# This is the ONE release step that is deliberately not automated. `brew +# bump-cask-pr` forks homebrew-cask into the token owner's account, which needs +# a CLASSIC PAT with the `repo` scope — and that scope grants write to every +# repository the account can reach. As a CI secret it would be usable by anyone +# with push access to this repo; in your shell it is not. See BUILDING.md +# § Homebrew cask. +# +# Everything error-prone (version, sha256, notarization check) is already done +# in CI by .github/workflows/bump-homebrew.yml, which opens a PR syncing +# desktopApp/packaging/homebrew/amethyst-nostr.rb. Merge that PR first; this +# script reads the merged values so the two can never disagree. +# +# Usage: +# scripts/bump-homebrew-cask.sh # uses the cask file as-is +# scripts/bump-homebrew-cask.sh v1.13.2 # asserts the cask matches this tag +# DRY_RUN=1 scripts/bump-homebrew-cask.sh # print what would happen, do nothing +# +# Requires: macOS, brew, and HOMEBREW_GITHUB_API_TOKEN exported (classic PAT, +# `repo` scope). Create one at: +# https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump + +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +CASK="$REPO_ROOT/desktopApp/packaging/homebrew/amethyst-nostr.rb" +EXPECTED_TAG="${1:-}" +DRY_RUN="${DRY_RUN:-}" + +die() { echo "error: $*" >&2; exit 1; } + +[[ "$(uname -s)" == "Darwin" ]] || die "casks are macOS-only; run this on a Mac" +command -v brew >/dev/null || die "brew not found" +[[ -f "$CASK" ]] || die "cask not found at $CASK" + +VERSION=$(grep -E '^ version "' "$CASK" | sed -E 's/.*"(.*)".*/\1/') +SHA=$(grep -E '^ sha256 "' "$CASK" | sed -E 's/.*"(.*)".*/\1/') +[[ -n "$VERSION" && -n "$SHA" ]] || die "could not parse version/sha256 out of $CASK" + +if [[ -n "$EXPECTED_TAG" && "v$VERSION" != "$EXPECTED_TAG" ]]; then + die "cask is at v$VERSION but you asked for $EXPECTED_TAG. +Merge the 'chore: sync amethyst-nostr cask to $EXPECTED_TAG' PR first, then pull." +fi + +URL="https://github.com/vitorpamplona/amethyst/releases/download/v${VERSION}/amethyst-desktop-${VERSION}-macos-arm64.dmg" + +echo "cask : amethyst-nostr" +echo "version : $VERSION" +echo "sha256 : $SHA" +echo "url : $URL" +echo + +# Re-verify against the live asset. CI already checked this, but the whole point +# of a manual gate is that a human confirms what is about to be advertised to +# every macOS user. +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +echo "==> downloading and verifying the release DMG" +curl -fsSL -o "$TMP/amethyst.dmg" "$URL" || die "could not download $URL" + +ACTUAL_SHA=$(shasum -a 256 "$TMP/amethyst.dmg" | awk '{print $1}') +[[ "$ACTUAL_SHA" == "$SHA" ]] || die "sha256 mismatch! + cask says : $SHA + actual : $ACTUAL_SHA" +echo " sha256 matches" + +xcrun stapler validate "$TMP/amethyst.dmg" >/dev/null 2>&1 \ + || die "the DMG has NO stapled notarization ticket. +Users would hit a Gatekeeper block. Do not submit this to homebrew-cask. +Check the notarizeReleaseDmg step in .github/workflows/create-release.yml." +echo " notarization ticket stapled" +echo + +if [[ -n "$DRY_RUN" ]]; then + echo "DRY_RUN set — would now run:" + echo " brew bump-cask-pr amethyst-nostr --version $VERSION --sha256 $SHA --url $URL" + exit 0 +fi + +[[ -n "${HOMEBREW_GITHUB_API_TOKEN:-}" ]] || die "HOMEBREW_GITHUB_API_TOKEN is not set. +Create a CLASSIC PAT with the 'repo' scope: + https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump +then: export HOMEBREW_GITHUB_API_TOKEN=ghp_... +Prefer a dedicated bot account — 'repo' reaches every repo that account can see." + +if ! brew info --cask amethyst-nostr >/dev/null 2>&1; then + cat >&2 < opening the homebrew-cask PR" +brew bump-cask-pr amethyst-nostr --version "$VERSION" --sha256 "$SHA" --url "$URL" --no-browse +echo +echo "Done. Watch the PR at https://github.com/Homebrew/homebrew-cask/pulls" diff --git a/scripts/bump-winget.sh b/scripts/bump-winget.sh new file mode 100755 index 0000000000..2bed5a3a12 --- /dev/null +++ b/scripts/bump-winget.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Push the VitorPamplona.Amethyst manifests upstream to microsoft/winget-pkgs. +# +# Like scripts/bump-homebrew-cask.sh, this is deliberately not automated: it +# needs push access to a fork of winget-pkgs, and the previous design kept that +# as a classic `public_repo` PAT in the WINGET_TOKEN Actions secret, handed to a +# third-party action. That scope grants write to every public repo the account +# can reach, and any Actions secret is usable by anyone with push access to this +# repo. See BUILDING.md § Winget. +# +# Unlike the Homebrew script this needs NO new token: it drives `gh`, which you +# are already authenticated with. It also does not need `wingetcreate` (which is +# Windows-only) — winget manifests are plain YAML, so this works from macOS or +# Linux. +# +# Everything error-prone (version, sha256, ProductCode) is already done in CI by +# .github/workflows/bump-winget.yml, which opens a PR syncing +# desktopApp/packaging/winget/. Merge that PR first; this script reads the merged +# values so the two can never disagree. +# +# Usage: +# scripts/bump-winget.sh # uses the manifests as-is +# scripts/bump-winget.sh v1.13.2 # asserts the manifests match this tag +# DRY_RUN=1 scripts/bump-winget.sh # print what would happen, do nothing + +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SRC="$REPO_ROOT/desktopApp/packaging/winget" +PKG_ID="VitorPamplona.Amethyst" +UPSTREAM="microsoft/winget-pkgs" +EXPECTED_TAG="${1:-}" +DRY_RUN="${DRY_RUN:-}" + +die() { echo "error: $*" >&2; exit 1; } + +command -v gh >/dev/null || die "gh not found — https://cli.github.com" +gh auth status >/dev/null 2>&1 || die "gh is not authenticated; run: gh auth login" +[[ -d "$SRC" ]] || die "manifests not found at $SRC" + +VERSION=$(awk '/^PackageVersion: /{print $2; exit}' "$SRC/$PKG_ID.yaml") +[[ -n "$VERSION" ]] || die "could not parse PackageVersion out of $SRC/$PKG_ID.yaml" + +if [[ -n "$EXPECTED_TAG" && "v$VERSION" != "$EXPECTED_TAG" ]]; then + die "manifests are at v$VERSION but you asked for $EXPECTED_TAG. +Merge the 'chore: sync winget manifests to $EXPECTED_TAG' PR first, then pull." +fi + +# InstallerSha256 and ProductCode are single-quoted in the YAML (see the +# comments there); strip the quotes when reading them back. +SHA=$(awk '/^ InstallerSha256: /{print $2; exit}' "$SRC/$PKG_ID.installer.yaml" | tr -d "'") +URL=$(awk '/^ InstallerUrl: /{print $2; exit}' "$SRC/$PKG_ID.installer.yaml") +PC=$(awk '/^ ProductCode: /{print $2; exit}' "$SRC/$PKG_ID.installer.yaml" | tr -d "'") + +[[ "$SHA" =~ ^0+$ ]] && die "InstallerSha256 is still the placeholder. +Run the sync workflow first: gh workflow run bump-winget.yml -f tag=v$VERSION" + +echo "package : $PKG_ID" +echo "version : $VERSION" +echo "sha256 : $SHA" +echo "product : $PC" +echo "url : $URL" +echo + +# Re-verify against the live asset. CI already did, but the whole point of a +# manual gate is that a human confirms what is about to be published. +echo "==> verifying the release MSI" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +curl -fsSL -o "$TMP/amethyst.msi" "$URL" || die "could not download $URL" + +if command -v sha256sum >/dev/null; then + ACTUAL=$(sha256sum "$TMP/amethyst.msi" | awk '{print $1}') +else + ACTUAL=$(shasum -a 256 "$TMP/amethyst.msi" | awk '{print $1}') +fi +ACTUAL=$(echo "$ACTUAL" | tr '[:lower:]' '[:upper:]') +[[ "$ACTUAL" == "$SHA" ]] || die "sha256 mismatch! + manifest says : $SHA + actual : $ACTUAL" +echo " sha256 matches" +echo + +DEST="manifests/v/VitorPamplona/Amethyst/$VERSION" +BRANCH="$PKG_ID-$VERSION" + +if [[ -n "$DRY_RUN" ]]; then + echo "DRY_RUN set — would now:" + echo " fork $UPSTREAM (if needed), branch $BRANCH" + echo " add $DEST/{$PKG_ID.yaml,$PKG_ID.installer.yaml,$PKG_ID.locale.en-US.yaml}" + echo " open a PR against $UPSTREAM" + exit 0 +fi + +# A new package needs a human-reviewed first submission; after that this script +# handles every version bump. +if ! gh api "repos/$UPSTREAM/contents/manifests/v/VitorPamplona/Amethyst" >/dev/null 2>&1; then + echo "note: $PKG_ID is not in $UPSTREAM yet — this will be the NEW-PACKAGE submission." + echo " Expect a slower, human-reviewed first pass." + echo +fi + +WORK="$TMP/winget-pkgs" +echo "==> preparing the fork" +gh repo fork "$UPSTREAM" --clone=false --remote=false >/dev/null 2>&1 || true +FORK="$(gh api user --jq .login)/winget-pkgs" + +# Shallow clone of the fork only — winget-pkgs full history is enormous. +gh repo clone "$FORK" "$WORK" -- --depth=1 >/dev/null 2>&1 \ + || die "could not clone $FORK" + +cd "$WORK" +git remote add upstream "https://github.com/$UPSTREAM.git" 2>/dev/null || true +git fetch --depth=1 upstream master >/dev/null 2>&1 || git fetch --depth=1 upstream main >/dev/null 2>&1 +DEFAULT_BRANCH=$(gh api "repos/$UPSTREAM" --jq .default_branch) +git checkout -B "$BRANCH" "upstream/$DEFAULT_BRANCH" >/dev/null 2>&1 + +mkdir -p "$DEST" +cp "$SRC/$PKG_ID.yaml" "$SRC/$PKG_ID.installer.yaml" "$SRC/$PKG_ID.locale.en-US.yaml" "$DEST/" + +# The reference copies carry an Amethyst-repo header comment; upstream manifests +# should not. Strip leading comment lines, keep the yaml-language-server line. +for f in "$DEST"/*.yaml; do + python3 - "$f" <<'PY' +import sys +p = sys.argv[1] +lines = open(p).read().splitlines(keepends=True) +out, started = [], False +for ln in lines: + if not started and ln.startswith('#') and 'yaml-language-server' not in ln: + continue + started = True + out.append(ln) +open(p, 'w').writelines(out) +PY +done + +git add "$DEST" +git -c user.name="$(gh api user --jq '.name // .login')" \ + -c user.email="$(gh api user --jq .id)+$(gh api user --jq .login)@users.noreply.github.com" \ + commit -q -m "$PKG_ID version $VERSION" +git push -q -u origin "$BRANCH" --force + +echo "==> opening the PR" +gh pr create --repo "$UPSTREAM" \ + --base "$DEFAULT_BRANCH" \ + --head "$(gh api user --jq .login):$BRANCH" \ + --title "$PKG_ID version $VERSION" \ + --body "Automated manifest sync for [Amethyst](https://github.com/vitorpamplona/amethyst) $VERSION. + +- Installer: \`amethyst-desktop-$VERSION-windows-x64.msi\` from the official GitHub release +- SHA256 verified against the published asset +- ProductCode read from the MSI Property table + +Manifests are generated from \`desktopApp/packaging/winget/\` in the upstream repo." + +echo +echo "Done. Watch the PR at https://github.com/$UPSTREAM/pulls" diff --git a/tools/buzz-agent/README.md b/tools/buzz-agent/README.md index 305c98912a..0379a566bb 100644 --- a/tools/buzz-agent/README.md +++ b/tools/buzz-agent/README.md @@ -112,6 +112,19 @@ empty-worktree failure mode, and `base_branch` resolution when `gh` answers oddl non-zero) — the paths that otherwise only fail on someone else's machine, unattended, via a kind-46007 whose stderr is the only clue. +### `./test-amy-args.sh` — the `amy` argument surface + +```bash +./gradlew :cli:installDist +./tools/buzz-agent/test-amy-args.sh +AMY=/path/to/amy ./tools/buzz-agent/test-amy-args.sh # or point at another build +``` + +Pins the flag names and error strings the wrappers and their operators key on — `--base-ref`, the +`repo-naddr-or-coordinates` positional, `pass --channel GID`, and the `no_relays` detail. A renamed +flag or reworded message breaks callers without breaking any compile, and none of it is covered by a +type. Runs offline against an isolated `~/.amy` (every case is rejected before a relay is dialled). + ### `agent-exec.sh` Verified end-to-end against a throwaway repo with a stubbed `gh` + agent diff --git a/tools/buzz-agent/test-amy-args.sh b/tools/buzz-agent/test-amy-args.sh new file mode 100755 index 0000000000..1e4f1dbdf8 --- /dev/null +++ b/tools/buzz-agent/test-amy-args.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# test-amy-args.sh — pins the argument-validation surface of the `amy` verbs the +# buzz-agent path drives (`buzz agent`, `buzz workflow`, `buzz dm`, `git`). +# +# These are the strings a human or a wrapper script keys on: a flag name silently +# renamed, or a usage/error message reworded, breaks callers without breaking any +# compile. Everything here runs offline against an isolated ~/.amy — the failing +# cases are all rejected before a relay is contacted. +# +# ./gradlew :cli:installDist +# ./tools/buzz-agent/test-amy-args.sh +# AMY=/path/to/amy ./tools/buzz-agent/test-amy-args.sh # or point at another build +# +# Exits 0 when every case passes, 1 otherwise. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +AMY="${AMY:-$ROOT/cli/build/install/amy/bin/amy}" +if [[ ! -x "$AMY" ]]; then + echo "no amy binary at $AMY — build one with: ./gradlew :cli:installDist" >&2 + echo "(or set AMY=/path/to/amy)" >&2 + exit 2 +fi + +# Isolate the data dir: `amy.home` is the seam the JVM tests use too, so this never +# touches a real ~/.amy. +AMY_HOME="$(mktemp -d)" +trap 'rm -rf "$AMY_HOME"' EXIT +export JAVA_OPTS="-Damy.home=$AMY_HOME" + +pass=0 +fail=0 + +expect() { # name, expected-substring, amy args... + local name="$1" want="$2" + shift 2 + local out + out="$("$AMY" "$@" 2>&1)" + if [[ "$out" == *"$want"* ]]; then + printf ' PASS %s\n' "$name" + pass=$((pass + 1)) + else + printf ' FAIL %s\n want output containing %q\n got: %s\n' \ + "$name" "$want" "$(echo "$out" | head -3 | tr '\n' ' ')" + fail=$((fail + 1)) + fi +} + +reject() { # name, unwanted-substring, amy args... + local name="$1" unwanted="$2" + shift 2 + local out + out="$("$AMY" "$@" 2>&1)" + if [[ "$out" != *"$unwanted"* ]]; then + printf ' PASS %s\n' "$name" + pass=$((pass + 1)) + else + printf ' FAIL %s\n output must NOT contain %q\n got: %s\n' \ + "$name" "$unwanted" "$(echo "$out" | head -3 | tr '\n' ' ')" + fail=$((fail + 1)) + fi +} + +printf '\namy arg-surface harness: %s\n\n' "$AMY" + +"$AMY" --account harness init > /dev/null 2>&1 || { echo "could not create a throwaway account" >&2; exit 1; } +A=(--account harness) +DEAD_RELAY=wss://127.0.0.1:1 # closed port: connects and fails immediately + +# --- the positional name every `git` verb shares --------------------------- +for verb in browse cat log; do + expect "git $verb names its missing positional" "repo-naddr-or-coordinates" git "$verb" +done + +# --- required flags on the workflow verbs ---------------------------------- +expect "workflow trigger demands a channel" "pass --channel GID" \ + "${A[@]}" buzz workflow trigger "$DEAD_RELAY" wf1 --task "do a thing" +expect "workflow list demands a channel" "pass --channel GID" \ + "${A[@]}" buzz workflow list "$DEAD_RELAY" + +# --- the --base-ref flag name ---------------------------------------------- +# `agent up` checks --repo before parsing the rest, so point it at a real repo to +# reach rejectUnknown. +expect "agent up rejects a mistyped --base-ref" "unknown flag: --base-reff" \ + "${A[@]}" buzz agent up "$DEAD_RELAY" --repo "$ROOT" --approver npub1qqq --base-reff x --timeout 1 +reject "agent up accepts --base-ref" "unknown flag" \ + "${A[@]}" buzz agent up "$DEAD_RELAY" --repo "$ROOT" --approver npub1qqq --base-ref x --timeout 1 +expect "agent serve rejects a mistyped --base-ref" "unknown flag: --base-reff" \ + "${A[@]}" buzz agent serve "$DEAD_RELAY" --exec true --base-reff x +reject "agent serve accepts --base-ref" "unknown flag" \ + "${A[@]}" buzz agent serve "$DEAD_RELAY" --exec true --base-ref x --once --timeout 1 + +# --- the no_relays detail --------------------------------------------------- +# Passing --relays wins over the outbox, so an all-unparseable list yields an empty +# set and must NOT tell the user to pass the flag they just passed. Note a bare word +# like "garbage" is *accepted* (the normalizer prepends wss://) — a pasted http url +# is the realistic rejection. +for bad in "http://x" "ws://" "," "!!!"; do + expect "buzz dm list names the real problem for --relays '$bad'" \ + "no usable relays in --relays" "${A[@]}" buzz dm list --relays "$bad" --timeout 2 +done +reject "buzz dm list accepts a well-formed relay" "no_relays" \ + "${A[@]}" buzz dm list --relays "$DEAD_RELAY" --timeout 2 + +printf '\n %d passed, %d failed\n\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]]