mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
Merge remote-tracking branch 'origin/main' into claude/push-notification-icons-branding-dy4egx
This commit is contained in:
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
|
||||
@@ -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
@@ -325,15 +325,29 @@ Quartz library in one pipeline.
|
||||
|
||||
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
|
||||
4. **Verify**:
|
||||
- GH Release contains 8 desktop assets + 12 Android assets
|
||||
4. **Verify** — the GH Release should hold **31 assets**:
|
||||
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
|
||||
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
|
||||
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
|
||||
configured, so macOS ships arm64-only.
|
||||
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
|
||||
F-Droid `.apks` set built for Accrescent.
|
||||
- **5 amy** + **5 geode** bundles.
|
||||
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
|
||||
- Intel + ARM DMGs both present
|
||||
- Android flow unchanged
|
||||
|
||||
Quick diff against the previous release, which catches a silently-dropped
|
||||
matrix leg better than any count:
|
||||
|
||||
```bash
|
||||
diff <(gh release view v1.13.0 --json assets --jq '.assets[].name' | sed 's/1\.13\.0/VER/g' | sort) \
|
||||
<(gh release view v1.13.1 --json assets --jq '.assets[].name' | sed 's/1\.13\.1/VER/g' | sort)
|
||||
```
|
||||
|
||||
5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
|
||||
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
|
||||
Homebrew + Winget bump workflows.
|
||||
or `-snapshot` is auto-classified as prerelease. Only stable tags run the
|
||||
Homebrew + Winget bump workflows (and those are no-ops until the one-time
|
||||
bootstrap PRs land — see § Bootstrap).
|
||||
|
||||
### Dry-run (no tag push)
|
||||
|
||||
@@ -389,8 +403,8 @@ provided automatically; everything else you set yourself.)
|
||||
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
|
||||
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
|
||||
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
|
||||
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
|
||||
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
|
||||
| ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — |
|
||||
| ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — |
|
||||
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
|
||||
|
||||
Note the **three distinct signing identities** people often conflate:
|
||||
@@ -473,11 +487,11 @@ distributes; the official Amethyst rollout for each is in
|
||||
| Channel | How it ships | Push or pull |
|
||||
|---|---|---|
|
||||
| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
|
||||
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) |
|
||||
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | Automatic (CI) |
|
||||
| **Google Play** | Download the signed `amethyst-googleplay-<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
@@ -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 (~25–30 min) the GH Release
|
||||
holds, per the asset-name contract:
|
||||
holds **31 assets**, per the asset-name contract:
|
||||
|
||||
- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs
|
||||
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`)
|
||||
- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz)
|
||||
- **CLI:** the `amy` artifacts
|
||||
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<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.
|
||||
|
||||
+77
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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.
|
||||
|
||||
+4
-90
@@ -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(
|
||||
|
||||
+34
-5
@@ -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
|
||||
|
||||
+67
-18
@@ -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
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+154
-94
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-17
@@ -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),
|
||||
|
||||
+37
@@ -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,
|
||||
)
|
||||
},
|
||||
|
||||
+49
-1
@@ -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.
|
||||
|
||||
+14
@@ -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 ||
|
||||
|
||||
+12
@@ -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 =
|
||||
|
||||
+4
@@ -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),
|
||||
|
||||
+19
-2
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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 & 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())
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"]}"""))
|
||||
}
|
||||
}
|
||||
+3
@@ -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
|
||||
|
||||
+75
@@ -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()
|
||||
}
|
||||
}
|
||||
+87
@@ -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
|
||||
@@ -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": []
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+8
@@ -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()) }
|
||||
|
||||
+79
@@ -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())
|
||||
}
|
||||
}
|
||||
+34
-14
@@ -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,
|
||||
|
||||
Executable
+104
@@ -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"
|
||||
Executable
+159
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Executable
+108
@@ -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 ]]
|
||||
Reference in New Issue
Block a user