Merge remote-tracking branch 'origin/main' into claude/push-notification-icons-branding-dy4egx

This commit is contained in:
Claude
2026-07-29 18:06:34 +00:00
57 changed files with 2577 additions and 463 deletions
@@ -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"
+43 -31
View File
@@ -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=<url> --sha256=<sha>` with a
# HOMEBREW_TOKEN). It is intentionally omitted until then because
# bump-formula-pr errors on a formula that is not yet in the tap.
# 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=<url> --sha256=<sha>` 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,
@@ -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=<url> --sha256=<sha>` with a HOMEBREW_TOKEN). It is intentionally
# omitted until then because bump-formula-pr errors on a formula that is not
# yet in the tap.
# 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=<url> --sha256=<sha>` 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,
+138 -32
View File
@@ -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']
});
+171 -29
View File
@@ -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']
});
+38 -2
View File
@@ -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.
+109 -20
View File
@@ -325,15 +325,29 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~2530 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-<version>.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
<https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump>.
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/<version>/`, 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`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
| macOS | `/Applications/Amethyst.app` | `~/.amethyst` (accounts + keys)<br>`~/Library/Application Support/Amethyst` (Tor)<br>`~/Library/Caches/AmethystDesktop` (image cache)<br>`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) |
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.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`
+94 -23
View File
@@ -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", "\"<new-event-id-hex>\"")
```
@@ -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 (~2530 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:<version>` 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:<version>` 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:<version>` 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:<version>` 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.
@@ -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()
@@ -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<String> = 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))
@@ -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)),
@@ -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.
@@ -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<String>) {
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")
}
}
@@ -790,6 +790,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
relayUrl = it.relayUrl,
isForum = it.isForum,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -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(
@@ -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)
}
@@ -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<HexKey>,
) = 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.
@@ -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<String>,
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(
@@ -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
@@ -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<Job?>(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
@@ -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)
}
},
)
}
@@ -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<String>()
(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<GroupId> { 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<GroupId> { 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<String>()) }
// 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()
},
)
}
}
}
}
@@ -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),
@@ -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,
)
},
@@ -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.
@@ -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 ||
@@ -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 =
@@ -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),
@@ -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,
)
}
@@ -458,6 +458,8 @@
<string name="refresh">Obnovit</string>
<string name="changed_chat_profile_to">Nový chatový profil:</string>
<string name="leave">Opustit</string>
<string name="remove_from_messages">Odebrat ze Zpráv</string>
<string name="add_to_messages">Přidat do Zpráv</string>
<string name="unfollow">Přestat sledovat</string>
<string name="channel_created">Kanál vytvořen</string>
<string name="channel_information_changed_to">"Informace kanálu změněna na"</string>
@@ -2216,6 +2218,30 @@
<string name="settings_section_tor_routing">Směrovat přes Tor</string>
<string name="settings_section_profile_sections">Sekce a kanály</string>
<string name="settings_section_home_tabs">Viditelné karty</string>
<string name="settings_section_home_content_types">Obsah v kanálu</string>
<string name="home_content_type_text_notes">Textové poznámky</string>
<string name="home_content_type_reposts">Sdílení</string>
<string name="home_content_type_comments">Komentáře a odpovědi</string>
<string name="home_content_type_pictures">Obrázky</string>
<string name="home_content_type_videos">Videa</string>
<string name="home_content_type_shorts">Krátká videa</string>
<string name="home_content_type_articles">Články</string>
<string name="home_content_type_wiki">Wiki stránky</string>
<string name="home_content_type_highlights">Zvýraznění</string>
<string name="home_content_type_polls">Ankety</string>
<string name="home_content_type_classifieds">Inzeráty</string>
<string name="home_content_type_torrents">Torrenty</string>
<string name="home_content_type_voice">Hlasové zprávy</string>
<string name="home_content_type_live_activities">Živé aktivity</string>
<string name="home_content_type_ephemeral_chat">Dočasné chaty</string>
<string name="home_content_type_interactive_stories">Interaktivní příběhy</string>
<string name="home_content_type_chess">Šachové partie</string>
<string name="home_content_type_birds">Pozorování ptáků</string>
<string name="home_content_type_attestations">Atestace</string>
<string name="home_content_type_nips">Návrhy NIP</string>
<string name="home_content_type_music">Hudba a zvuk</string>
<string name="home_content_type_podcasts">Podcasty</string>
<string name="home_content_type_fundraisers">Sbírky</string>
<string name="settings_section_reminders">Připomenutí</string>
<string name="wallet_connect">Připojení peněženky</string>
<string name="language">Jazyk</string>
@@ -2452,6 +2478,10 @@
<string name="relay_group_edit_confirm">Uložit</string>
<string name="relay_group_menu_members">Členové</string>
<string name="relay_group_menu_edit">Upravit skupinu</string>
<string name="relay_group_delete">Smazat skupinu</string>
<string name="relay_group_delete_confirm">Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět.</string>
<string name="buzz_channel_delete">Smazat kanál</string>
<string name="buzz_channel_delete_confirm">Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět.</string>
<string name="relay_group_threads_title">Vlákna</string>
<string name="relay_group_pin_message">Připnout zprávu</string>
<string name="relay_group_unpin_message">Odepnout zprávu</string>
@@ -2605,6 +2635,14 @@
<string name="share_target_as_dm">Odeslat jako DM</string>
<string name="share_to_dm_title">Odeslat…</string>
<string name="share_to_dm_start_new">Nová zpráva</string>
<string name="share_target_as_highlight">Nové zvýraznění</string>
<string name="new_highlight_title">Nové zvýraznění</string>
<string name="new_highlight_passage_label">Zvýrazněný text</string>
<string name="new_highlight_passage_placeholder">Co vás zaujalo?</string>
<string name="new_highlight_source_label">URL zdroje</string>
<string name="new_highlight_note_label">Vaše poznámka (volitelné)</string>
<string name="new_highlight_note_placeholder">Přidejte své myšlenky…</string>
<string name="highlight_action">Zvýraznit</string>
<string name="copy_url_to_clipboard">Kopírovat URL do schránky</string>
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
<string name="add_media_to_gallery">Přidat média do galerie</string>
@@ -3392,6 +3430,12 @@
<string name="buzz_pin">Připnout kanál</string>
<string name="buzz_unpin">Odepnout kanál</string>
<string name="buzz_dm_section_empty">Zatím žádné konverzace</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d skrytá konverzace</item>
<item quantity="few">%1$d skryté konverzace</item>
<item quantity="many">%1$d skryté konverzace</item>
<item quantity="other">%1$d skrytých konverzací</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">Zobrazit další %1$d konverzaci</item>
<item quantity="few">Zobrazit všechny %1$d konverzace</item>
@@ -3572,6 +3616,7 @@
<string name="git_repo_settings_topics">Témata (oddělená čárkami)</string>
<string name="git_repo_settings_save">Uložit</string>
<string name="git_repositories">Git repozitáře</string>
<string name="highlights">Zvýraznění</string>
<string name="nsite_title">Statický web: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Oprávnění:</string>
@@ -4653,6 +4698,7 @@
<string name="new_conversation_location_pro_2">Kompatibilní s Bitchatem; zasáhne blízké uživatele na stejných relayích.</string>
<string name="new_conversation_location_con_1">Veřejné a dočasné — žádná historie, může to číst kdokoli v buňce.</string>
<!-- Buzz workflow run board -->
<string name="buzz_job_board_title">Fronta úloh</string>
<string name="buzz_workflow_runs_title">Běhy workflow</string>
<string name="buzz_agent_work_title">Práce agenta</string>
<string name="buzz_workflow_new_run">Nový běh</string>
@@ -438,6 +438,8 @@
<string name="refresh">Aktualisieren</string>
<string name="changed_chat_profile_to">Neues Chat-Profil:</string>
<string name="leave">Verlassen</string>
<string name="remove_from_messages">Aus Nachrichten entfernen</string>
<string name="add_to_messages">Zu Nachrichten hinzufügen</string>
<string name="unfollow">Entfolgen</string>
<string name="channel_created">Kanal erstellt</string>
<string name="channel_information_changed_to">"Kanalinformationen geändert in"</string>
@@ -2134,6 +2136,25 @@
<string name="settings_section_tor_routing">Über Tor leiten</string>
<string name="settings_section_profile_sections">Abschnitte &amp; Feeds</string>
<string name="settings_section_home_tabs">Sichtbare Tabs</string>
<string name="settings_section_home_content_types">Inhalte im Feed</string>
<string name="home_content_type_text_notes">Textnotizen</string>
<string name="home_content_type_comments">Kommentare &amp; Antworten</string>
<string name="home_content_type_pictures">Bilder</string>
<string name="home_content_type_shorts">Kurzvideos</string>
<string name="home_content_type_articles">Artikel</string>
<string name="home_content_type_wiki">Wiki-Seiten</string>
<string name="home_content_type_polls">Umfragen</string>
<string name="home_content_type_classifieds">Kleinanzeigen</string>
<string name="home_content_type_voice">Sprachnachrichten</string>
<string name="home_content_type_live_activities">Live-Aktivitäten</string>
<string name="home_content_type_ephemeral_chat">Ephemere Chats</string>
<string name="home_content_type_interactive_stories">Interaktive Geschichten</string>
<string name="home_content_type_chess">Schachpartien</string>
<string name="home_content_type_birds">Vogelbeobachtungen</string>
<string name="home_content_type_attestations">Attestierungen</string>
<string name="home_content_type_nips">NIP-Entwürfe</string>
<string name="home_content_type_music">Musik &amp; Audio</string>
<string name="home_content_type_fundraisers">Spendenaktionen</string>
<string name="settings_section_reminders">Erinnerungen</string>
<string name="wallet_connect">Wallet Verbindung</string>
<string name="language">Sprache</string>
@@ -2366,6 +2387,10 @@
<string name="relay_group_edit_confirm">Speichern</string>
<string name="relay_group_menu_members">Mitglieder</string>
<string name="relay_group_menu_edit">Gruppe bearbeiten</string>
<string name="relay_group_delete">Gruppe löschen</string>
<string name="relay_group_delete_confirm">„%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.</string>
<string name="buzz_channel_delete">Kanal löschen</string>
<string name="buzz_channel_delete_confirm">„%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.</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Nachricht anheften</string>
<string name="relay_group_unpin_message">Nachricht loslösen</string>
@@ -2509,6 +2534,14 @@
<string name="share_target_as_dm">Als DM senden</string>
<string name="share_to_dm_title">Senden an…</string>
<string name="share_to_dm_start_new">Neue Nachricht</string>
<string name="share_target_as_highlight">Neues Highlight</string>
<string name="new_highlight_title">Neues Highlight</string>
<string name="new_highlight_passage_label">Hervorgehobener Text</string>
<string name="new_highlight_passage_placeholder">Was ist dir aufgefallen?</string>
<string name="new_highlight_source_label">Quell-URL</string>
<string name="new_highlight_note_label">Deine Notiz (optional)</string>
<string name="new_highlight_note_placeholder">Füge deine Gedanken hinzu…</string>
<string name="highlight_action">Hervorheben</string>
<string name="copy_url_to_clipboard">URL in die Zwischenablage kopieren</string>
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
<string name="add_media_to_gallery">Medien zur Galerie hinzufügen</string>
@@ -3276,6 +3309,10 @@
<string name="buzz_pin">Kanal anheften</string>
<string name="buzz_unpin">Kanal nicht mehr anheften</string>
<string name="buzz_dm_section_empty">Noch keine Unterhaltungen</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d ausgeblendete Unterhaltung</item>
<item quantity="other">%1$d ausgeblendete Unterhaltungen</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">%1$d weitere Unterhaltung anzeigen</item>
<item quantity="other">Alle %1$d Unterhaltungen anzeigen</item>
@@ -4577,6 +4614,7 @@
<string name="buzz_persona_system_prompt">System-Prompt</string>
<string name="buzz_persona_model">Modell (optional)</string>
<string name="buzz_persona_provider">Anbieter (optional)</string>
<string name="buzz_persona_runtime">Laufzeitumgebung (optional)</string>
<string name="buzz_persona_avatar">Avatar-URL (optional)</string>
<string name="buzz_persona_publishing">Wird veröffentlicht…</string>
<string name="buzz_persona_publish">Persona veröffentlichen</string>
@@ -438,6 +438,8 @@
<string name="refresh">Atualizar</string>
<string name="changed_chat_profile_to">Novo perfil de chat:</string>
<string name="leave">Sair</string>
<string name="remove_from_messages">Remover das Mensagens</string>
<string name="add_to_messages">Adicionar às Mensagens</string>
<string name="unfollow">Desseguir</string>
<string name="channel_created">Canal criado</string>
<string name="channel_information_changed_to">"Informação do canal mudou para"</string>
@@ -2132,6 +2134,28 @@
<string name="settings_section_tor_routing">Rotear pelo Tor</string>
<string name="settings_section_profile_sections">Seções e feeds</string>
<string name="settings_section_home_tabs">Abas visíveis</string>
<string name="settings_section_home_content_types">Conteúdo no feed</string>
<string name="home_content_type_text_notes">Notas de texto</string>
<string name="home_content_type_reposts">Repostagens</string>
<string name="home_content_type_comments">Comentários e respostas</string>
<string name="home_content_type_pictures">Imagens</string>
<string name="home_content_type_videos">Vídeos</string>
<string name="home_content_type_shorts">Curtas</string>
<string name="home_content_type_articles">Artigos</string>
<string name="home_content_type_wiki">Páginas wiki</string>
<string name="home_content_type_highlights">Destaques</string>
<string name="home_content_type_polls">Enquetes</string>
<string name="home_content_type_classifieds">Classificados</string>
<string name="home_content_type_voice">Mensagens de voz</string>
<string name="home_content_type_live_activities">Atividades ao vivo</string>
<string name="home_content_type_ephemeral_chat">Chats efêmeros</string>
<string name="home_content_type_interactive_stories">Histórias interativas</string>
<string name="home_content_type_chess">Partidas de xadrez</string>
<string name="home_content_type_birds">Observações de aves</string>
<string name="home_content_type_attestations">Atestações</string>
<string name="home_content_type_nips">Rascunhos de NIP</string>
<string name="home_content_type_music">Música e áudio</string>
<string name="home_content_type_fundraisers">Arrecadações</string>
<string name="settings_section_reminders">Lembretes</string>
<string name="wallet_connect">Conectar Carteira</string>
<string name="language">Linguagem</string>
@@ -2364,6 +2388,10 @@
<string name="relay_group_edit_confirm">Salvar</string>
<string name="relay_group_menu_members">Membros</string>
<string name="relay_group_menu_edit">Editar grupo</string>
<string name="relay_group_delete">Excluir grupo</string>
<string name="relay_group_delete_confirm">Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito.</string>
<string name="buzz_channel_delete">Excluir canal</string>
<string name="buzz_channel_delete_confirm">Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito.</string>
<string name="relay_group_threads_title">Tópicos</string>
<string name="relay_group_pin_message">Fixar mensagem</string>
<string name="relay_group_unpin_message">Desafixar mensagem</string>
@@ -2507,6 +2535,14 @@
<string name="share_target_as_dm">Enviar como DM</string>
<string name="share_to_dm_title">Enviar para…</string>
<string name="share_to_dm_start_new">Nova mensagem</string>
<string name="share_target_as_highlight">Novo destaque</string>
<string name="new_highlight_title">Novo destaque</string>
<string name="new_highlight_passage_label">Texto destacado</string>
<string name="new_highlight_passage_placeholder">O que chamou sua atenção?</string>
<string name="new_highlight_source_label">URL de origem</string>
<string name="new_highlight_note_label">Sua nota (opcional)</string>
<string name="new_highlight_note_placeholder">Adicione seus comentários…</string>
<string name="highlight_action">Destacar</string>
<string name="copy_url_to_clipboard">Copiar URL para a área de transferência</string>
<string name="copy_the_note_id_to_the_clipboard">Copiar ID da nota para a área de transferência</string>
<string name="add_media_to_gallery">Adicionar Mídia à Galeria</string>
@@ -3274,6 +3310,10 @@
<string name="buzz_pin">Fixar canal</string>
<string name="buzz_unpin">Desafixar canal</string>
<string name="buzz_dm_section_empty">Nenhuma conversa ainda</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d conversa oculta</item>
<item quantity="other">%1$d conversas ocultas</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">Ver mais %1$d conversa</item>
<item quantity="other">Ver todas as %1$d conversas</item>
@@ -3450,6 +3490,7 @@
<string name="git_repo_settings_topics">Tópicos (separados por vírgula)</string>
<string name="git_repo_settings_save">Salvar</string>
<string name="git_repositories">Repositórios Git</string>
<string name="highlights">Destaques</string>
<string name="nsite_title">Site Estático: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Permissões:</string>
@@ -438,6 +438,8 @@
<string name="refresh">Uppdatera</string>
<string name="changed_chat_profile_to">Ny chattprofil:</string>
<string name="leave">Lämna</string>
<string name="remove_from_messages">Ta bort från Meddelanden</string>
<string name="add_to_messages">Lägg till i Meddelanden</string>
<string name="unfollow">Sluta följa</string>
<string name="channel_created">Kanal skapad</string>
<string name="channel_information_changed_to">"Kanalinformation ändrad till"</string>
@@ -2132,6 +2134,30 @@
<string name="settings_section_tor_routing">Dirigera via Tor</string>
<string name="settings_section_profile_sections">Sektioner &amp; flöden</string>
<string name="settings_section_home_tabs">Synliga flikar</string>
<string name="settings_section_home_content_types">Innehåll i flödet</string>
<string name="home_content_type_text_notes">Textanteckningar</string>
<string name="home_content_type_reposts">Återinlägg</string>
<string name="home_content_type_comments">Kommentarer &amp; svar</string>
<string name="home_content_type_pictures">Bilder</string>
<string name="home_content_type_videos">Videor</string>
<string name="home_content_type_shorts">Kortfilmer</string>
<string name="home_content_type_articles">Artiklar</string>
<string name="home_content_type_wiki">Wiki-sidor</string>
<string name="home_content_type_highlights">Höjdpunkter</string>
<string name="home_content_type_polls">Omröstningar</string>
<string name="home_content_type_classifieds">Annonser</string>
<string name="home_content_type_torrents">Torrenter</string>
<string name="home_content_type_voice">Röstmeddelanden</string>
<string name="home_content_type_live_activities">Liveaktiviteter</string>
<string name="home_content_type_ephemeral_chat">Tillfälliga chattar</string>
<string name="home_content_type_interactive_stories">Interaktiva berättelser</string>
<string name="home_content_type_chess">Schackpartier</string>
<string name="home_content_type_birds">Fågelobservationer</string>
<string name="home_content_type_attestations">Attesteringar</string>
<string name="home_content_type_nips">NIP-utkast</string>
<string name="home_content_type_music">Musik &amp; ljud</string>
<string name="home_content_type_podcasts">Poddar</string>
<string name="home_content_type_fundraisers">Insamlingar</string>
<string name="settings_section_reminders">Påminnelser</string>
<string name="wallet_connect">Plånboksanslut</string>
<string name="language">Språk</string>
@@ -2364,6 +2390,10 @@
<string name="relay_group_edit_confirm">Spara</string>
<string name="relay_group_menu_members">Medlemmar</string>
<string name="relay_group_menu_edit">Redigera grupp</string>
<string name="relay_group_delete">Radera grupp</string>
<string name="relay_group_delete_confirm">Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras.</string>
<string name="buzz_channel_delete">Radera kanal</string>
<string name="buzz_channel_delete_confirm">Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras.</string>
<string name="relay_group_threads_title">Trådar</string>
<string name="relay_group_pin_message">Fäst meddelande</string>
<string name="relay_group_unpin_message">Lossa meddelande</string>
@@ -2507,6 +2537,14 @@
<string name="share_target_as_dm">Skicka som DM</string>
<string name="share_to_dm_title">Skicka till…</string>
<string name="share_to_dm_start_new">Nytt meddelande</string>
<string name="share_target_as_highlight">Ny höjdpunkt</string>
<string name="new_highlight_title">Ny höjdpunkt</string>
<string name="new_highlight_passage_label">Markerad text</string>
<string name="new_highlight_passage_placeholder">Vad fastnade du för?</string>
<string name="new_highlight_source_label">Käll-URL</string>
<string name="new_highlight_note_label">Din anteckning (valfritt)</string>
<string name="new_highlight_note_placeholder">Lägg till dina tankar…</string>
<string name="highlight_action">Markera</string>
<string name="copy_url_to_clipboard">Kopiera URL till urklipp</string>
<string name="copy_the_note_id_to_the_clipboard">Kopiera anteckningens ID till urklipp</string>
<string name="add_media_to_gallery">Lägg till media i galleriet</string>
@@ -3274,6 +3312,10 @@
<string name="buzz_pin">Fäst kanal</string>
<string name="buzz_unpin">Lossa kanal</string>
<string name="buzz_dm_section_empty">Inga konversationer än</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d dold konversation</item>
<item quantity="other">%1$d dolda konversationer</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">Visa %1$d konversation till</item>
<item quantity="other">Visa alla %1$d konversationer</item>
@@ -3450,6 +3492,7 @@
<string name="git_repo_settings_topics">Ämnen (kommaseparerade)</string>
<string name="git_repo_settings_save">Spara</string>
<string name="git_repositories">Git-repositories</string>
<string name="highlights">Höjdpunkter</string>
<string name="nsite_title">Statisk webbplats: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Behörigheter:</string>
+8
View File
@@ -2334,11 +2334,15 @@
<string name="home_content_type_text_notes">Text notes</string>
<string name="home_content_type_reposts">Reposts</string>
<string name="home_content_type_comments">Comments &amp; replies</string>
<string name="home_content_type_pictures">Pictures</string>
<string name="home_content_type_videos">Videos</string>
<string name="home_content_type_shorts">Shorts</string>
<string name="home_content_type_articles">Articles</string>
<string name="home_content_type_wiki">Wiki pages</string>
<string name="home_content_type_highlights">Highlights</string>
<string name="home_content_type_polls">Polls</string>
<string name="home_content_type_classifieds">Classifieds</string>
<string name="home_content_type_torrents">Torrents</string>
<string name="home_content_type_voice">Voice messages</string>
<string name="home_content_type_live_activities">Live activities</string>
<string name="home_content_type_ephemeral_chat">Ephemeral chats</string>
@@ -2570,6 +2574,7 @@
<string name="relay_group_create_title">Create a group</string>
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
<string name="buzz_channel_create_title">New channel</string>
<string name="buzz_forum_create_title">New forum</string>
<string name="buzz_channel_flag_private">Private channel</string>
<string name="buzz_channel_flag_private_desc">Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it.</string>
<string name="buzz_channel_flag_forum">Forum channel</string>
@@ -2612,6 +2617,8 @@
<string name="relay_group_delete_confirm">Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone.</string>
<string name="buzz_channel_delete">Delete channel</string>
<string name="buzz_channel_delete_confirm">Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone.</string>
<string name="buzz_channel_archive">Archive channel</string>
<string name="buzz_channel_unarchive">Unarchive channel</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Pin message</string>
<string name="relay_group_unpin_message">Unpin message</string>
@@ -3603,6 +3610,7 @@
<string name="relay_group_section_channels">Channels</string>
<string name="relay_group_section_forums">Forums</string>
<string name="relay_group_section_archived">Archived</string>
<string name="buzz_agent_working">Working…</string>
<string name="relay_group_add_member">Add member</string>
<string name="buzz_community_add_people">Add people to this workspace</string>
@@ -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())
+11
View File
@@ -29,6 +29,17 @@ tasks.withType<ProcessResources>().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>("test") {
inputs
.dir(rootProject.layout.projectDirectory.dir("tools/buzz-agent"))
.withPropertyName("buzzAgentWrapperReference")
.withPathSensitivity(PathSensitivity.RELATIVE)
}
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
+2 -2
View File
@@ -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
@@ -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<String> =
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(
@@ -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}")
}
@@ -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"]}"""))
}
}
@@ -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
@@ -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<Set<String>>(emptySet())
/** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */
val flow: StateFlow<Set<String>> = 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<String>) {
deleted.value = keys
}
/** Test-only: clears the set so unit tests don't leak state into each other. */
fun clearForTesting() {
deleted.value = emptySet()
}
}
@@ -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)))
}
}
@@ -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
@@ -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
@@ -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
@@ -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/<version>/ 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
+9 -4
View File
@@ -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": []
+2 -2
View File
@@ -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
@@ -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
@@ -82,6 +82,12 @@ class EditMetadataEvent(
parent: String? = null,
children: List<String> = emptyList(),
previousEvents: List<String> = 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<EditMetadataEvent>.() -> 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()) }
@@ -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<Array<String>>,
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())
}
}
@@ -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,
+104
View File
@@ -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 <<EOF
error: cask 'amethyst-nostr' does not exist in homebrew-cask yet.
\`brew bump-cask-pr\` can only bump an EXISTING cask. The first submission is a
manual, human-reviewed new-cask PR. See BUILDING.md § Homebrew cask
(one-time initial PR) for that flow; come back to this script for every
release after it merges.
EOF
exit 1
fi
echo "==> 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"
+159
View File
@@ -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"
+13
View File
@@ -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
+108
View File
@@ -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 ]]