mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
Compare commits
143
Commits
b575f9078d
...
1f4d9728be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4d9728be | ||
|
|
25c4516270 | ||
|
|
d56d0cf725 | ||
|
|
f7cf257365 | ||
|
|
edd36b467c | ||
|
|
5b357cd2fc | ||
|
|
c47afe6427 | ||
|
|
a2e199bd57 | ||
|
|
2d7ef2fd93 | ||
|
|
21623cfd3f | ||
|
|
08b3cf266b | ||
|
|
f6e68f31d4 | ||
|
|
27c863925a | ||
|
|
c4c8649fcc | ||
|
|
112fbb8379 | ||
|
|
6fbcb880dd | ||
|
|
60e3df72b2 | ||
|
|
14046fd0ce | ||
|
|
91bd2b44ee | ||
|
|
09902fe470 | ||
|
|
14b615b10d | ||
|
|
8678c88db6 | ||
|
|
6fac964d16 | ||
|
|
96a896f78e | ||
|
|
1c24ba9cf6 | ||
|
|
9e4aedee05 | ||
|
|
eae0ca814d | ||
|
|
ec60834046 | ||
|
|
2684d2d4a8 | ||
|
|
fd6696da82 | ||
|
|
00c46bcc92 | ||
|
|
26ad072993 | ||
|
|
52643fb48f | ||
|
|
202163f4b9 | ||
|
|
097c3e874d | ||
|
|
adc36017d1 | ||
|
|
65073c351c | ||
|
|
34633624cc | ||
|
|
9f367269e6 | ||
|
|
9f77245860 | ||
|
|
b185f58ada | ||
|
|
f79741e611 | ||
|
|
ff40d49b78 | ||
|
|
439bc5d5f9 | ||
|
|
bde562b4a6 | ||
|
|
1793ee5ffe | ||
|
|
07267e254b | ||
|
|
16030599d9 | ||
|
|
ee8623bd20 | ||
|
|
7e6170390d | ||
|
|
de1c4905cc | ||
|
|
0279938e05 | ||
|
|
536d3cf1a5 | ||
|
|
979ce7a96f | ||
|
|
7cda707380 | ||
|
|
b58543fb2d | ||
|
|
707cffec6d | ||
|
|
d44264a1ac | ||
|
|
04243d8dfa | ||
|
|
60161e3fae | ||
|
|
ca61eb107d | ||
|
|
9d2c5e0cee | ||
|
|
e6bce31249 | ||
|
|
38e5c848a4 | ||
|
|
ac426531f3 | ||
|
|
967d51b6f9 | ||
|
|
21329b6dab | ||
|
|
240f4e67a6 | ||
|
|
a41db22030 | ||
|
|
417147cfe7 | ||
|
|
afaf5f582b | ||
|
|
c13d2840b9 | ||
|
|
bdc21fee1e | ||
|
|
22383e7a1c | ||
|
|
6ba53918c4 | ||
|
|
2496a7fb3d | ||
|
|
c422a9d022 | ||
|
|
3510296673 | ||
|
|
f723310dc7 | ||
|
|
662bd87495 | ||
|
|
6e2a8f00f4 | ||
|
|
21c44cfc36 | ||
|
|
cb648011d3 | ||
|
|
22503b8fe3 | ||
|
|
0284c8c86a | ||
|
|
d504e43924 | ||
|
|
422bf10337 | ||
|
|
f353389892 | ||
|
|
97ff971542 | ||
|
|
ce25273898 | ||
|
|
3cfa2e0efb | ||
|
|
bf73e0934a | ||
|
|
8a183d1cbd | ||
|
|
34fa103dbd | ||
|
|
b4798331e8 | ||
|
|
0703797bdb | ||
|
|
db529444c4 | ||
|
|
f98859afa8 | ||
|
|
3e583fd425 | ||
|
|
a89aaed9fa | ||
|
|
6035e5f0e7 | ||
|
|
4d21165d7d | ||
|
|
2e47e9cd56 | ||
|
|
09f2ec79e7 | ||
|
|
ab5e4116ad | ||
|
|
8a8f58e7ad | ||
|
|
0e48a8c85b | ||
|
|
37b3d650e0 | ||
|
|
b0c0f12591 | ||
|
|
a5d59a69c3 | ||
|
|
592fd8f542 | ||
|
|
a302b82413 | ||
|
|
42bf2a43d8 | ||
|
|
3ab87dd48e | ||
|
|
54b5ff8876 | ||
|
|
5a665950b8 | ||
|
|
054dffd55d | ||
|
|
9a5b36d0ba | ||
|
|
b666a189ac | ||
|
|
7d26c06fe9 | ||
|
|
0ae90dbdc6 | ||
|
|
8d877ea70d | ||
|
|
7ba5631ec3 | ||
|
|
1133560dd6 | ||
|
|
842a01ea2f | ||
|
|
fcbd9d727b | ||
|
|
30c7dbc2f3 | ||
|
|
4700e51d82 | ||
|
|
c26988f399 | ||
|
|
76b3323583 | ||
|
|
cabe539e8e | ||
|
|
6f5e0c6a6b | ||
|
|
c5ba33486a | ||
|
|
c204e5b485 | ||
|
|
1b70f0939d | ||
|
|
dd6b779463 | ||
|
|
4ab73789a6 | ||
|
|
0ab1930123 | ||
|
|
bc482cb6eb | ||
|
|
e862b3ecac | ||
|
|
b2b5bccec1 | ||
|
|
646459e367 | ||
|
|
f46534a219 |
@@ -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.
|
||||
|
||||
|
||||
@@ -56,11 +56,36 @@ import java.io.File
|
||||
*/
|
||||
class Amethyst : Application() {
|
||||
init {
|
||||
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
|
||||
Log.minLevel = DEFAULT_LOG_LEVEL
|
||||
Log.d("AmethystApp") { "Creating App $this" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Restores the full firehose in debug builds: per-socket relay lifecycle
|
||||
* (`Connecting…`/`Disconnected`/`OnOpen`), per-second event throughput, per-stream Arti
|
||||
* SOCKS errors and per-relay census detail.
|
||||
*
|
||||
* Off by default. A cold start on the outbox model dials ~400 relays, and those sources
|
||||
* alone emit ~3.7k of the ~6.2k lines an 80s boot produces — which buries the boot
|
||||
* narrative and the relay-protocol warnings (`auth-required`, `rate-limited`,
|
||||
* `unsupported: too many filters`) that are the actionable ones. Flip this, or set
|
||||
* [Log.minLevel] at runtime, when you need the per-socket detail back.
|
||||
*/
|
||||
const val VERBOSE_LOGS = false
|
||||
|
||||
/**
|
||||
* Debug defaults to INFO so a boot reads as a narrative plus warnings; release defaults to
|
||||
* WARN rather than ERROR so relay-protocol refusals stay visible in the field — they are
|
||||
* the highest-value-per-line diagnostics we emit and were previously dropped entirely.
|
||||
*/
|
||||
val DEFAULT_LOG_LEVEL: LogLevel =
|
||||
when {
|
||||
!BuildConfig.DEBUG -> LogLevel.WARN
|
||||
VERBOSE_LOGS -> LogLevel.DEBUG
|
||||
else -> LogLevel.INFO
|
||||
}
|
||||
|
||||
lateinit var instance: AppModules
|
||||
private set
|
||||
}
|
||||
@@ -83,10 +108,15 @@ class Amethyst : Application() {
|
||||
// is self-contained (WebView + IPC), so we skip all app init there and
|
||||
// leave `instance` unset; any accidental use fails fast.
|
||||
if (isNappletSandbox) {
|
||||
Log.d("AmethystApp") { "Skipping AppModules init in sandbox process" }
|
||||
// Milestone, not chatter: this is the one line that explains why `instance` is unset
|
||||
// and `LocalCache` is empty in this process. Without it a napplet-process log looks
|
||||
// like a broken app rather than a deliberately secret-free sandbox.
|
||||
Log.i("AmethystApp") { "Napplet sandbox process starting — no account, no AppModules" }
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("AmethystApp") { "Amethyst ${BuildConfig.VERSION_NAME} starting in main process (log level ${Log.minLevel})" }
|
||||
|
||||
instance = AppModules(this)
|
||||
|
||||
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1224,6 +1230,14 @@ class AppModules(
|
||||
blossomResolver.uriToUrlCache.evictAll()
|
||||
blossomResolver.blossomHitCache.cache.evictAll()
|
||||
localBlossomCacheProbe.invalidate()
|
||||
// Re-probe immediately so enabling the feature activates it
|
||||
// this session. Otherwise `available` only advances when a
|
||||
// `blossom:` URI is resolved, and the common feed-image path
|
||||
// never routes through the resolver until `available` is
|
||||
// already true — so a freshly-enabled toggle (or a cache that
|
||||
// came up after launch) would stay dormant and the settings
|
||||
// "detected" chip would read stale.
|
||||
localBlossomCacheProbe.isAvailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -325,6 +326,9 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
// How many accounts are in play is the first thing you need when reading any
|
||||
// boot log: nearly every per-account subsystem below multiplies by this number.
|
||||
Log.i("LocalPreferences") { "Found ${newSystemOfAccounts.size} saved account(s)" }
|
||||
newSystemOfAccounts
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
@@ -712,6 +716,7 @@ object LocalPreferences {
|
||||
|
||||
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
|
||||
Log.d("LocalPreferences") { "Load account from file $npub" }
|
||||
val startedAtMs = TimeUtils.nowMillis()
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
@@ -1017,7 +1022,11 @@ object LocalPreferences {
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("LocalPreferences") { "Loaded account from file $npub" }
|
||||
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
|
||||
// the most expensive things a cold start does (it resolves a fan of backup events), it runs
|
||||
// once per account, and "which account was slow" is the first question when a boot drags.
|
||||
// The six intermediate steps above stay at DEBUG.
|
||||
Log.i("LocalPreferences") { "Loaded account $npub in ${TimeUtils.nowMillis() - startedAtMs}ms" }
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -89,7 +89,13 @@ class AntiSpamFilter {
|
||||
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
|
||||
val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
|
||||
// it is already reported where it can be acted on — relayStats.newSpam below and
|
||||
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
|
||||
// with a pair of njump links each, which is the widest line in the log and says
|
||||
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
|
||||
// open the two events and compare them.
|
||||
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
// Log down offenders
|
||||
val spammer = logOffender(hash, event)
|
||||
@@ -118,7 +124,13 @@ class AntiSpamFilter {
|
||||
// LRU cache while the spammer record still matches this hash.
|
||||
val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
|
||||
// it is already reported where it can be acted on — relayStats.newSpam below and
|
||||
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
|
||||
// with a pair of njump links each, which is the widest line in the log and says
|
||||
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
|
||||
// open the two events and compare them.
|
||||
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
// Log down offenders
|
||||
val spammer = logOffender(hash, event)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -389,7 +389,7 @@ object NotificationUtils {
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
return NotificationCompat.Action
|
||||
.Builder(R.drawable.ic_notif_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||
.Builder(R.drawable.ic_action_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||
.addRemoteInput(replyRemoteInput(applicationContext))
|
||||
.setAllowGeneratedReplies(true)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
@@ -460,7 +460,7 @@ object NotificationUtils {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
return NotificationCompat.Action
|
||||
.Builder(R.drawable.ic_notif_message, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
|
||||
.Builder(R.drawable.ic_action_mark_read, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.build()
|
||||
}
|
||||
|
||||
+2
-2
@@ -53,9 +53,9 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTop
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.rememberIsAudioTrack
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -105,7 +105,7 @@ fun RenderVideoPlayer(
|
||||
// unnecessary recomposition of the whole player tree just to update a value that is only
|
||||
// ever read inside the onDoubleTap callback below.
|
||||
val containerWidth = remember { intArrayOf(0) }
|
||||
val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) }
|
||||
val isLive = remember(mediaItem.src.videoUri, mediaItem.src.mimeType) { isHlsMedia(mediaItem.src.videoUri, mediaItem.src.mimeType) }
|
||||
|
||||
val swipeState = remember { FullscreenSwipeControlsState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
@@ -113,7 +113,7 @@ fun RenderTopButtons(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) }
|
||||
val isLive = remember(mediaData.videoUri, mediaData.mimeType) { isHlsMedia(mediaData.videoUri, mediaData.mimeType) }
|
||||
val pipSupported =
|
||||
remember {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
|
||||
/**
|
||||
* Whether a URL plus its imeta mime identifies HLS.
|
||||
*
|
||||
* This is the caller-side form for code that holds a URL and a mime but no `MediaItem` yet — UI that
|
||||
* has a [MediaItemData] or a `MediaUrlContent`. Once a `MediaItem` exists,
|
||||
* `isHlsMediaItem` is the equivalent, and both must answer the same way: the UI decides what to
|
||||
* render, the factory decides how to load it, and a disagreement shows up as a player that streams
|
||||
* something the surrounding chrome says is a still image.
|
||||
*
|
||||
* Defined in terms of [MediaItemCache.toExoPlayerMimeType] rather than re-deriving the rules, so it
|
||||
* cannot drift from the mime that actually reaches ExoPlayer. That normalizer prefers an explicit
|
||||
* mime (mapping the four HLS aliases onto [MimeTypes.APPLICATION_M3U8]) and otherwise falls back to a
|
||||
* **path-anchored** `.m3u8` test.
|
||||
*
|
||||
* Both halves matter. A BUD-10 blossom playlist is `https://host/<sha256>` with no extension at all,
|
||||
* so only the mime identifies it; and anchoring to the path stops `video.mp4?ref=a.m3u8` counting as
|
||||
* HLS on the strength of its query string.
|
||||
*
|
||||
* Note this answers *is it HLS*, which callers use as a proxy for *is it live*. The proxy is
|
||||
* imprecise in the same way for every HLS URL — an on-demand HLS playlist also answers true — and
|
||||
* that imprecision is older than this function. Liveness is only truly knowable from
|
||||
* `#EXT-X-ENDLIST` once the playlist is loaded, which is what `HlsLivenessCache` records.
|
||||
*/
|
||||
fun isHlsMedia(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): Boolean = MediaItemCache.toExoPlayerMimeType(mimeType, url) == MimeTypes.APPLICATION_M3U8
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.diskCache
|
||||
|
||||
fun isLiveStreaming(url: String) = url.contains(".m3u8", true)
|
||||
+65
-15
@@ -20,10 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
@@ -31,7 +34,6 @@ import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -58,6 +60,26 @@ internal fun shouldBypassCache(
|
||||
else -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a [MediaItem] is HLS, via media3's own content-type inference.
|
||||
*
|
||||
* This is the one "is this HLS?" predicate for playback routing. [CustomMediaSourceFactory] uses it
|
||||
* to pick both the cache route and the media-source factory, and [HlsLivenessRecorder] uses it to
|
||||
* decide which items are worth learning a liveness verdict for. Those two **must** agree: if the
|
||||
* recorder tested more narrowly than the factory, an item the factory treats as HLS would never be
|
||||
* classified, [HlsLivenessCache] would answer `isKnownOnDemand = false` for it forever, and
|
||||
* [shouldBypassCache] would keep it out of the disk cache permanently.
|
||||
*
|
||||
* It reads back the mimeType [MediaItemCache] already normalised, which is what makes it correct for
|
||||
* BUD-10 blossom URIs — `https://host/<sha256>`, no extension, mime as the only signal. A `.m3u8`
|
||||
* substring test on the URL misses those, and separately gives a false positive on a query string
|
||||
* over progressive media (`video.mp4?ref=a.m3u8`).
|
||||
*/
|
||||
internal fun isHlsMediaItem(mediaItem: MediaItem?): Boolean {
|
||||
val config = mediaItem?.localConfiguration ?: return false
|
||||
return Util.inferContentTypeForUriAndMimeType(config.uri, config.mimeType) == C.CONTENT_TYPE_HLS
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a [MediaItem] plays through the caching data source or bypasses it.
|
||||
*
|
||||
@@ -81,20 +103,42 @@ class CustomMediaSourceFactory(
|
||||
videoCache: VideoCache,
|
||||
dataSourceFactory: DataSource.Factory,
|
||||
) : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
|
||||
private var nonCachingFactory: MediaSource.Factory =
|
||||
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
|
||||
|
||||
private val cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(cachingDataSource)
|
||||
private val nonCachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(dataSourceFactory)
|
||||
|
||||
// Stateless, so one instance serves both HLS factories.
|
||||
private val playlistParserFactory = LowLatencyStrippingHlsPlaylistParserFactory()
|
||||
|
||||
// HLS is built explicitly rather than through DefaultMediaSourceFactory, which exposes no hook
|
||||
// for a playlist parser factory. See LowLatencyStrippingHlsPlaylistParserFactory for why we need
|
||||
// one. Everything else still routes through the Default factories above.
|
||||
//
|
||||
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
|
||||
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
|
||||
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
|
||||
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
|
||||
// be mirrored here.
|
||||
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
|
||||
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
|
||||
|
||||
private fun hlsFactory(dataSource: DataSource.Factory): MediaSource.Factory =
|
||||
HlsMediaSource
|
||||
.Factory(dataSource)
|
||||
.setPlaylistParserFactory(playlistParserFactory)
|
||||
|
||||
private val allFactories = listOf(cachingFactory, nonCachingFactory, cachingHlsFactory, nonCachingHlsFactory)
|
||||
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
nonCachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
allFactories.forEach { it.setDrmSessionManagerProvider(drmSessionManagerProvider) }
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
cachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
nonCachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -103,18 +147,24 @@ class CustomMediaSourceFactory(
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
val id = mediaItem.mediaId
|
||||
val flaggedLive = isFlaggedLive(mediaItem)
|
||||
val hls = isLiveStreaming(id)
|
||||
|
||||
// One predicate governs both the cache routing below and which factory builds the source, so
|
||||
// the two can never disagree. See isHlsMediaItem.
|
||||
val hls = isHlsMediaItem(mediaItem)
|
||||
|
||||
val knownOnDemand = HlsLivenessCache.isKnownOnDemand(id)
|
||||
val bypassCache = shouldBypassCache(flaggedLive, hls, knownOnDemand)
|
||||
|
||||
val source =
|
||||
if (bypassCache) {
|
||||
nonCachingFactory.createMediaSource(mediaItem)
|
||||
val factory =
|
||||
if (hls) {
|
||||
if (bypassCache) nonCachingHlsFactory else cachingHlsFactory
|
||||
} else {
|
||||
cachingFactory.createMediaSource(mediaItem)
|
||||
if (bypassCache) nonCachingFactory else cachingFactory
|
||||
}
|
||||
// Logs the three routing inputs directly rather than a re-derived label, so it can't drift
|
||||
// from shouldBypassCache.
|
||||
val source = factory.createMediaSource(mediaItem)
|
||||
|
||||
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
|
||||
// shouldBypassCache.
|
||||
Log.d(PLAYBACK_DIAG_TAG) {
|
||||
"SOURCE ${if (bypassCache) "BYPASS" else "CACHE"} flaggedLive=$flaggedLive hls=$hls knownOnDemand=$knownOnDemand " +
|
||||
"mime=${mediaItem.localConfiguration?.mimeType} -> ${source::class.java.simpleName} id=$id"
|
||||
|
||||
+6
-4
@@ -25,7 +25,6 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -66,7 +65,7 @@ internal fun livenessVerdictToRecord(
|
||||
* reliable live/on-demand discriminator (`#EXT-X-ENDLIST`) is inside the playlist, so it is knowable
|
||||
* only once ExoPlayer has loaded it — hence learning it here rather than from the URL.
|
||||
*
|
||||
* Only `.m3u8` items are considered; progressive media is unambiguous and never routed by liveness.
|
||||
* Only HLS items are considered; progressive media is unambiguous and never routed by liveness.
|
||||
*/
|
||||
class HlsLivenessRecorder(
|
||||
private val player: Player,
|
||||
@@ -85,8 +84,11 @@ class HlsLivenessRecorder(
|
||||
|
||||
private fun maybeRecord(allowOnDemand: Boolean) {
|
||||
if (player.currentTimeline.isEmpty) return
|
||||
val url = player.currentMediaItem?.mediaId ?: return
|
||||
if (!isLiveStreaming(url)) return
|
||||
val mediaItem = player.currentMediaItem ?: return
|
||||
// Must be the same predicate CustomMediaSourceFactory routes on — see isHlsMediaItem for
|
||||
// what goes wrong when the two disagree.
|
||||
if (!isHlsMediaItem(mediaItem)) return
|
||||
val url = mediaItem.mediaId
|
||||
|
||||
val known = HlsLivenessCache.verdict(url)
|
||||
val toRecord =
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.upstream.ParsingLoadable
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Low-Latency HLS tags that we remove before media3's playlist parser sees them.
|
||||
*
|
||||
* `EXT-X-PART` and `EXT-X-PRELOAD-HINT` are the ones that matter: they are the only things in these
|
||||
* playlists that produce a **byte-range-bounded** chunk, which is what triggers the crash documented
|
||||
* on [LowLatencyStrippingHlsPlaylistParserFactory]. `EXT-X-PART-INF` and `EXT-X-SERVER-CONTROL` go
|
||||
* with them — leaving those behind advertises a low-latency contract (PART-TARGET, PART-HOLD-BACK,
|
||||
* CAN-BLOCK-RELOAD) that the stripped playlist can no longer honour.
|
||||
*
|
||||
* Deliberately **not** stripped:
|
||||
* - `EXT-X-SKIP` — marks a delta playlist whose segments were legitimately omitted. Removing the tag
|
||||
* while the segments stay missing would corrupt the playlist. Dropping `EXT-X-SERVER-CONTROL`
|
||||
* already stops media3 requesting deltas (`_HLS_skip=YES`), so this should never appear anyway.
|
||||
* - `EXT-X-RENDITION-REPORT` — inert once the parts are gone.
|
||||
*/
|
||||
private val LOW_LATENCY_TAGS =
|
||||
listOf(
|
||||
"#EXT-X-PART:",
|
||||
"#EXT-X-PART-INF:",
|
||||
"#EXT-X-PRELOAD-HINT:",
|
||||
"#EXT-X-SERVER-CONTROL:",
|
||||
)
|
||||
|
||||
/**
|
||||
* Removes the Low-Latency HLS tags from a playlist, leaving every other byte untouched.
|
||||
*
|
||||
* Line separators are preserved exactly: the split/join round-trips `\n`, keeps the `\r` of a CRLF
|
||||
* playlist as trailing content, and keeps a trailing newline (which `split` surfaces as a final
|
||||
* empty element). Blank lines are never dropped.
|
||||
*/
|
||||
internal fun stripLowLatencyTags(playlist: String): String {
|
||||
// Cheap pre-check: the overwhelming majority of playlists carry no LL tags at all, and this
|
||||
// runs on every playlist reload of every live stream.
|
||||
if (LOW_LATENCY_TAGS.none { playlist.contains(it) }) return playlist
|
||||
|
||||
return playlist
|
||||
.split("\n")
|
||||
.filterNot { line ->
|
||||
val trimmed = line.trimStart()
|
||||
LOW_LATENCY_TAGS.any { trimmed.startsWith(it) }
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* The byte-level form: decode as UTF-8, strip, re-encode.
|
||||
*
|
||||
* Split out from [LowLatencyStrippingParser] so the charset round-trip is reachable from a plain JVM
|
||||
* unit test — `parse` takes an `android.net.Uri`, which stubs to null under
|
||||
* `unitTests.isReturnDefaultValues`, so nothing that goes through it is testable without Robolectric.
|
||||
*
|
||||
* A UTF-8 BOM survives: decoding leaves U+FEFF in the string, `trimStart` does not treat it as
|
||||
* whitespace, and re-encoding reproduces the same three bytes. When there is nothing to strip the
|
||||
* *original array* is returned, so the common path neither re-encodes nor copies.
|
||||
*/
|
||||
internal fun stripLowLatencyTags(playlist: ByteArray): ByteArray {
|
||||
val original = playlist.toString(Charsets.UTF_8)
|
||||
val stripped = stripLowLatencyTags(original)
|
||||
return if (stripped === original) playlist else stripped.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a media3 playlist parser and strips the Low-Latency tags before delegating.
|
||||
*
|
||||
* Playlists are a few KB, so reading the stream fully into memory is cheaper than the alternative of
|
||||
* a streaming line filter and keeps the transform a pure, testable [stripLowLatencyTags] call.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal class LowLatencyStrippingParser(
|
||||
private val delegate: ParsingLoadable.Parser<HlsPlaylist>,
|
||||
) : ParsingLoadable.Parser<HlsPlaylist> {
|
||||
override fun parse(
|
||||
uri: Uri,
|
||||
inputStream: InputStream,
|
||||
): HlsPlaylist = delegate.parse(uri, ByteArrayInputStream(stripLowLatencyTags(inputStream.readBytes())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves media3 a de-low-latency-ed view of every HLS playlist.
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* media3 crashes fatally on a Low-Latency HLS playlist whose parts are byte ranges — which is what
|
||||
* zap-stream-core emits (`#EXT-X-PART:URI="…",DURATION=…,BYTERANGE="359712@0"`). Reproduced on
|
||||
* media3 1.10.1, Pixel 9a / Android 17, ~0.6s after `ExoPlayerImpl.Init`:
|
||||
*
|
||||
* ```
|
||||
* IllegalArgumentException
|
||||
* at androidx.media3.datasource.DataSpec.<init> // checkArgument(length > 0 || length == LENGTH_UNSET)
|
||||
* at androidx.media3.datasource.DataSpec.subrange
|
||||
* at androidx.media3.exoplayer.hls.HlsMediaChunk.feedDataToExtractor
|
||||
* at androidx.media3.exoplayer.hls.HlsMediaChunk.loadMedia
|
||||
* ```
|
||||
*
|
||||
* `HlsMediaChunk.feedDataToExtractor` re-enters as `dataSpec.subrange(nextLoadPosition)`. Once the
|
||||
* whole bounded range has been fed to the extractor, `nextLoadPosition == length`, so `subrange`
|
||||
* asks for a zero-length `DataSpec` and the constructor's `length > 0` precondition throws. The
|
||||
* early-return guard in `subrange` only covers `offset == 0`, so a fully-consumed chunk falls
|
||||
* straight through. Unbounded chunks are safe — `length == C.LENGTH_UNSET` short-circuits — so this
|
||||
* is reachable only via a byte-range part.
|
||||
*
|
||||
* The failure is unrecoverable rather than merely retried: `Loader` wraps it as
|
||||
* `UnexpectedLoaderException`, which `DefaultLoadErrorHandlingPolicy` lists as non-retriable, so it
|
||||
* becomes a fatal `ExoPlaybackException: Source error`. Forcing a retry would not help either — the
|
||||
* `HlsMediaChunk` instance keeps its `nextLoadPosition`, so it would throw identically forever.
|
||||
*
|
||||
* Still present verbatim in media3 1.11.0-rc01, so there is no version to upgrade to.
|
||||
*
|
||||
* **Tracking: https://github.com/androidx/media/issues/3350** — delete this whole file and its test
|
||||
* once that is fixed and we are on a media3 release carrying the fix, then drop the explicit
|
||||
* `HlsMediaSource.Factory` in [CustomMediaSourceFactory] and let `DefaultMediaSourceFactory` build
|
||||
* HLS again. That also restores low latency, and removes the caveat about the wrapping
|
||||
* `DefaultMediaSourceFactory` features documented there.
|
||||
*
|
||||
* ## Trade-off
|
||||
*
|
||||
* We lose low latency on LL-HLS streams: playback falls back to whole segments, roughly one
|
||||
* `TARGETDURATION` further behind the live edge. LL playlists still list their complete segments
|
||||
* below the part tags, so they play normally otherwise. Given the alternative is a hard failure
|
||||
* within a second, and that media3 offers no per-stream way to decline just the parts, disabling it
|
||||
* globally is the conservative trade.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal class LowLatencyStrippingHlsPlaylistParserFactory(
|
||||
private val delegate: HlsPlaylistParserFactory = DefaultHlsPlaylistParserFactory(),
|
||||
) : HlsPlaylistParserFactory {
|
||||
override fun createPlaylistParser(): ParsingLoadable.Parser<HlsPlaylist> = LowLatencyStrippingParser(delegate.createPlaylistParser())
|
||||
|
||||
override fun createPlaylistParser(
|
||||
multivariantPlaylist: HlsMultivariantPlaylist,
|
||||
previousMediaPlaylist: HlsMediaPlaylist?,
|
||||
): ParsingLoadable.Parser<HlsPlaylist> = LowLatencyStrippingParser(delegate.createPlaylistParser(multivariantPlaylist, previousMediaPlaylist))
|
||||
}
|
||||
+5
-2
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.playerPool.positions
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.isHlsMediaItem
|
||||
import kotlin.math.abs
|
||||
|
||||
class CurrentPlayPositionCacher(
|
||||
@@ -41,7 +41,10 @@ class CurrentPlayPositionCacher(
|
||||
isLiveStreaming = false
|
||||
} else {
|
||||
currentUrl = mediaItem.mediaId
|
||||
isLiveStreaming = isLiveStreaming(mediaItem.mediaId)
|
||||
// Same predicate the source factory routes on, so a blossom-hosted playlist — which has
|
||||
// no `.m3u8` in its URL — is recognised here too and doesn't get a resume position
|
||||
// persisted against a stream that has no stable position to return to.
|
||||
isLiveStreaming = isHlsMediaItem(mediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-12
@@ -56,7 +56,10 @@ import kotlin.concurrent.thread
|
||||
*/
|
||||
class BootRelayDiagnostics(
|
||||
val client: INostrClient,
|
||||
val dumpAtSeconds: List<Long> = listOf(20, 45, 90),
|
||||
// 5s first: the pool is assembled and dialling well before 20s, and the early datapoint is what
|
||||
// distinguishes "slow to connect" from "connected fine, slow to serve". Affordable because the
|
||||
// rollup is now 3 INFO lines rather than 5 — the ===== banners moved to DEBUG.
|
||||
val dumpAtSeconds: List<Long> = listOf(5, 20, 45, 90),
|
||||
) {
|
||||
companion object {
|
||||
const val TAG = "BootRelayDiag"
|
||||
@@ -197,8 +200,13 @@ class BootRelayDiagnostics(
|
||||
fun detach() = client.removeConnectionListener(listener)
|
||||
|
||||
/**
|
||||
* One line per relay plus a rollup. Kept to a single Log.w per line so the whole census
|
||||
* One line per relay plus a rollup. Kept to a single Log call per line so the whole census
|
||||
* survives logcat's per-tag rate limiting on a busy boot.
|
||||
*
|
||||
* The rollup goes out at INFO — it is the one boot line worth reading by default, and it
|
||||
* carries the aggregate that per-socket failure logging used to spell out a few hundred
|
||||
* times. The per-relay WASTE/SERVE tables are DEBUG: useful when you are chasing a specific
|
||||
* relay, too long (up to 45 lines a census) to sit in the default log.
|
||||
*/
|
||||
fun dump(atSeconds: Long) {
|
||||
val snapshot = records.toMap()
|
||||
@@ -214,27 +222,27 @@ class BootRelayDiagnostics(
|
||||
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
|
||||
}
|
||||
|
||||
Log.w(TAG, "===== boot census @${atSeconds}s =====")
|
||||
Log.w(
|
||||
Log.d(TAG, "===== boot census @${atSeconds}s =====")
|
||||
Log.i(
|
||||
TAG,
|
||||
"pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
|
||||
"census @${atSeconds}s pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
|
||||
"dials=${snapshot.values.sumOf { it.tentatives.get() }} " +
|
||||
"events=${snapshot.values.sumOf { it.events.get() }} " +
|
||||
"reqs=${snapshot.values.sumOf { it.reqsSent.get() }} " +
|
||||
"auths=${snapshot.values.sumOf { it.authsSent.get() }}",
|
||||
)
|
||||
Log.w(TAG, "failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.w(TAG, "closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.i(TAG, "census @${atSeconds}s failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.i(TAG, "census @${atSeconds}s closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
|
||||
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
|
||||
Log.w(TAG, "--- top wasted dials (no events received) ---")
|
||||
Log.d(TAG, "--- top wasted dials (no events received) ---")
|
||||
snapshot
|
||||
.filter { it.value.events.get() == 0 }
|
||||
.entries
|
||||
.sortedByDescending { it.value.tentatives.get() }
|
||||
.take(25)
|
||||
.forEach { (url, r) ->
|
||||
Log.w(
|
||||
Log.d(
|
||||
TAG,
|
||||
"WASTE ${url.url} dials=${r.tentatives.get()} opens=${r.opens.get()} " +
|
||||
"fail=[${r.failures.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
|
||||
@@ -245,17 +253,17 @@ class BootRelayDiagnostics(
|
||||
|
||||
// The relays actually carrying the boot, so a suppression change can be checked for
|
||||
// coverage loss rather than just CLOSED reduction.
|
||||
Log.w(TAG, "--- top event providers ---")
|
||||
Log.d(TAG, "--- top event providers ---")
|
||||
served.entries
|
||||
.sortedByDescending { it.value.events.get() }
|
||||
.take(20)
|
||||
.forEach { (url, r) ->
|
||||
Log.w(
|
||||
Log.d(
|
||||
TAG,
|
||||
"SERVE ${url.url} events=${r.events.get()} reqs=${r.reqsSent.get()} eose=${r.eoses.get()} " +
|
||||
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}",
|
||||
)
|
||||
}
|
||||
Log.w(TAG, "===== end census @${atSeconds}s =====")
|
||||
Log.d(TAG, "===== end census @${atSeconds}s =====")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -29,11 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@@ -57,9 +60,16 @@ class BlossomServerResolver(
|
||||
suspend fun findServers(uriStr: String): BlossomUriServer? {
|
||||
uriToUrlCache[uriStr]?.let { return it }
|
||||
|
||||
// Confined to Dispatchers.IO: this is reached from Compose
|
||||
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
|
||||
// which run on the main dispatcher. The pre-suspension work here —
|
||||
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
|
||||
// build, and the server-list flow setup — must stay off the UI thread.
|
||||
val result =
|
||||
withTimeoutOrNull(10000) {
|
||||
findServersInner(uriStr)
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeoutOrNull(10000) {
|
||||
findServersInner(uriStr)
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
|
||||
+32
-23
@@ -22,10 +22,13 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -77,33 +80,39 @@ class LocalBlossomCacheProbe(
|
||||
cachedAtMs = 0L
|
||||
}
|
||||
|
||||
// Confined to Dispatchers.IO because callers reach this through suspend
|
||||
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
|
||||
// on the main dispatcher: building the OkHttp client and issuing the HEAD
|
||||
// must not touch the UI thread.
|
||||
private suspend fun probe(): Boolean =
|
||||
try {
|
||||
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
|
||||
val client =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
|
||||
val client =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$LOCAL_CACHE_BASE/")
|
||||
.head()
|
||||
.build()
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$LOCAL_CACHE_BASE/")
|
||||
.head()
|
||||
.build()
|
||||
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
// Spec says HEAD / returns 2xx when available. Some implementations
|
||||
// may answer 405 (method not allowed) while still being a working
|
||||
// Blossom cache, so treat that as available too.
|
||||
response.isSuccessful || response.code == 405
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
// Spec says HEAD / returns 2xx when available. Some implementations
|
||||
// may answer 405 (method not allowed) while still being a working
|
||||
// Blossom cache, so treat that as available too.
|
||||
response.isSuccessful || response.code == 405
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
false
|
||||
}
|
||||
|
||||
private fun currentTimeMs(): Long = System.currentTimeMillis()
|
||||
|
||||
@@ -52,13 +52,10 @@ fun LoadUrlPreview(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadUrlPreviewDirect(
|
||||
fun rememberUrlPreviewState(
|
||||
url: String,
|
||||
urlText: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav? = null,
|
||||
) {
|
||||
): UrlPreviewState {
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val urlPreviewState by
|
||||
produceState(
|
||||
@@ -69,6 +66,18 @@ fun LoadUrlPreviewDirect(
|
||||
accountViewModel.urlPreview(url) { value = it }
|
||||
}
|
||||
}
|
||||
return urlPreviewState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadUrlPreviewDirect(
|
||||
url: String,
|
||||
urlText: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav? = null,
|
||||
) {
|
||||
val urlPreviewState = rememberUrlPreviewState(url, accountViewModel)
|
||||
|
||||
CrossfadeIfEnabled(
|
||||
targetState = urlPreviewState,
|
||||
|
||||
@@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -37,12 +41,14 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil3.compose.AsyncImage
|
||||
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.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -53,6 +59,7 @@ fun UrlPreviewCard(
|
||||
url: String,
|
||||
previewInfo: UrlInfoItem,
|
||||
onUrlComments: (() -> Unit)? = null,
|
||||
onCardClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val popupExpanded =
|
||||
@@ -95,7 +102,11 @@ fun UrlPreviewCard(
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
runCatching { uri.openUri(url) }
|
||||
if (onCardClick != null) {
|
||||
onCardClick()
|
||||
} else {
|
||||
runCatching { uri.openUri(url) }
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
popupExpanded.value = true
|
||||
@@ -109,14 +120,34 @@ fun UrlPreviewCard(
|
||||
modifier = previewCardImageModifier,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Only meaningful when the card's own tap does something else (e.g. opening the
|
||||
// comment thread); otherwise it would duplicate the card's open-in-browser tap.
|
||||
if (onCardClick != null) {
|
||||
IconButton(
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
|
||||
contentDescription = stringRes(R.string.url_preview_open_in_browser),
|
||||
modifier = Size14Modifier,
|
||||
tint = Color.Gray,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
|
||||
+2
-2
@@ -98,7 +98,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -431,7 +431,7 @@ private fun DialogContent(
|
||||
}
|
||||
|
||||
val isPdfOrStaticImage = myContent is MediaUrlImage || myContent is MediaLocalImage || myContent is MediaUrlPdf
|
||||
val isNotLiveStream = myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)
|
||||
val isNotLiveStream = myContent !is MediaUrlContent || !isHlsMedia(myContent.url, myContent.mimeType)
|
||||
if (isPdfOrStaticImage && isNotLiveStream) {
|
||||
val localContext = LocalContext.current
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ fun FeedLoaded(
|
||||
routeForLastRead: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
header: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -54,6 +55,12 @@ fun FeedLoaded(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
if (header != null) {
|
||||
item {
|
||||
header()
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
|
||||
@@ -51,6 +51,8 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.image
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
|
||||
@@ -313,10 +315,10 @@ private class WarmTargets {
|
||||
|
||||
/**
|
||||
* Collects everything worth warming for this note. Reads NIP-92 imeta tags (every
|
||||
* kind), then the free-text body: text notes and comments go through the shared
|
||||
* render cache — which both warms the renderer and yields a fully classified body
|
||||
* — while every other kind gets a cheap URL scan. Returns null only when the note
|
||||
* has no event.
|
||||
* kind), the NIP-73 external-content scope (kind 1111 only), then the free-text
|
||||
* body: text notes and comments go through the shared render cache — which both
|
||||
* warms the renderer and yields a fully classified body — while every other kind
|
||||
* gets a cheap URL scan. Returns null only when the note has no event.
|
||||
*/
|
||||
private fun Note.collectWarmTargets(): WarmTargets? {
|
||||
val ev = event ?: return null
|
||||
@@ -335,6 +337,13 @@ private fun Note.collectWarmTargets(): WarmTargets? {
|
||||
// imetas() above misses these entirely.
|
||||
targets.addFileHeaderMedia(ev)
|
||||
|
||||
// NIP-73 external-content scope. Only kind 1111 carries one, so the type check
|
||||
// keeps every other kind out of the tag walk. Reuses scope() — the same accessor
|
||||
// the renderer uses — so we only warm a URL that will actually be shown.
|
||||
if (ev is CommentEvent) {
|
||||
(ev.scope() as? UrlId)?.let { targets.link(it.url) }
|
||||
}
|
||||
|
||||
// Free-text body. The reproducible-key kinds parse through the shared cache
|
||||
// (which also warms the render); everything else is scanned for URLs only.
|
||||
val rendered = warmRenderCache(ev)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+41
-7
@@ -28,6 +28,7 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
@@ -41,6 +42,9 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
|
||||
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -52,6 +56,14 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
|
||||
|
||||
/**
|
||||
* The normalized scope (e.g. [ExternalId.toScope]) of the external content a screen is
|
||||
* currently dedicated to, if any. A screen built entirely around one external scope (e.g.
|
||||
* the URL thread screen) provides this so nested comments sharing that same scope don't
|
||||
* redundantly repeat the preview the screen itself already shows.
|
||||
*/
|
||||
val LocalCurrentExternalScope = staticCompositionLocalOf<String?> { null }
|
||||
|
||||
@Composable
|
||||
fun DisplayExternalId(
|
||||
externalId: ExternalId,
|
||||
@@ -68,7 +80,7 @@ fun DisplayExternalId(
|
||||
}
|
||||
|
||||
is UrlId -> {
|
||||
DisplayUrlExternalId(externalId, nav)
|
||||
DisplayUrlExternalId(externalId, accountViewModel, nav)
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -80,14 +92,36 @@ fun DisplayExternalId(
|
||||
@Composable
|
||||
fun DisplayUrlExternalId(
|
||||
externalId: UrlId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
DisplayExternalIdChip(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(id = R.string.external_url_scope),
|
||||
label = externalId.url,
|
||||
linkInteractionListener = { nav.nav(Route.Url(externalId.url)) },
|
||||
)
|
||||
val url = externalId.url
|
||||
val chip =
|
||||
@Composable {
|
||||
DisplayExternalIdChip(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(id = R.string.external_url_scope),
|
||||
label = url,
|
||||
linkInteractionListener = { nav.nav(Route.Url(url)) },
|
||||
)
|
||||
}
|
||||
|
||||
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
|
||||
// third-party page, so when previews are off this stays a plain link chip.
|
||||
if (!accountViewModel.settings.showUrlPreview()) {
|
||||
chip()
|
||||
return
|
||||
}
|
||||
|
||||
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo, onCardClick = { nav.nav(Route.Url(url)) })
|
||||
}
|
||||
|
||||
else -> {
|
||||
chip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+18
-3
@@ -49,13 +49,16 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayNameChip
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
|
||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/**
|
||||
@@ -100,7 +103,14 @@ fun RenderRelayGroupMessage(
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact, tappable header naming the Buzz group (or DM participant) a message belongs to. */
|
||||
/**
|
||||
* A compact, tappable header naming the Buzz group (or DM participant) a message belongs to, plus a
|
||||
* [RelayNameChip] naming its host relay — the same pairing the Messages row uses, and the NIP-29
|
||||
* analog of the [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill]
|
||||
* a Concord message wears on these same cards. A group id is only unique within its host relay, so
|
||||
* without the relay a notification from `#general` doesn't say *which* `#general` it came from. The
|
||||
* chip taps through to the relay's channel list, while the name/avatar open the room itself.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupChannelHeader(
|
||||
channel: RelayGroupChannel,
|
||||
@@ -129,16 +139,21 @@ fun RelayGroupChannelHeader(
|
||||
)
|
||||
|
||||
if (dmOther != null) {
|
||||
RelayGroupDmName(dmOther, channel, accountViewModel, Modifier.weight(1f))
|
||||
RelayGroupDmName(dmOther, channel, accountViewModel, Modifier.weight(1f, fill = false))
|
||||
} else {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
|
||||
RelayNameChip(
|
||||
label = channel.groupId.relayUrl.displayUrl(),
|
||||
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
@@ -142,9 +143,11 @@ fun RenderTextEvent(
|
||||
}
|
||||
} else if (!makeItShort && noteEvent is CommentEvent) {
|
||||
// A comment scoped to an external identifier (`I` tag) has no in-cache parent
|
||||
// note. Show the scope itself as the reply context.
|
||||
// note. Show the scope itself as the reply context -- unless the screen we're
|
||||
// in is already dedicated to this exact scope (e.g. the URL thread screen),
|
||||
// in which case every row would otherwise redundantly repeat the same preview.
|
||||
val scope = remember(note) { noteEvent.scope() }
|
||||
if (scope != null) {
|
||||
if (scope != null && scope.toScope() != LocalCurrentExternalScope.current) {
|
||||
DisplayExternalId(scope, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureIn
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
@@ -349,7 +349,7 @@ fun RenderTopButtonsForVoice(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(modifier) {
|
||||
if (!isLiveStreaming(mediaData.videoUri)) {
|
||||
if (!isHlsMedia(mediaData.videoUri, mediaData.mimeType)) {
|
||||
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
|
||||
ShareMediaAction(
|
||||
popupExpanded = popupExpanded,
|
||||
|
||||
+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.
|
||||
|
||||
+6
-1
@@ -608,7 +608,12 @@ class GroupEventHandler(
|
||||
return
|
||||
}
|
||||
if (!manager.isMember(groupId)) {
|
||||
Log.w("MarmotDbg") {
|
||||
// Debug, not warn: relays serve kind:445 for every group they carry, so traffic for
|
||||
// groups we are not in is the expected steady state, not an anomaly — unlike the null
|
||||
// manager and missing 'h' tag above, which stay warnings because they mean we cannot
|
||||
// process a group we *should* be handling. Fires ~22 times a boot (the same handful of
|
||||
// events, re-offered on each pass), which drowns the two real warnings next to it.
|
||||
Log.d("MarmotDbg") {
|
||||
"GroupEventHandler.add: not a member of group=${groupId.take(8)}… — dropping kind:445 ${event.id.take(8)}…"
|
||||
}
|
||||
return
|
||||
|
||||
+22
-119
@@ -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
|
||||
@@ -63,48 +58,39 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordAuthorFacepile
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordUnreadBadge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.recentAuthorHexes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelUnreadCountFlow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/** How many recent-poster avatars a channel row's facepile shows at most. */
|
||||
private const val FACEPILE_MAX = 4
|
||||
|
||||
/** A first screen's worth of recent messages to prefetch per visible card, so previews fill in. */
|
||||
private const val CARD_WARMUP_LIMIT = 10
|
||||
|
||||
/**
|
||||
* One channel row in a Buzz workspace's community view: a channel the user is a member of (via
|
||||
* 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.
|
||||
* kind-44100), rendered like the Concord server view — a colored monogram, the channel name with the
|
||||
* last message's relative time, and below it a preview of the last message (author + snippet, or the
|
||||
* Buzz activity summary for system/diff/job rows) with an unread-count badge.
|
||||
* 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.
|
||||
*
|
||||
* [showActivityPreview] gates the chat-activity machinery — the recent-message warmup, the
|
||||
* last-message preview, the recent-posters facepile and the unread badge. Enable it for **chat**
|
||||
* channels (whose content lives in [RelayGroupChannel.notes]); leave it off for **forum** channels,
|
||||
* whose posts are threads (a separate store), so the row doesn't open a kind-9 chat subscription that
|
||||
* would return nothing and drives a member-count summary instead.
|
||||
* last-message preview and the unread badge. Enable it for **chat** channels (whose content lives in
|
||||
* [RelayGroupChannel.notes]); leave it off for **forum** channels, whose posts are threads (a
|
||||
* separate store), so the row doesn't open a kind-9 chat subscription that would return nothing and
|
||||
* drives a member-count summary instead.
|
||||
*/
|
||||
@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
|
||||
@@ -131,11 +117,10 @@ fun BuzzImportRow(
|
||||
val memberCount = channel.memberCount()
|
||||
val isPrivate = channel.isPrivate()
|
||||
|
||||
// The channel's own notes flow drives the preview/facepile so they update the moment a message
|
||||
// folds in, independent of the metadata-scoped [observeChannel] above. Only collected for chat
|
||||
// channels; a forum row shows a member-count summary with no facepile/unread.
|
||||
// The channel's own notes flow drives the preview so it updates the moment a message folds in,
|
||||
// independent of the metadata-scoped [observeChannel] above. Only collected for chat channels; a
|
||||
// forum row shows a member-count summary with no unread.
|
||||
val lastNote: Note?
|
||||
val faceAuthors: List<String>
|
||||
val unread: Int
|
||||
if (showActivityPreview) {
|
||||
val notesState by channel
|
||||
@@ -143,14 +128,12 @@ fun BuzzImportRow(
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
lastNote = remember(notesState) { channel.newestTimelineNote(account) }
|
||||
faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) }
|
||||
unread =
|
||||
remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
.value
|
||||
} else {
|
||||
lastNote = null
|
||||
faceAuthors = emptyList()
|
||||
unread = 0
|
||||
}
|
||||
val hasUnread = unread > 0
|
||||
@@ -163,14 +146,9 @@ fun BuzzImportRow(
|
||||
isPrivate = isPrivate,
|
||||
memberCount = memberCount,
|
||||
lastNote = lastNote,
|
||||
faceAuthors = faceAuthors,
|
||||
unread = unread,
|
||||
hasUnread = hasUnread,
|
||||
isAdded = isAdded,
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
onAdd = onAdd,
|
||||
onRemove = onRemove,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -192,25 +170,20 @@ private fun BuzzImportRowContent(
|
||||
isPrivate: Boolean,
|
||||
memberCount: Int,
|
||||
lastNote: Note?,
|
||||
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)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// Line 1: a lock (private), the channel name, a pin marker (starred), and the recent-
|
||||
// posters facepile pushed to the right.
|
||||
// Line 1: a lock (private), the channel name, a pin marker (starred), and the last-message
|
||||
// time pushed to the right.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (isPrivate) {
|
||||
Icon(
|
||||
@@ -236,13 +209,6 @@ private fun BuzzImportRowContent(
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
ConcordAuthorFacepile(faceAuthors, accountViewModel)
|
||||
}
|
||||
// Line 2: the last-message preview, then the time + unread badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
|
||||
}
|
||||
lastNote?.createdAt()?.let { ts ->
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
@@ -251,16 +217,15 @@ private fun BuzzImportRowContent(
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Line 2: the last-message preview, then the unread-message badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
}
|
||||
}
|
||||
BuzzChannelRowMenu(
|
||||
isAdded = isAdded,
|
||||
onAdd = onAdd,
|
||||
onRemove = onRemove,
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,68 +273,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
|
||||
|
||||
+9
-15
@@ -46,7 +46,6 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -74,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
|
||||
@@ -223,7 +223,7 @@ fun ConcordChannelListScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
ShorterTopAppBar(
|
||||
title = { Text(communityName, maxLines = 1) },
|
||||
navigationIcon = {
|
||||
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
|
||||
@@ -403,8 +403,6 @@ private fun ConcordChannelListRow(
|
||||
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
val hasUnread = unread > 0
|
||||
// The recent posters' faces — recomputed as the channel's notes change (keyed on channelState).
|
||||
val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) }
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
@@ -421,7 +419,7 @@ private fun ConcordChannelListRow(
|
||||
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// Line 1: channel name + the recent-posters facepile pushed to the right.
|
||||
// Line 1: channel name + the last-message time pushed to the right.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
channelName,
|
||||
@@ -431,13 +429,6 @@ private fun ConcordChannelListRow(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ConcordAuthorFacepile(faceAuthors, accountViewModel)
|
||||
}
|
||||
// Line 2: the last-message preview (or a live "typing…"), then the time + unread badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
|
||||
}
|
||||
lastNote?.createdAt()?.let { ts ->
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
@@ -446,6 +437,12 @@ private fun ConcordChannelListRow(
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Line 2: the last-message preview (or a live "typing…"), then the unread-message badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
}
|
||||
}
|
||||
@@ -458,9 +455,6 @@ private fun ConcordChannelListRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** How many recent-poster avatars a channel row's facepile shows at most. */
|
||||
private const val FACEPILE_MAX = 4
|
||||
|
||||
/**
|
||||
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
|
||||
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A horizontal strip of the recent posters in a channel — the "who's here" cue that makes a busy
|
||||
* channel feel alive. Each poster is drawn with the app's standard profile avatar
|
||||
* ([ClickableUserPicture]), so it carries the same following badge (top-right) and trust-score tag
|
||||
* (bottom-centre) shown everywhere else a user appears, instead of a bare cropped image. Laid out
|
||||
* with a small gap rather than an overlapping stack so those badges stay readable; the newest poster
|
||||
* is leftmost. Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordAuthorFacepile(
|
||||
authorHexes: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
avatarSize: Dp = 24.dp,
|
||||
maxShown: Int = 4,
|
||||
) {
|
||||
if (authorHexes.isEmpty()) return
|
||||
val shown = authorHexes.take(maxShown)
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
shown.forEach { hex ->
|
||||
ClickableUserPicture(
|
||||
baseUserHex = hex,
|
||||
size = avatarSize,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -39,7 +39,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -68,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
|
||||
@@ -115,7 +115,7 @@ fun ConcordHomeScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
ShorterTopAppBar(
|
||||
title = { Text(stringRes(R.string.concord_home_title)) },
|
||||
navigationIcon = {
|
||||
// Back arrow only when this is a pushed screen (from the drawer / a deep link);
|
||||
|
||||
-20
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -135,22 +134,3 @@ private fun ConcordChannel.newMessagesSince(
|
||||
notes.count { _, note ->
|
||||
(note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the
|
||||
* facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a
|
||||
* chatty author counts once (at their newest message) rather than crowding out quieter voices.
|
||||
*/
|
||||
fun ConcordChannel.recentAuthorHexes(limit: Int): List<HexKey> {
|
||||
val latestByAuthor = HashMap<HexKey, Long>()
|
||||
for (note in notes.values()) {
|
||||
val author = note.author?.pubkeyHex ?: continue
|
||||
val at = note.createdAt() ?: continue
|
||||
val prev = latestByAuthor[author]
|
||||
if (prev == null || at > prev) latestByAuthor[author] = at
|
||||
}
|
||||
return latestByAuthor.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+231
-107
@@ -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
|
||||
@@ -47,6 +45,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@@ -73,7 +72,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.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
@@ -100,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -114,9 +114,37 @@ import kotlinx.coroutines.launch
|
||||
/** A first screen's worth of recent messages to prefetch per visible group card, ahead of a tap. */
|
||||
private const val CHANNEL_LIST_WARMUP_LIMIT = 10
|
||||
|
||||
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
|
||||
/**
|
||||
* How long this relay's socket must stay down before the Tor→clearnet escape hatch is offered, so a
|
||||
* slow first connect (or a reconnect) isn't mistaken for a relay that blocks Tor exits.
|
||||
*/
|
||||
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
|
||||
|
||||
/**
|
||||
* Whether to offer the Tor→clearnet escape hatch for this relay ([TorClearnetBanner]).
|
||||
*
|
||||
* The banner accuses *this relay* of blocking Tor exits and offers a privacy downgrade to work
|
||||
* around it, so it has to rule out every other explanation — and be able to deliver:
|
||||
*
|
||||
* - [usesTor]: this relay is *actually* routed over Tor right now (`TorRelayEvaluation.useTor`, the
|
||||
* same predicate the pool dials with). Tor being enabled globally says nothing about one relay —
|
||||
* onion, localhost and the per-role presets (trusted / DM / new) each decide independently.
|
||||
* - [torIsUp]: the SOCKS proxy is Active. While Tor is still bootstrapping *nothing* Tor-routed
|
||||
* connects, which is Tor's problem (and its own dialog's), not this relay's.
|
||||
* - [trustingMovesToClearnet]: the action on offer — adding the relay to the kind-10089 Trusted list —
|
||||
* would actually change its routing. Under a preset that keeps trusted relays on Tor it wouldn't,
|
||||
* and the button would be inert.
|
||||
* - [disconnectedLongEnough]: the socket has been continuously down for [TOR_CLEARNET_HINT_DELAY_MS].
|
||||
* A relay that answers is reachable by definition, and one that merely reconnects is not a relay
|
||||
* that blocks Tor — only a sustained silence is.
|
||||
*/
|
||||
internal fun shouldOfferTorClearnetFallback(
|
||||
usesTor: Boolean,
|
||||
torIsUp: Boolean,
|
||||
trustingMovesToClearnet: Boolean,
|
||||
disconnectedLongEnough: Boolean,
|
||||
): Boolean = usesTor && torIsUp && trustingMovesToClearnet && disconnectedLongEnough
|
||||
|
||||
/**
|
||||
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
|
||||
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
|
||||
@@ -172,6 +200,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 +212,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 +235,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 +270,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 +293,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
|
||||
@@ -271,19 +332,54 @@ fun RelayGroupChannelListScreen(
|
||||
var showAddPeople by remember { mutableStateOf(false) }
|
||||
|
||||
// Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over
|
||||
// Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded
|
||||
// after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted
|
||||
// Relay List (connected over clearnet even while Tor stays on for everything else).
|
||||
val torType by Amethyst.instance.torPrefs.torType
|
||||
.collectAsStateWithLifecycle(TorType.OFF)
|
||||
val isOnion = remember(relay) { relay.url.contains(".onion") }
|
||||
var connectTimedOut by remember(relay) { mutableStateOf(false) }
|
||||
LaunchedEffect(relay) {
|
||||
delay(TOR_CLEARNET_HINT_DELAY_MS)
|
||||
connectTimedOut = true
|
||||
// Tor. Offer to reach it over clearnet — which adds it to the kind-10089 Trusted Relay List
|
||||
// (connected over clearnet even while Tor stays on for everything else).
|
||||
//
|
||||
// Every input below is a live signal, because a grace timer on its own says nothing about the
|
||||
// relay: the banner used to fire on `torType != OFF && !onion && !trusted` plus a 6s delay, so on
|
||||
// a working, answering relay it appeared after six seconds and never went away — the socket state
|
||||
// was never consulted, and neither was whether this relay is Tor-routed at all (Tor being *on*
|
||||
// doesn't mean this relay goes through it; the per-role presets decide).
|
||||
val torEvaluation =
|
||||
Amethyst.instance.torEvaluatorFlow.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
// The same predicate the relay pool itself dials with, so the banner can't claim Tor for a relay
|
||||
// the app is reaching over clearnet (onion / localhost / trusted-off-Tor are all folded in here).
|
||||
val usesTor by remember(relay) { derivedStateOf { torEvaluation.value.useTor(relay) } }
|
||||
|
||||
// While Tor is bootstrapping every Tor-routed relay is silent — that's Tor's own failure (and its
|
||||
// own dialog), so don't let it read as "this relay blocks Tor exits".
|
||||
val torStatus by Amethyst.instance.torManager.status
|
||||
.collectAsStateWithLifecycle()
|
||||
val torIsUp = torStatus is TorServiceStatus.Active
|
||||
|
||||
// The offer adds the relay to the kind-10089 Trusted list, which only moves it to clearnet while
|
||||
// trusted relays are *off* Tor. Under the Small-Payloads / Full-Privacy presets they are on Tor,
|
||||
// so the button would add an entry and change no routing at all — don't offer what can't help.
|
||||
val trustingMovesToClearnet by remember { derivedStateOf { !torEvaluation.value.torSettings.trustedRelaysViaTor } }
|
||||
|
||||
// Global flow (any relay's connect/disconnect re-emits), so derive this relay's boolean.
|
||||
val connectedRelays =
|
||||
accountViewModel.account.client
|
||||
.connectedRelaysFlow()
|
||||
.collectAsStateWithLifecycle()
|
||||
val isConnected by remember(relay) { derivedStateOf { relay in connectedRelays.value } }
|
||||
|
||||
// Debounced on the *connection* rather than armed once per screen: the countdown restarts every
|
||||
// time the socket drops and clears the moment it comes back, so a reconnect can't flash the
|
||||
// banner, and circuits that die mid-session still surface it. Keying the wait on cached content
|
||||
// instead would have hidden the offer exactly when a working relay went dark with a full screen.
|
||||
var disconnectedLongEnough by remember(relay) { mutableStateOf(false) }
|
||||
LaunchedEffect(relay, isConnected) {
|
||||
if (isConnected) {
|
||||
disconnectedLongEnough = false
|
||||
} else {
|
||||
delay(TOR_CLEARNET_HINT_DELAY_MS)
|
||||
disconnectedLongEnough = true
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val showTorHint = torType != TorType.OFF && !isOnion && relay !in trustedRelays && connectTimedOut
|
||||
val showTorHint = shouldOfferTorClearnetFallback(usesTor, torIsUp, trustingMovesToClearnet, disconnectedLongEnough)
|
||||
|
||||
// A pinned relay works both as a pushed detail (from the drawer or another screen) and as a
|
||||
// bottom-nav tab. Read once here (it is @Composable): the back arrow shows only when pushed;
|
||||
@@ -326,17 +422,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 +465,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 +483,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 +499,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 +562,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 +624,6 @@ fun RelayGroupChannelListScreen(
|
||||
row = row,
|
||||
myPubkey = myPubkey,
|
||||
isHidden = false,
|
||||
onToggleMessages = { dmVm.removeFromMessages(row) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {
|
||||
@@ -520,7 +655,6 @@ fun RelayGroupChannelListScreen(
|
||||
row = row,
|
||||
myPubkey = myPubkey,
|
||||
isHidden = true,
|
||||
onToggleMessages = { dmVm.addToMessages(row) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {
|
||||
@@ -640,27 +774,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 +840,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 +869,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.
|
||||
|
||||
-24
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@@ -112,29 +111,6 @@ fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/**
|
||||
* The pubkeys of the [limit] most-recent distinct posters in this group, newest first — the facepile
|
||||
* shown on a channel row. One O(notes) pass keeps each author's latest post time, so a chatty author
|
||||
* counts once (at their newest message) rather than crowding out quieter voices.
|
||||
*/
|
||||
fun RelayGroupChannel.recentAuthorHexes(
|
||||
account: Account,
|
||||
limit: Int,
|
||||
): List<HexKey> {
|
||||
val latestByAuthor = HashMap<HexKey, Long>()
|
||||
for (note in notes.values()) {
|
||||
if (!isRelayGroupTimelineMessage(note, account)) continue
|
||||
val author = note.author?.pubkeyHex ?: continue
|
||||
val at = note.createdAt() ?: continue
|
||||
val prev = latestByAuthor[author]
|
||||
if (prev == null || at > prev) latestByAuthor[author] = at
|
||||
}
|
||||
return latestByAuthor.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
}
|
||||
|
||||
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
|
||||
private fun RelayGroupChannel.hasChatNewerThan(
|
||||
account: Account,
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatLabelMaxWidth
|
||||
|
||||
/**
|
||||
* A tappable chip naming the relay a NIP-29 / Buzz group lives on. A group id is only unique within
|
||||
* its host relay, so the relay is part of the room's identity — the same `#general` can exist on two
|
||||
* Buzz servers and only this chip tells them apart.
|
||||
*
|
||||
* Wears the same highlighted wash as [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill]
|
||||
* so every "which server / community does this room belong to" chip reads the same way. Unlike the
|
||||
* muted note-header [com.vitorpamplona.amethyst.commons.ui.note.HeaderPill] (PoW/OTS/location
|
||||
* markers), this one is a first-class navigation entry point, so it keeps the stronger
|
||||
* `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Buzz
|
||||
* message reads the same wherever it surfaces; the width is capped at [ChatLabelMaxWidth] with a
|
||||
* middle ellipsis so a long host is truncated instead of crowding the channel name out.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayNameChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(11.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-43
@@ -20,22 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -98,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayNameChip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
|
||||
@@ -746,43 +741,6 @@ private fun ConcordServerRoomCompose(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tappable chip naming the server/community a Messages row belongs to. Unlike the muted
|
||||
* note-header [HeaderPill] (PoW/OTS/location markers), this one is a first-class navigation entry
|
||||
* point, so it keeps the stronger `secondaryContainer` highlight.
|
||||
*/
|
||||
@Composable
|
||||
private fun RelayNameChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(11.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Messages row title as the channel name followed by a muted [HeaderPill] naming the room
|
||||
* type (Public Chat, Marmot Group, ...). The pill mirrors the Concord community chip so every group
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ fun MessagesPager(
|
||||
// same state holder, so the two surfaces cannot disagree.
|
||||
headerContent =
|
||||
if (tabs[page].resource == R.string.new_requests) {
|
||||
{ ChannelInvitesSection(accountViewModel) }
|
||||
{ ChannelInvitesSection(accountViewModel, nav) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
+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 =
|
||||
|
||||
+114
-31
@@ -21,29 +21,47 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
@@ -60,6 +78,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@Composable
|
||||
fun ChannelInvitesSection(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val invites by accountViewModel.feedStates.channelInvites.flow
|
||||
@@ -69,48 +88,110 @@ fun ChannelInvitesSection(
|
||||
|
||||
Column(modifier) {
|
||||
invites.forEach { invite ->
|
||||
ChannelInviteCard(invite, accountViewModel)
|
||||
// Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row
|
||||
// below it. Without a key Compose matches children by position and each shifted row would
|
||||
// recompose against a different invite — re-resolving the actor and reloading their avatar.
|
||||
key(invite.channelId) {
|
||||
ChannelInviteCard(invite, accountViewModel, nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One pending add, drawn as a feed row instead of a floating Material card: the actor is the row's
|
||||
* author — picture, name and time in the usual note header — and "added you to X" is the row's content,
|
||||
* so the prompt reads like the reply/mention notifications it sits next to. The three choices take the
|
||||
* reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelInviteCard(
|
||||
invite: BuzzChannelInvite,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) }
|
||||
val baseChannel =
|
||||
remember(invite.channelId, invite.relay) {
|
||||
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
|
||||
}
|
||||
|
||||
// The channel's own metadata flow, collected directly rather than through `observeChannel`. That
|
||||
// helper also registers a ChannelFinder query, and every assembler under it is gated on
|
||||
// `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all,
|
||||
// so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow
|
||||
// still fills the name in when the group's kind-39000 lands from the directory subscription, and
|
||||
// nothing here opens the channel's *message* subscription — holding that back until the viewer
|
||||
// answers is the whole point of the prompt.
|
||||
val channelState by
|
||||
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
|
||||
.collectAsStateWithLifecycle()
|
||||
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
|
||||
val actorName = actorUser?.let { observeUserName(it, accountViewModel).value }
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
// A pending invite is by definition unanswered, so it always carries the new-item wash rather than
|
||||
// fading with a last-read marker: it is a standing question, not a dated event.
|
||||
val backgroundColor =
|
||||
MaterialTheme.colorScheme.newItemBackgroundColor
|
||||
.compositeOver(MaterialTheme.colorScheme.background)
|
||||
|
||||
NoteComposeLayout(
|
||||
modifier =
|
||||
remember(backgroundColor) {
|
||||
Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth()
|
||||
},
|
||||
authorPicture = {
|
||||
Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) {
|
||||
if (actorUser != null) {
|
||||
UserPicture(actorUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
|
||||
} else {
|
||||
DisplayBlankAuthor(Size55dp, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
},
|
||||
firstRow = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
modifier = UserNameRowHeight,
|
||||
) {
|
||||
// Who did it matters: the relay reports a self-join with the same event, so naming the
|
||||
// actor is what tells "I joined this" apart from "a stranger put me here".
|
||||
text =
|
||||
stringRes(
|
||||
R.string.channel_invite_body,
|
||||
actorName ?: stringRes(R.string.channel_invite_unknown_actor),
|
||||
invite.relay.displayUrl(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (actorUser != null) {
|
||||
UsernameDisplay(actorUser, Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_unknown_actor),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
// DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the
|
||||
// dotted variant's own leading space would double it. Same choice the note header makes.
|
||||
TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight)
|
||||
}
|
||||
},
|
||||
secondRow = {},
|
||||
noteContent = {
|
||||
Text(text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()))
|
||||
|
||||
Text(
|
||||
text = invite.relay.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
reactionsRow = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
|
||||
) {
|
||||
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
|
||||
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
|
||||
@@ -120,10 +201,12 @@ fun ChannelInviteCard(
|
||||
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
|
||||
Text(stringRes(R.string.channel_invite_ignore))
|
||||
}
|
||||
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
|
||||
// "Add to Messages", so it carries that label rather than a second word for one action.
|
||||
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold)
|
||||
Text(stringRes(R.string.add_to_messages), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ internal fun SingleNotificationsBody(
|
||||
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
|
||||
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
|
||||
// standing decision, not a dated event, so they must not scroll away into history.
|
||||
ChannelInvitesSection(accountViewModel)
|
||||
ChannelInvitesSection(accountViewModel, nav)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVi
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
|
||||
@@ -265,7 +265,7 @@ fun UrlImageView(
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
val imageModelUrl = artworkUri ?: bridgedUrl
|
||||
val canLoadAsImage = !isVideo || artworkUri != null || !isLiveStreaming(content.url)
|
||||
val canLoadAsImage = !isVideo || artworkUri != null || !isHlsMedia(content.url, content.mimeType)
|
||||
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||
if (it && canLoadAsImage) {
|
||||
|
||||
+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),
|
||||
|
||||
+43
-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
|
||||
@@ -132,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
|
||||
import com.vitorpamplona.amethyst.ui.note.observeEdits
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.types.AudioHeader
|
||||
@@ -280,6 +282,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
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.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
@@ -330,6 +333,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
@@ -512,7 +516,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 +543,7 @@ fun RenderThreadFeed(
|
||||
parentBackgroundColor = background,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = onCollapse,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1116,6 +1136,27 @@ private fun FullBleedNoteCompose(
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else if (noteEvent is CommentEvent) {
|
||||
// A comment scoped to external content (NIP-73 `I` tag, e.g. a URL) has no
|
||||
// in-cache parent note, so it surfaces here as the thread's own "master" note.
|
||||
// Show its external scope for context, same as the reply-context branch in
|
||||
// RenderTextEvent does for non-root occurrences of the same comment.
|
||||
val scope = remember(baseNote) { noteEvent.scope() }
|
||||
if (scope != null) {
|
||||
DisplayExternalId(scope, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
false,
|
||||
canPreview,
|
||||
quotesLeft = 3,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else {
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
|
||||
+51
-7
@@ -23,19 +23,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.url
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
|
||||
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal.UrlFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
|
||||
|
||||
@Composable
|
||||
@@ -85,7 +94,7 @@ fun UrlScreen(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
DisplayUrlHeader(url, Modifier.weight(1f))
|
||||
Text(stringRes(R.string.external_content_title))
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
@@ -97,12 +106,47 @@ fun UrlScreen(
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableFeedView(
|
||||
feedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
CompositionLocalProvider(LocalCurrentExternalScope provides url) {
|
||||
RefresheableFeedView(
|
||||
feedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onLoaded = { state, listState ->
|
||||
FeedLoaded(
|
||||
state,
|
||||
listState,
|
||||
null,
|
||||
accountViewModel,
|
||||
nav,
|
||||
header = { UrlScreenPreview(url, accountViewModel) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UrlScreenPreview(
|
||||
url: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
|
||||
// third-party page, so when previews are off this stays the plain URL header.
|
||||
if (!accountViewModel.settings.showUrlPreview()) {
|
||||
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
|
||||
return
|
||||
}
|
||||
|
||||
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
|
||||
else -> {
|
||||
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,24 @@ class TorService(
|
||||
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||
|
||||
/**
|
||||
* Every status change goes through here so the transition is logged exactly once, at INFO, with
|
||||
* the time since bootstrap started.
|
||||
*
|
||||
* Tor state is the single biggest determinant of whether a boot works — a stuck `Connecting`,
|
||||
* an `Active` that took 40s, and a silent drop back to `Off` are three completely different
|
||||
* bugs that used to look identical in the log, because the per-status detail lived only in
|
||||
* Arti's own DEBUG chatter. Self-transitions are not logged: the reset paths reassign `Off`
|
||||
* defensively and repeating it adds nothing.
|
||||
*/
|
||||
private fun setStatus(next: TorServiceStatus) {
|
||||
val prev = _status.value
|
||||
_status.value = next
|
||||
if (prev::class == next::class && prev.toString() == next.toString()) return
|
||||
val since = if (bootstrapStartedAtMs > 0) " after ${System.currentTimeMillis() - bootstrapStartedAtMs}ms" else ""
|
||||
Log.i("TorService") { "${prev::class.simpleName} -> ${next::class.simpleName}$since" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime detector for a rotten guard sample. Arti logs [ALL_GUARDS_DOWN_MARKER] every time it
|
||||
* fails to find a usable guard; [GUARDS_DOWN_THRESHOLD] of those inside [GUARDS_DOWN_WINDOW_MS]
|
||||
@@ -218,11 +236,11 @@ class TorService(
|
||||
lifecycleMutex.withLock {
|
||||
if (proxyRunning.get()) {
|
||||
if (_status.value is TorServiceStatus.Active) return@withLock
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
setStatus(TorServiceStatus.Connecting)
|
||||
return@withLock
|
||||
}
|
||||
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
setStatus(TorServiceStatus.Connecting)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// Initialize TorClient once — this bootstraps the Tor network.
|
||||
@@ -292,7 +310,7 @@ class TorService(
|
||||
if (initResult != 0) {
|
||||
Log.e("TorService") { "Failed to initialize Arti on retry: error $initResult" }
|
||||
initialized.set(false)
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
@@ -313,7 +331,7 @@ class TorService(
|
||||
|
||||
if (!started) {
|
||||
Log.e("TorService") { "Failed to start SOCKS proxy after $MAX_PORT_RETRIES attempts" }
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
@@ -332,7 +350,7 @@ class TorService(
|
||||
// reset/stop can't clobber it.
|
||||
val startedAt = bootstrapStartedAtMs
|
||||
val elapsed = if (startedAt > 0) System.currentTimeMillis() - startedAt else -1
|
||||
_status.value = TorServiceStatus.Active(socksPort)
|
||||
setStatus(TorServiceStatus.Active(socksPort))
|
||||
Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort (bootstrap took ${elapsed}ms)" }
|
||||
}
|
||||
}
|
||||
@@ -350,7 +368,7 @@ class TorService(
|
||||
Log.d("TorService") { "SOCKS proxy stopped" }
|
||||
}
|
||||
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +383,7 @@ class TorService(
|
||||
lifecycleMutex.withLock {
|
||||
resetLocked()
|
||||
}
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,7 +399,7 @@ class TorService(
|
||||
clearAllArtiData()
|
||||
}
|
||||
}
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
|
||||
</vector>
|
||||
@@ -4,5 +4,16 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M19,3H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM14,17H7v-2h7v2zM17,13H7v-2h10v2zM17,9H7V7h10v2z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.9,2.5H18.1A2.3,2.3 0 0 1 20.4,4.8V19.2A2.3,2.3 0 0 1 18.1,21.5H5.9A2.3,2.3 0 0 1 3.6,19.2V4.8A2.3,2.3 0 0 1 5.9,2.5Z M6.7,4.6H17.3A1.1,1.1 0 0 1 18.4,5.7V18.3A1.1,1.1 0 0 1 17.3,19.4H6.7A1.1,1.1 0 0 1 5.6,18.3V5.7A1.1,1.1 0 0 1 6.7,4.6Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M11.99,7H16.75A0.65,0.65 0 0 1 17.4,7.65V7.65A0.65,0.65 0 0 1 16.75,8.3H11.99A0.65,0.65 0 0 1 11.34,7.65V7.65A0.65,0.65 0 0 1 11.99,7Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M11.99,9.9H16.75A0.65,0.65 0 0 1 17.4,10.55V10.55A0.65,0.65 0 0 1 16.75,11.2H11.99A0.65,0.65 0 0 1 11.34,10.55V10.55A0.65,0.65 0 0 1 11.99,9.9Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M7.25,12.8H16.75A0.65,0.65 0 0 1 17.4,13.45V13.45A0.65,0.65 0 0 1 16.75,14.1H7.25A0.65,0.65 0 0 1 6.6,13.45V13.45A0.65,0.65 0 0 1 7.25,12.8Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M7.25,15.7H16.75A0.65,0.65 0 0 1 17.4,16.35V16.35A0.65,0.65 0 0 1 16.75,17H7.25A0.65,0.65 0 0 1 6.6,16.35V16.35A0.65,0.65 0 0 1 7.25,15.7Z"/>
|
||||
<group
|
||||
android:scaleX="0.017"
|
||||
android:scaleY="0.017"
|
||||
android:translateX="4.216"
|
||||
android:translateY="4.718">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M19.8,9.8L19.733,10.818L19.534,11.819L19.206,12.785L18.755,13.7L18.188,14.548L17.515,15.315L16.748,15.988L15.9,16.555L14.985,17.006L14.019,17.334L13.018,17.533L12,17.6L10.982,17.533L9.981,17.334L9.015,17.006L8.1,16.555L7.252,15.988L6.485,15.315L5.812,14.548L5.245,13.7L4.794,12.785L4.466,11.819L4.267,10.818L4.2,9.8L4.267,8.782L4.466,7.781L4.794,6.815L5.245,5.9L5.812,5.052L6.485,4.285L7.252,3.612L8.1,3.045L9.015,2.594L9.981,2.266L10.982,2.067L12,2L13.018,2.067L14.019,2.266L14.985,2.594L15.9,3.045L16.748,3.612L17.515,4.285L18.188,5.052L18.755,5.9L19.206,6.815L19.534,7.781L19.733,8.782Z M 16.250896717373898,12.066845476381104 C 16.240844675740593,12.036317053642914 13.909887910328264,6.486473178542833 13.269163330664531,4.947393915132104 C 12.488827061649319,4.947393915132104 10.840664531625299,4.947393915132104 9.677978382706165,4.947393915132104 C 9.336581265012011,5.791393114491592 8.456096877502002,7.942157726180943 7.749475580464371,9.674831865492393 C 8.136665332265814,10.598502802241793 8.484391513210568,11.428354683747 8.752818254603682,12.068334667734188 L 10.215204163330664,12.068334667734188 A 0.09977582065652522,0.09977582065652522 135.0 0,0 10.308650920736588,11.951060848678944 C 10.295992794235387,11.655828662930345 9.492201761409126,10.582493995196158 9.731961569255404,9.421669335468376 C 9.79599679743795,9.171857485988792 9.8447678142514,8.917950360288232 9.914759807846275,8.66925540432346 A 8.781761409127302,8.781761409127302 135.0 0,1 10.586385108086468,6.93062449959968 C 10.630688550840672,6.8461128903122495 10.651164931945555,6.808138510808647 10.753919135308244,6.872173738991193 C 10.972085668534826,7.008062449959968 11.21594075260208,7.000244195356284 11.459051240992792,6.982746196957566 C 11.66828262610088,6.937325860688551 11.770292233787027,6.836805444355485 12.154131305044032,6.938815052041633 C 12.230080064051238,6.938815052041633 12.30416733386709,6.938815052041633 12.38160528422738,6.945888710968775 C 12.565892714171335,6.960408326661328 12.739011208967172,7.004339471577261 12.849583666933544,7.171128903122498 C 12.970580464371494,7.3535548438751 12.989939951961565,7.559807846277022 12.970580464371494,7.770528422738191 C 12.941168935148115,8.085864691753402 12.96201761409127,8.379607686148919 13.228955164131303,8.601869495596477 A 2.873767013610889,2.873767013610889 0.0 0,0 13.528654923939149,8.801793434747797 C 13.661192954363488,8.886677341873497 13.820908726981584,8.923534827862289 13.93445956765412,9.041553242594075 C 14.001473178542831,9.11117293835068 14.040936749399517,9.184887910328262 14.011897518014408,9.283919135308246 C 13.9828582866293,9.382950360288229 13.90542033626901,9.429487590072057 13.803410728582863,9.440284227381904 C 13.545780624499596,9.4682065652522 13.293734987990389,9.415712570056042 13.040572457966368,9.385184147317853 C 13.007437950360284,9.381088871096875 12.97504803843074,9.378855084067252 12.940796637309843,9.37662129703763 A 0.5729663730984789,0.5729663730984789 0.0 0,1 12.750552441953557,9.382950360288229 L 12.690984787830258,9.382950360288229 A 1.333943154523619,1.333943154523619 0.0 0,0 12.39612489991993,9.453314651721376 C 12.157481985588463,9.540432345876699 11.940060048038424,9.63611289031225 11.658230584467567,9.63127301841473 A 0.21593274619695757,0.21593274619695757 0.0 0,0 11.555848678943148,9.675576461168934 C 11.335076060848673,9.871777421937548 11.202165732586062,10.11972778222578 11.312365892714165,10.481973578863089 C 11.615044035228177,11.327834267413929 12.392029623698955,13.36839871897518 12.725608486789426,14.248883106485188 C 12.716301040832661,13.652461969575661 12.713694955964767,12.584339471577263 12.711461168935143,12.065728582866294 C 14.044287429943951,12.067590072057648 16.088202562049634,12.066845476381104 16.251269015212163,12.066845476381104 L 16.250896717373898,12.066845476381104"/>
|
||||
<path android:fillColor="#000000" android:pathData="M9.4,15L8.1,22.4L11.5,18.6Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M14.6,15L15.9,22.4L12.5,18.6Z"/>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M19,22H5v-2h14v2zM17.16,8.26C18.22,9.63 18.7,11.4 18.5,13.16C18.15,15.98 15.67,18 12.83,18H11.5C8.66,18 6.35,15.98 6,13.16c-0.2,-1.79 0.29,-3.56 1.34,-4.93C6.5,7.68 6,6.65 6,5.5 6,3.57 7.57,2 9.5,2c1.03,0 1.95,0.45 2.59,1.15C12.71,2.44 13.58,2 14.5,2 16.43,2 18,3.57 18,5.5c0,1.15 -0.5,2.18 -0.84,2.76z"/>
|
||||
<group
|
||||
android:scaleX="0.76"
|
||||
android:scaleY="0.76"
|
||||
android:translateX="-0.399"
|
||||
android:translateY="1.08">
|
||||
<path android:fillColor="#000000" android:pathData="M 5.0,22.0 L 5.0,18.0 Q 5.0,17.43 5.3,16.96 Q 5.6,16.5 6.1,16.23 L 11.0,13.75 L 11.0,12.0 L 7.53,13.72 Q 7.23,13.88 6.9,13.95 Q 6.58,14.02 6.25,14.02 Q 5.47,14.02 4.79,13.62 Q 4.1,13.22 3.73,12.47 Q 3.38,11.8 3.43,11.04 Q 3.48,10.28 3.9,9.62 L 7.0,5.0 L 5.0,2.0 L 11.0,2.0 Q 14.32,2.0 16.66,4.32 Q 19.0,6.65 19.0,10.0 L 19.0,22.0 L 5.0,22.0 M 7.0,20.0 L 17.0,20.0 L 17.0,10.0 Q 17.0,7.5 15.25,5.75 Q 13.5,4.0 11.0,4.0 L 8.75,4.0 L 9.4,5.0 L 5.58,10.75 Q 5.45,10.95 5.44,11.16 Q 5.42,11.38 5.53,11.57 Q 5.65,11.85 5.86,11.94 Q 6.08,12.03 6.28,12.02 Q 6.35,12.02 6.65,11.95 L 13.0,8.75 L 13.0,15.0 L 7.0,18.0 L 7.0,20.0"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.037"
|
||||
android:scaleY="0.037"
|
||||
android:translateX="8.092"
|
||||
android:translateY="6.423">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,12 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M 6.49,16.6 L 2.58,12.0 L 6.489999999999998,7.4 L 5.3,6.0 L 0.19999999999999996,12.0 L 5.3,18.0 L 6.49,16.6 M 17.51,16.6 L 21.42,12.000000000000002 L 17.51,7.400000000000002 L 18.700000000000003,6.0 L 23.8,12.0 L 18.700000000000003,18.0 L 17.51,16.6"/>
|
||||
<group
|
||||
android:scaleX="0.04"
|
||||
android:scaleY="0.04"
|
||||
android:translateX="1.752"
|
||||
android:translateY="1.568">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,13 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.4,3.4H18.6A3,3 0 0 1 21.6,6.4V17.6A3,3 0 0 1 18.6,20.6H5.4A3,3 0 0 1 2.4,17.6V6.4A3,3 0 0 1 5.4,3.4Z M6.2,6H17.8A1.2,1.2 0 0 1 19,7.2V16.8A1.2,1.2 0 0 1 17.8,18H6.2A1.2,1.2 0 0 1 5,16.8V7.2A1.2,1.2 0 0 1 6.2,6Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M4.7,18L9,10.8L13,18Z"/>
|
||||
<group
|
||||
android:scaleX="0.043"
|
||||
android:scaleY="0.043"
|
||||
android:translateX="4.846"
|
||||
android:translateY="4.03">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,12 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.87,0 3.63,-0.52 5.13,-1.42l-0.9,-1.55C15,19.65 13.55,20 12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8v0.72c0,0.79 -0.71,1.78 -1.5,1.78s-1.5,-0.99 -1.5,-1.78V12c0,-2.76 -2.24,-5 -5,-5S7,9.24 7,12s2.24,5 5,5c1.38,0 2.64,-0.56 3.54,-1.47 0.65,0.89 1.77,1.47 2.96,1.47 1.97,0 3.5,-1.6 3.5,-3.78V12c0,-5.52 -4.48,-10 -10,-10zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M 12.0,2.0 C 6.48,2.0 2.0,6.48 2.0,12.0 C 2.0,17.52 6.48,22.0 12.0,22.0 C 13.870000000000001,22.0 15.629999999999999,21.48 17.13,20.58 L 16.23,19.029999999999998 C 15.0,19.65 13.55,20.0 12.0,20.0 C 7.58,20.0 4.0,16.42 4.0,12.0 C 4.0,7.579999999999998 7.58,4.0 12.0,4.0 C 16.42,4.0 20.0,7.58 20.0,12.0 L 20.0,12.72 C 20.0,13.510000000000002 19.29,14.5 18.5,14.5 C 17.71,14.5 17.0,13.51 16.5,12.72 C 14.3,12.5 14.0,15.0 17.7,17.0 C 20.47,17.0 22.0,15.4 22.0,13.22 L 22.0,12.0 C 22.0,6.48 17.52,2.0 12.0,2.0"/>
|
||||
<group
|
||||
android:scaleX="0.042"
|
||||
android:scaleY="0.042"
|
||||
android:translateX="1.822"
|
||||
android:translateY="1.15">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.7,1.5H18.3A4.2,4.2 0 0 1 22.5,5.7V13.3A4.2,4.2 0 0 1 18.3,17.5H5.7A4.2,4.2 0 0 1 1.5,13.3V5.7A4.2,4.2 0 0 1 5.7,1.5Z M 11.022353883106485,11.834219375500401 C 11.012842273819055,11.805332265812652 8.80720576461169,6.55386709367494 8.200928742994394,5.097534027221777 C 7.462546036829464,5.097534027221777 5.902994395516411,5.097534027221777 4.802818254603682,5.097534027221777 C 4.479775820656525,5.896156925540433 3.6466293034427535,7.9312890312249795 2.9779983987189746,9.570808646917534 C 3.3443714971977583,10.444819855884708 3.673402722177742,11.23005604483587 3.927397918334667,11.835628502802242 L 5.311160928742994,11.835628502802242 A 0.09441152922337871,0.09441152922337871 135.0 0,0 5.3995836669335455,11.724659727782228 C 5.387606084867893,11.445300240192156 4.627029623698958,10.429671737389915 4.853899119295435,9.331257005604485 C 4.914491593274619,9.094875900720577 4.960640512409927,8.854619695756606 5.0268694955964754,8.61929543634908 A 8.309623698959168,8.309623698959168 135.0 0,1 5.6623859087269794,6.974139311449161 C 5.704307445956763,6.894171337069657 5.7236829463570835,6.8582385908727 5.820912730184145,6.918831064851883 C 6.027349879903921,7.047413931144917 6.258094475580462,7.040016012810249 6.488134507606082,7.023458767013612 C 6.686116893514809,6.980480384307447 6.782642113690949,6.885364291433149 7.145844675740589,6.981889511609288 C 7.217710168134506,6.981889511609288 7.287814251401116,6.981889511609288 7.361088871096875,6.988582866293036 C 7.535468374699757,7.002321857485989 7.6992794235388295,7.043891112890313 7.803907125700558,7.201713370696559 C 7.918398718975178,7.37433146517214 7.936717373899116,7.569495596477183 7.918398718975178,7.768887109687752 C 7.890568454763809,8.067269815852683 7.9102962369895895,8.345220176140915 8.162882305844674,8.555532425940754 A 2.719263410728583,2.719263410728583 0.0 0,0 8.44646917534027,8.74470776621297 C 8.571881505204162,8.825028022417934 8.72301040832666,8.85990392313851 8.83045636509207,8.971577261809449 C 8.893867093674936,9.037453963170538 8.931208967173736,9.10720576461169 8.903730984787828,9.200912730184148 C 8.87625300240192,9.294619695756607 8.802978382706161,9.338654923939153 8.70645316253002,9.348871096877502 C 8.462674139311444,9.37529223378703 8.224179343474775,9.325620496397118 7.984627702161726,9.296733386709368 C 7.953274619695753,9.292858286629304 7.9226261008807,9.290744595676543 7.890216172938347,9.28863090472378 A 0.5421617293835068,0.5421617293835068 0.0 0,1 7.710200160128098,9.294619695756605 L 7.653835068054438,9.294619695756605 A 1.2622257806244996,1.2622257806244996 0.0 0,0 7.374827862289825,9.361200960768615 C 7.1490152121697275,9.44363490792634 6.943282626100874,9.534171337069656 6.676605284227376,9.52959167333867 A 0.2043234587670136,0.2043234587670136 0.0 0,0 6.579727782225774,9.571513210568455 C 6.370824659727775,9.757165732586069 6.2450600480384235,9.991785428342673 6.349335468374694,10.334555644515612 C 6.635740592473972,11.134939951961568 7.370952762209763,13.065796637309848 7.686597277822251,13.89894315452362 C 7.6777902321857425,13.334587670136111 7.675324259407519,12.323891112890314 7.673210568454758,11.83316253002402 C 8.934379503602877,11.834923939151324 10.868406725380298,11.834219375500401 11.02270616493194,11.834219375500401 L 11.022353883106485,11.834219375500401 M14.1,5.1H19.4A0.8,0.8 0 0 1 20.2,5.9V5.9A0.8,0.8 0 0 1 19.4,6.7H14.1A0.8,0.8 0 0 1 13.3,5.9V5.9A0.8,0.8 0 0 1 14.1,5.1Z M14.1,8.7H19.4A0.8,0.8 0 0 1 20.2,9.5V9.5A0.8,0.8 0 0 1 19.4,10.3H14.1A0.8,0.8 0 0 1 13.3,9.5V9.5A0.8,0.8 0 0 1 14.1,8.7Z M14.1,12.3H19.4A0.8,0.8 0 0 1 20.2,13.1V13.1A0.8,0.8 0 0 1 19.4,13.9H14.1A0.8,0.8 0 0 1 13.3,13.1V13.1A0.8,0.8 0 0 1 14.1,12.3Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M6,16.5L6,22.3L11.6,16.5Z"/>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z M 16.525148118494798,14.52599679743795 C 16.514447558046438,14.49349879903923 14.033106485188151,8.585600480384304 13.351044835868693,6.947225780624498 C 12.520364291433145,6.947225780624498 10.765868694955962,6.947225780624498 9.528170536429144,6.947225780624498 C 9.16474779823859,7.845676541232986 8.227457966373098,10.1352001601281 7.475248198558846,11.979659727782225 C 7.887417934347478,12.962922337870296 8.257578062449959,13.846313050440353 8.543322658126499,14.527582065652522 L 10.100056044835867,14.527582065652522 A 0.10621297037630105,0.10621297037630105 135.0 0,0 10.199531625300239,14.402742193755005 C 10.18605684547638,14.088462770216173 9.330408326661328,12.945880704563653 9.585636509207365,11.710164131305046 C 9.653803042433946,11.44423538831065 9.705720576461168,11.173947157726182 9.780228182546036,10.909207365892716 A 9.348326661329063,9.348326661329063 135.0 0,1 10.495184147317852,9.058406725380305 C 10.54234587670136,8.968442754203362 10.56414331465172,8.928018414731785 10.673526821457164,8.996184947958367 C 10.905768614891914,9.140840672538031 11.165356285028022,9.13251801441153 11.424151321056843,9.113891112890313 C 11.646881505204162,9.065540432345877 11.755472377902318,8.958534827862291 12.164075260208163,9.06712570056045 C 12.244923939151318,9.06712570056045 12.323791032826257,9.06712570056045 12.406224979983985,9.074655724579664 C 12.602401921537227,9.090112089671738 12.786689351481185,9.136877502001601 12.904395516413128,9.314427542033627 C 13.033198558847076,9.508622898318656 13.053807045636507,9.72818254603683 13.033198558847076,9.95249799839872 C 13.001889511609285,10.288178542834268 13.024083266613289,10.600872698158527 13.308242594075258,10.837473979183347 A 3.059171337069656,3.059171337069656 0.0 0,0 13.627277822257804,11.050296236989592 C 13.768366693354682,11.140656525220177 13.938386709367492,11.179891913530826 14.05926341072858,11.305524419535628 C 14.130600480384304,11.379635708566854 14.172610088070453,11.45810648518815 14.141697357886306,11.563526821457165 C 14.11078462770216,11.66894715772618 14.028350680544433,11.718486789431546 13.919759807846273,11.729979983987189 C 13.645508406725375,11.759703763010407 13.377201761409124,11.703823058446757 13.10770616493194,11.671325060048037 C 13.072433947157721,11.666965572457965 13.037954363490789,11.664587670136108 13.00149319455564,11.66220976781425 A 0.6099319455564451,0.6099319455564451 0.0 0,1 12.79897518014411,11.66894715772618 L 12.735564451561242,11.66894715772618 A 1.420004003202562,1.420004003202562 0.0 0,0 12.421681345076054,11.743851080864692 C 12.167642113690945,11.836589271417132 11.936192954363484,11.938442754203365 11.636180944755798,11.933290632506003 A 0.2298638911128903,0.2298638911128903 0.0 0,0 11.527193755003996,11.98045236188951 C 11.292177742193747,12.189311449159325 11.150692554043227,12.453258606885507 11.26800240192153,12.838875100080063 C 11.59020816653322,13.739307445956763 12.417321857485982,15.91152121697358 12.772421937550034,16.84881104883907 C 12.76251401120896,16.21391112890312 12.75973979183346,15.076877502001603 12.757361889511603,14.524807846277023 C 14.176176941553237,14.526789431545238 16.35195756605284,14.52599679743795 16.525544435548433,14.52599679743795 L 16.525148118494798,14.52599679743795"/>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
|
||||
<group
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9"
|
||||
android:translateX="-1.5"
|
||||
android:translateY="4.6">
|
||||
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.052"
|
||||
android:scaleY="0.052"
|
||||
android:translateX="3.65"
|
||||
android:translateY="-6.25">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
|
||||
<group
|
||||
android:scaleX="1.15"
|
||||
android:scaleY="1.15"
|
||||
android:translateX="-1.8"
|
||||
android:translateY="-1.8">
|
||||
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.029"
|
||||
android:scaleY="0.029"
|
||||
android:translateX="4.621"
|
||||
android:translateY="4.489">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
|
||||
<group
|
||||
android:scaleX="1.15"
|
||||
android:scaleY="1.15"
|
||||
android:translateX="-7.05"
|
||||
android:translateY="-1.95">
|
||||
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.042"
|
||||
android:scaleY="0.042"
|
||||
android:translateX="7.35"
|
||||
android:translateY="-4.5">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -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>
|
||||
@@ -2533,9 +2563,7 @@
|
||||
<string name="relay_group_browse_popular">Oblíbené relaye</string>
|
||||
<string name="relay_group_no_messages_yet">Zatím žádné zprávy</string>
|
||||
<string name="channel_invite_title">Přidáno do %1$s</string>
|
||||
<string name="channel_invite_body">%1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách?</string>
|
||||
<string name="channel_invite_unknown_actor">Někdo</string>
|
||||
<string name="channel_invite_accept">Zobrazit</string>
|
||||
<string name="channel_invite_ignore">Ignorovat</string>
|
||||
<string name="channel_invite_leave">Opustit</string>
|
||||
<string name="relay_group_join_to_post">Připojte se k této skupině pro odesílání zpráv.</string>
|
||||
@@ -2605,6 +2633,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 +3428,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 +3614,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 +4696,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>
|
||||
@@ -2447,9 +2472,7 @@
|
||||
<string name="relay_group_browse_popular">Beliebte Relays</string>
|
||||
<string name="relay_group_no_messages_yet">Noch keine Nachrichten</string>
|
||||
<string name="channel_invite_title">Zu %1$s hinzugefügt</string>
|
||||
<string name="channel_invite_body">%1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen?</string>
|
||||
<string name="channel_invite_unknown_actor">Jemand</string>
|
||||
<string name="channel_invite_accept">Anzeigen</string>
|
||||
<string name="channel_invite_ignore">Ignorieren</string>
|
||||
<string name="channel_invite_leave">Verlassen</string>
|
||||
<string name="relay_group_join_to_post">Tritt dieser Gruppe bei, um Nachrichten zu senden.</string>
|
||||
@@ -2509,6 +2532,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 +3307,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 +4612,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>
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
<string name="concord_leave_message">क्या %1$s छोडें। इसे हटाया जाएगा इस लेखा की सूची से तथा आपके यन्त्रों पर समचरणीकरण रुक जाएगा। समुदाय को सूचित नहीं किया जाएगा। तथा आपको उसके सदस्य कार्यसूची से हटाया नहीं जाएगा। सन्देश जिनका आप अरहस्यीकरण नहीं कर सकेंगे सम्भाव्यतः पुनःप्राप्तव्य नहीं होंगे। तथा आप केवल नए आमन्त्रण के साथ लौट सकेंगे।</string>
|
||||
<string name="concord_leave_owner_warning">आपने इस समुदाय को बनाया। छोड जाने से यह मिटेगा नहीं। किसी अन्य के हाथ सौंपा नहीं जाएगा। परन्तु स्वत्वधारी कुंचिका जो आपकी सूची में हैं वह हटाया जाएगा। आप आगे से इसका प्रबन्धन नहीं कर पाएँगे।</string>
|
||||
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत पत्रों का प्रकाशन तथा पठन किया जाता है।</string>
|
||||
<string name="concord_dissolved_read_only">इस समुदाय को विघटित किया गया है तथा अब पठनेवशक्य है। आप इसका इतिहास पढ सकते हैं परन्तु कोई नए सन्देश नहीं भेज सकते।</string>
|
||||
<string name="concord_typing_one">%1$s टंकण मध्य…</string>
|
||||
<string name="concord_typing_two">%1$s तथा %2$s टंकण मध्य…</string>
|
||||
<string name="concord_typing_many">अनेक लोग टंकण मध्य…</string>
|
||||
@@ -437,6 +438,8 @@
|
||||
<string name="refresh">नवीकरण</string>
|
||||
<string name="changed_chat_profile_to">नया चर्चा परिचय :</string>
|
||||
<string name="leave">निर्गमन</string>
|
||||
<string name="remove_from_messages">सन्देशों से हटाएँ</string>
|
||||
<string name="add_to_messages">सन्देशों में जोडें</string>
|
||||
<string name="unfollow">अनुचरण ना करें</string>
|
||||
<string name="channel_created">प्रणाली बनायी गयी</string>
|
||||
<string name="channel_information_changed_to">"प्रणाली जानकारी परिवर्तित की गयी"</string>
|
||||
@@ -2133,6 +2136,26 @@
|
||||
<string name="settings_section_tor_routing">टोर॰ के माध्यम से</string>
|
||||
<string name="settings_section_profile_sections">विभाग तथा सूचनावलियाँ</string>
|
||||
<string name="settings_section_home_tabs">दृश्य पृष्ठसूचक</string>
|
||||
<string name="settings_section_home_content_types">सूचनावली में विषयवस्तु</string>
|
||||
<string name="home_content_type_text_notes">लेखीय टीकाएँ</string>
|
||||
<string name="home_content_type_reposts">पुनःप्रकाशन</string>
|
||||
<string name="home_content_type_comments">टिप्पणियाँ तथा प्रत्युत्तर</string>
|
||||
<string name="home_content_type_articles">लेख</string>
|
||||
<string name="home_content_type_wiki">समूहसम्पाद्य पृष्ठ</string>
|
||||
<string name="home_content_type_highlights">प्रमुखताएँ</string>
|
||||
<string name="home_content_type_polls">मतदान</string>
|
||||
<string name="home_content_type_classifieds">वर्गीकृत विज्ञापन</string>
|
||||
<string name="home_content_type_voice">ध्वनि सन्देश</string>
|
||||
<string name="home_content_type_live_activities">वर्तमान गतिविधियाँ</string>
|
||||
<string name="home_content_type_ephemeral_chat">अस्थायी चर्चाएँ</string>
|
||||
<string name="home_content_type_interactive_stories">अन्तरक्रियाशील कहानियाँ</string>
|
||||
<string name="home_content_type_chess">चतुरंग खेल</string>
|
||||
<string name="home_content_type_birds">पक्षी अवलोकन घटनाएँ</string>
|
||||
<string name="home_content_type_attestations">साक्ष्यांकन सूची</string>
|
||||
<string name="home_content_type_nips">निप॰ पाण्डुलिपियाँ</string>
|
||||
<string name="home_content_type_music">संगीत तथा ध्वनि</string>
|
||||
<string name="home_content_type_podcasts">पुटप्रसार</string>
|
||||
<string name="home_content_type_fundraisers">धनसंग्रहण</string>
|
||||
<string name="settings_section_reminders">अनुस्मारकसूची</string>
|
||||
<string name="wallet_connect">धनकोष संयोजन</string>
|
||||
<string name="language">भाषा</string>
|
||||
@@ -2365,6 +2388,10 @@
|
||||
<string name="relay_group_edit_confirm">अभिलेखन</string>
|
||||
<string name="relay_group_menu_members">सदस्य सूची</string>
|
||||
<string name="relay_group_menu_edit">समूह सम्पादन</string>
|
||||
<string name="relay_group_delete">समूह मिटाएँ</string>
|
||||
<string name="relay_group_delete_confirm">क्या %1$s को मिटा दें। यह इस समूह को तथा इसके इतिहास को हटा देगा सब के लिए। इसे पूर्ववत नहीं किया जा सकता।</string>
|
||||
<string name="buzz_channel_delete">प्रणाली मिटाएँ</string>
|
||||
<string name="buzz_channel_delete_confirm">क्या %1$s को मिटा दें। यह इस प्रणाली को तथा इसके सन्देशों को हटा देगा सब के लिए। इसे पूर्ववत नहीं किया जा सकता।</string>
|
||||
<string name="relay_group_threads_title">सूत्र</string>
|
||||
<string name="relay_group_pin_message">सन्देश टाँकें</string>
|
||||
<string name="relay_group_unpin_message">सन्देश से टाँका हटाएँ</string>
|
||||
@@ -2446,9 +2473,7 @@
|
||||
<string name="relay_group_browse_popular">लोकप्रिय पुनःप्रसारक</string>
|
||||
<string name="relay_group_no_messages_yet">कोई सन्देश नहीं अब तक</string>
|
||||
<string name="channel_invite_title">%1$s में जोडा गया</string>
|
||||
<string name="channel_invite_body">%1$s ने आपको इस प्रणाली में जोडा %2$s पर। क्या इसे सन्देशों में दिखाया जाए।</string>
|
||||
<string name="channel_invite_unknown_actor">कोई व्यक्ति</string>
|
||||
<string name="channel_invite_accept">दिखाएँ</string>
|
||||
<string name="channel_invite_ignore">उपेक्षा करें</string>
|
||||
<string name="channel_invite_leave">छोडें</string>
|
||||
<string name="relay_group_join_to_post">इस समूह से जुडें सन्देशों को भेजने के लिए।</string>
|
||||
@@ -2509,6 +2534,14 @@
|
||||
<string name="share_target_as_dm">सीधेसन्देश के रूप में भेजें</string>
|
||||
<string name="share_to_dm_title">को भेजें…</string>
|
||||
<string name="share_to_dm_start_new">नया सन्देश</string>
|
||||
<string name="share_target_as_highlight">नया उद्दीप्तव्य</string>
|
||||
<string name="new_highlight_title">नया उद्दीप्तव्य</string>
|
||||
<string name="new_highlight_passage_label">उद्दीप्त लेख</string>
|
||||
<string name="new_highlight_passage_placeholder">क्या था प्रमुख आपके लिए।</string>
|
||||
<string name="new_highlight_source_label">स्रोत जालपता</string>
|
||||
<string name="new_highlight_note_label">आपकी टीका (विकल्पात्मक)</string>
|
||||
<string name="new_highlight_note_placeholder">आपके विचार जोडें…</string>
|
||||
<string name="highlight_action">उद्दीप्तव्य</string>
|
||||
<string name="copy_url_to_clipboard">टाँकाफलक में जालपता की अनुकृति करें</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">टाँकाफलक में टीका विभेदक की अनुकृति करें</string>
|
||||
<string name="add_media_to_gallery">अभिलेख को चित्रालय में जोडें</string>
|
||||
@@ -3276,6 +3309,10 @@
|
||||
<string name="buzz_pin">प्रणाली टाँकें</string>
|
||||
<string name="buzz_unpin">प्रणाली टाँका हटाएँ</string>
|
||||
<string name="buzz_dm_section_empty">कोई संवाद नहीं अब तक</string>
|
||||
<plurals name="buzz_dm_hidden_count">
|
||||
<item quantity="one">%1$d छिपाया गया संवाद</item>
|
||||
<item quantity="other">%1$d छिपाए गए संवाद</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">देखें %1$d और संवाद</item>
|
||||
<item quantity="other">देखें सभी %1$d संवाद</item>
|
||||
@@ -3452,6 +3489,7 @@
|
||||
<string name="git_repo_settings_topics">विषय (अल्पविराम विभाजित)</string>
|
||||
<string name="git_repo_settings_save">अभिलेखन</string>
|
||||
<string name="git_repositories">गिट क्रमलेखकोश</string>
|
||||
<string name="highlights">प्रमुखताएँ</string>
|
||||
<string name="nsite_title">नोस्टरस्थान : %1$s</string>
|
||||
<string name="napplet_card_title">नोस्टर संलग्नक्रमक : %1$s</string>
|
||||
<string name="napplet_card_permissions">अनुमतियाँ :</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -341,6 +341,7 @@
|
||||
<string name="concord_leave_message">Chcesz opuścić %1$s? Zostaniesz usunięty z listy członków tej społeczności, a synchronizacja z Twoimi urządzeniami zostanie wstrzymana. Społeczność nie zostanie o tym powiadomiona, a Ty nie zostaniesz usunięty z listy jej członków. Wiadomości, których nie będziesz już mógł odszyfrować, mogą okazać się nie do odzyskania, a powrót do społeczności będzie możliwy wyłącznie po otrzymaniu nowego zaproszenia.</string>
|
||||
<string name="concord_leave_owner_warning">To Ty stworzyłeś tę społeczność. Odejście nie powoduje jej usunięcia ani przekazania komukolwiek innemu, ale powoduje usunięcie klucza właściciela przechowywanego na Twojej liście — nie będziesz mógł już nią zarządzać.</string>
|
||||
<string name="concord_edit_relays_desc">Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności.</string>
|
||||
<string name="concord_dissolved_read_only">Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości.</string>
|
||||
<string name="concord_typing_one">%1$s pisze…</string>
|
||||
<string name="concord_typing_two">%1$s i %2$s piszą…</string>
|
||||
<string name="concord_typing_many">Kilka osób pisze…</string>
|
||||
@@ -457,6 +458,8 @@
|
||||
<string name="refresh">Odśwież</string>
|
||||
<string name="changed_chat_profile_to">Nowy profil czatu:</string>
|
||||
<string name="leave">Wyjdź</string>
|
||||
<string name="remove_from_messages">Usuń z wiadomości</string>
|
||||
<string name="add_to_messages">Dodaj do wiadomości</string>
|
||||
<string name="unfollow">Nie obserwuj</string>
|
||||
<string name="channel_created">Kanał utworzony</string>
|
||||
<string name="channel_information_changed_to">"Informacje o kanale zmienione na"</string>
|
||||
@@ -790,6 +793,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="workout_suggestion_connect_button">Połącz</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="workout_from_health_connect">Z Health Connect</string>
|
||||
<plurals name="workout_suggestion_combined_sessions">
|
||||
<item quantity="one">%1$d aktywność</item>
|
||||
<item quantity="few">%1$d aktywności</item>
|
||||
<item quantity="many">%1$d aktywności</item>
|
||||
<item quantity="other">%1$d aktywności</item>
|
||||
</plurals>
|
||||
<string name="exercise_running">Bieganie</string>
|
||||
<string name="exercise_walking">Spacer</string>
|
||||
<string name="exercise_cycling">Jazda na rowerze</string>
|
||||
@@ -2209,6 +2218,29 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="settings_section_tor_routing">Przejście przez Tor</string>
|
||||
<string name="settings_section_profile_sections">Sekcje & Wątki</string>
|
||||
<string name="settings_section_home_tabs">Widoczne karty</string>
|
||||
<string name="settings_section_home_content_types">Zawartość kanału</string>
|
||||
<string name="home_content_type_text_notes">Notatki tekstowe</string>
|
||||
<string name="home_content_type_reposts">Reposty</string>
|
||||
<string name="home_content_type_comments">Komentarze & odpowiedzi</string>
|
||||
<string name="home_content_type_pictures">Zdjęcia</string>
|
||||
<string name="home_content_type_videos">Filmy wideo</string>
|
||||
<string name="home_content_type_shorts">Filmiki</string>
|
||||
<string name="home_content_type_articles">Artykuły</string>
|
||||
<string name="home_content_type_wiki">Strony Wiki</string>
|
||||
<string name="home_content_type_highlights">Wyróżnienia</string>
|
||||
<string name="home_content_type_polls">Ankiety</string>
|
||||
<string name="home_content_type_classifieds">Ogłoszenia</string>
|
||||
<string name="home_content_type_torrents">Torrenty</string>
|
||||
<string name="home_content_type_voice">Wiadomości głosowe</string>
|
||||
<string name="home_content_type_live_activities">Bieżąca aktywność</string>
|
||||
<string name="home_content_type_ephemeral_chat">Czaty efemeryczne</string>
|
||||
<string name="home_content_type_interactive_stories">Interaktywne opowieści</string>
|
||||
<string name="home_content_type_chess">Szachy</string>
|
||||
<string name="home_content_type_attestations">Świadectwa</string>
|
||||
<string name="home_content_type_nips">Szkice NIP</string>
|
||||
<string name="home_content_type_music">Muzyka & audio</string>
|
||||
<string name="home_content_type_podcasts">Podcasty</string>
|
||||
<string name="home_content_type_fundraisers">Zbiórki funduszy</string>
|
||||
<string name="settings_section_reminders">Przypomnienia</string>
|
||||
<string name="wallet_connect">Podłącz portfel</string>
|
||||
<string name="language">Język</string>
|
||||
@@ -2406,6 +2438,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="chat_type_ephemeral_desc">Lekkie pokoje, które nie utrzymują się w historii.</string>
|
||||
<string name="relay_group_create_title">Utwórz grupę</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">Nowy kanał</string>
|
||||
<string name="buzz_forum_create_title">Nowe forum</string>
|
||||
<string name="buzz_channel_flag_private">Kanał prywatny</string>
|
||||
<string name="buzz_channel_flag_private_desc">Ukryty na liście kanałów i dostępny wyłącznie na zaproszenie. Opcja „Wyłączone” oznacza, że każdy użytkownik tego transmitera może go znaleźć i dołączyć do niego.</string>
|
||||
<string name="buzz_channel_flag_forum">Kanał forum</string>
|
||||
<string name="buzz_channel_flag_forum_desc">Wpisy w wątku zamiast osi czasu czatu. Nie można tego później zmienić.</string>
|
||||
<string name="relay_group_relay_no_nip29">Ten transmiter nie obsługuje grup zgodnych ze standardem NIP-29. Grupa utworzona tutaj nie będzie działać — transmiter nie będzie zarządzał jej nazwą, członkami ani wiadomościami. Wybierz transmiter, który obsługuje grupy oparte na rekomendacjach.</string>
|
||||
<string name="relay_group_create_name">Nazwa grupy</string>
|
||||
<string name="relay_group_create_topic">Temat (opcjonalnie)</string>
|
||||
@@ -2440,6 +2478,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="relay_group_edit_confirm">Zapisz</string>
|
||||
<string name="relay_group_menu_members">Członkowie</string>
|
||||
<string name="relay_group_menu_edit">Edytuj grupę</string>
|
||||
<string name="relay_group_delete">Usuń grupę</string>
|
||||
<string name="relay_group_delete_confirm">Usunąć \"%1$s\"? To usuwa grupę i jej historię dla wszystkich i nie może być cofnięte.</string>
|
||||
<string name="buzz_channel_delete">Usuń kanał</string>
|
||||
<string name="buzz_channel_delete_confirm">Usunąć \"%1$s\"? To usuwa kanał i jego wiadomości dla wszystkich i nie może być cofnięte.</string>
|
||||
<string name="buzz_channel_archive">Archiwizuj kanał</string>
|
||||
<string name="buzz_channel_unarchive">Odarchiwizuj kanał</string>
|
||||
<string name="relay_group_threads_title">Wątki</string>
|
||||
<string name="relay_group_pin_message">Przypnij wiadomość</string>
|
||||
<string name="relay_group_unpin_message">Odepnij wiadomość</string>
|
||||
@@ -2502,6 +2546,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="relay_group_favorite_relay">Faworyzuj ten transmiter</string>
|
||||
<string name="relay_group_threads_empty">Brak wątków. Zacznij od przycisku +.</string>
|
||||
<!-- Same, where this viewer cannot start one: not a member, or a Buzz chat channel (forum posts belong to forum channels). -->
|
||||
<string name="relay_group_threads_empty_read_only">Brak wątków.</string>
|
||||
<string name="relay_group_threads_loading_older">Ładowanie starych wątków…</string>
|
||||
<string name="relay_group_threads_all_caught_up">Brak starszych wątków</string>
|
||||
<string name="relay_group_thread_new">Nowy wątek</string>
|
||||
@@ -2519,6 +2564,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="relay_group_browse_your_relays">Transmitery, których używasz</string>
|
||||
<string name="relay_group_browse_popular">Popularne transmitery</string>
|
||||
<string name="relay_group_no_messages_yet">Brak wiadomości</string>
|
||||
<string name="channel_invite_title">Dodano do %1$s</string>
|
||||
<string name="channel_invite_unknown_actor">Ktoś</string>
|
||||
<string name="channel_invite_ignore">Ignoruj</string>
|
||||
<string name="channel_invite_leave">Wyjdź</string>
|
||||
<string name="relay_group_join_to_post">Dołącz do tej grupy, aby wysyłać wiadomości.</string>
|
||||
<string name="relay_group_invite_only_to_post">Ta grupa jest dostępna wyłącznie dla zaproszonych osób — aby móc publikować posty, potrzebujesz zaproszenia.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
@@ -2587,6 +2636,14 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="share_target_as_dm">Wyślij jako DM</string>
|
||||
<string name="share_to_dm_title">Wyślij do…</string>
|
||||
<string name="share_to_dm_start_new">Nowa wiadomość</string>
|
||||
<string name="share_target_as_highlight">Nowe wyróżnienie</string>
|
||||
<string name="new_highlight_title">Nowe wyróżnienie</string>
|
||||
<string name="new_highlight_passage_label">Wyróżniony tekst</string>
|
||||
<string name="new_highlight_passage_placeholder">Co szczególnie zwróciło twoją uwagę?</string>
|
||||
<string name="new_highlight_source_label">Adres źródła</string>
|
||||
<string name="new_highlight_note_label">Twoja notatka (opcjonalnie)</string>
|
||||
<string name="new_highlight_note_placeholder">Dodaj swoje przemyślenia…</string>
|
||||
<string name="highlight_action">Wyróżnienie</string>
|
||||
<string name="copy_url_to_clipboard">Skopiuj adres do schowka</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string>
|
||||
<string name="add_media_to_gallery">Dodaj pliki do Galerii</string>
|
||||
@@ -3259,7 +3316,28 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="buzz_message_edited">(edytowane)</string>
|
||||
<string name="buzz_diff_truncated">(diff skrócony)</string>
|
||||
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
|
||||
<string name="buzz_system_member_joined">%1$s dołączył(a)</string>
|
||||
<string name="buzz_system_member_added">%1$s został(a) dodany(a) przez %2$s</string>
|
||||
<string name="buzz_system_member_left">%1$s zostało</string>
|
||||
<string name="buzz_system_member_removed">%1$s został usunięty przez %2$s</string>
|
||||
<string name="buzz_system_topic_changed">%1$s ustawił(a) temat na \"%2$s\"</string>
|
||||
<string name="buzz_system_topic_cleared">%1$s wyczyszczono temat</string>
|
||||
<string name="buzz_system_purpose_changed">%1$s ustawił cel na \"%2$s\"</string>
|
||||
<string name="buzz_system_purpose_cleared">%1$s doprecyzował cel</string>
|
||||
<string name="buzz_system_visibility_open">%1$s otworzył ten kanał — każdy może go znaleźć i dołączyć do niego</string>
|
||||
<string name="buzz_system_visibility_private">%1$s ustawił ten kanał jako prywatny — dostępny tylko dla zaproszonych osób</string>
|
||||
<string name="buzz_system_visibility_other">%1$s zmieniono widoczność na %2$s</string>
|
||||
<string name="buzz_system_ttl_set">%1$s ustawił wiadomości do zniknięcia po %2$s</string>
|
||||
<string name="buzz_system_ttl_cleared">%1$s wyłączył(a) funkcję znikających wiadomości</string>
|
||||
<string name="buzz_system_channel_archived">%1$s zarchiwizował ten kanał</string>
|
||||
<string name="buzz_system_channel_unarchived">%1$s przywrócił ten kanał</string>
|
||||
<string name="buzz_system_channel_created">%1$s utworzył ten kanał</string>
|
||||
<string name="buzz_system_channel_deleted">%1$s usunął ten kanał</string>
|
||||
<string name="buzz_system_message_deleted">%1$s usunął wiadomość</string>
|
||||
<string name="buzz_system_message_deleted_reason">%1$s usunął wiadomość: %2$s</string>
|
||||
<string name="buzz_system_dm_created">%1$s rozpoczął tę rozmowę</string>
|
||||
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
|
||||
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
|
||||
<!-- Buzz forum votes (kind 45002). -->
|
||||
@@ -3519,6 +3597,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="git_repo_settings_topics">Tematy (oddzielone przecinkami)</string>
|
||||
<string name="git_repo_settings_save">Zapisz</string>
|
||||
<string name="git_repositories">Repozytoria Git</string>
|
||||
<string name="highlights">Wyróżnienia</string>
|
||||
<string name="nsite_title">Statyczna Witryna: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Uprawnienia:</string>
|
||||
@@ -3916,7 +3995,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="kind_git_pr_update">Aktualizacja PR</string>
|
||||
<string name="kind_zap_goals">Cele Zap-a</string>
|
||||
<string name="kind_hashtag_follows">Obserwowane hashtagi</string>
|
||||
<string name="kind_highlights">Najważniejsze informacje</string>
|
||||
<string name="kind_highlights">Wyróżnienia</string>
|
||||
<string name="kind_http_auth">Autoryzacja http</string>
|
||||
<string name="kind_index_relay_list">Indeks listy transmiterów</string>
|
||||
<string name="kind_adventure_prologue">Wstęp do przygody</string>
|
||||
@@ -4600,6 +4679,51 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="new_conversation_location_pro_2">Kompatybilny z Bitchatem; dociera do pobliskich użytkowników na tych samych transmiterach.</string>
|
||||
<string name="new_conversation_location_con_1">Publiczny i efemeryczny – brak historii, każdy uczestnik może go odczytać.</string>
|
||||
<!-- Buzz workflow run board -->
|
||||
<string name="buzz_workflow_section_active">Pracuje teraz</string>
|
||||
<string name="buzz_workflow_section_shipped">Wysłano</string>
|
||||
<string name="buzz_workflow_section_closed">Zamknięte</string>
|
||||
<string name="buzz_workflow_gate_needs_you">Wymaga zatwierdzenia</string>
|
||||
<string name="buzz_workflow_gate_awaiting">Oczekiwanie na zatwierdzenie</string>
|
||||
<string name="buzz_workflow_no_description">(brak opisu)</string>
|
||||
<string name="buzz_workflow_by">przez</string>
|
||||
<string name="buzz_workflow_cancel">Anuluj</string>
|
||||
<string name="buzz_workflow_trigger_failed_toast">Nie udało się uruchomić zadania — sprawdź, czy możesz publikować posty w tej przestrzeni roboczej</string>
|
||||
<string name="buzz_workflow_approved_toast">Zatwierdzono — autor tworzy pull request</string>
|
||||
<string name="buzz_workflow_denied_toast">Odmowa – prace zostały odrzucone</string>
|
||||
<string name="buzz_workflow_decision_failed_toast">Nie można opublikować Twojej decyzji — sprawdź, czy możesz publikować posty w tej przestrzeni roboczej</string>
|
||||
<string name="buzz_workflow_view_pr">Zobacz PR</string>
|
||||
<string name="buzz_workflow_closed_denied">Odmowa – odrzucona praca</string>
|
||||
<string name="buzz_workflow_closed_cancelled">Anulowano</string>
|
||||
<string name="buzz_workflow_closed_failed_prefix">Nieudane: %1$s</string>
|
||||
<string name="buzz_workflow_closed_failed">Niepowodzenie</string>
|
||||
<string name="buzz_workflow_pill_queued">W kolejce</string>
|
||||
<string name="buzz_workflow_pill_working">Pracuję</string>
|
||||
<string name="buzz_workflow_pill_needs_approval">Wymaga zatwierdzenia</string>
|
||||
<string name="buzz_workflow_pill_approved">Zatwierdzono</string>
|
||||
<string name="buzz_workflow_pill_shipped">Wysłano</string>
|
||||
<string name="buzz_workflow_pill_failed">Niepowodzenie</string>
|
||||
<string name="buzz_workflow_pill_cancelled">Anulowano</string>
|
||||
<string name="buzz_workflow_pill_denied">Odmowa</string>
|
||||
<string name="buzz_workflow_trigger_title">Uruchom proces</string>
|
||||
<string name="buzz_workflow_trigger_desc">Program wykonuje zadanie, a następnie wstrzymuje się, czekając na zatwierdzenie przez użytkownika, zanim utworzy zgłoszenie PR. Cały kanał ma wgląd w przebieg zadania.</string>
|
||||
<string name="buzz_workflow_no_defs_hint">Nie ma jeszcze żadnych przepływów pracy. Otwórz menu powyżej i wybierz opcję „Nowa definicja…”, aby utworzyć przepływ pracy, a następnie go uruchom.</string>
|
||||
<string name="buzz_workflow_task_label">Co należy zrobić?</string>
|
||||
<string name="buzz_workflow_trigger_run">Uruchom</string>
|
||||
<string name="buzz_workflow_picker_label">Proces</string>
|
||||
<string name="buzz_workflow_picker_empty">Nie zdefiniowano jeszcze procesów</string>
|
||||
<string name="buzz_workflow_picker_choose">Wybierz proces</string>
|
||||
<string name="buzz_workflow_new_definition">Nowa definicja…</string>
|
||||
<string name="buzz_workflow_def_title">Nowa definicja procesu</string>
|
||||
<string name="buzz_workflow_def_desc">Nadaje nazwę dla kanału i publikuje jego recepturę YAML (kind-30620). Rzeczywisty transmiter Buzz uruchamia YAML. W przypadku hostingu własnego moduł uruchamiający wykonuje skonfigurowane polecenie — w tym przypadku definicja służy jedynie do nazwania i skatalogowania uruchomienia.</string>
|
||||
<string name="buzz_workflow_def_name">Nazwa</string>
|
||||
<string name="buzz_workflow_def_name_hint">zbuduj i przetestuj</string>
|
||||
<string name="buzz_workflow_def_yaml">Receptura YAML</string>
|
||||
<string name="buzz_workflow_def_publish_failed">Nie można opublikować definicji — sprawdź czy możesz opublikować wpis w tym obszarze roboczym.</string>
|
||||
<string name="buzz_workflow_publishing">Publikowanie…</string>
|
||||
<string name="buzz_workflow_create_definition">Utwórz definicję</string>
|
||||
<string name="buzz_workflow_empty_title">Jeszcze nie uruchomiono</string>
|
||||
<string name="buzz_workflow_empty_desc">Naciśnij „Nowe uruchomienie” w celu uruchomienia procesu. Moduł wykonawczy realizuje zadanie i zatrzymuje się na etapie zatwierdzenia — przed wysłaniem jakichkolwiek danych zatwierdzenie musi udzielić osoba.</string>
|
||||
<!-- Buzz agent attestation -->
|
||||
<string name="buzz_attest_topbar">Świadectwa</string>
|
||||
<!-- Buzz agent persona editor -->
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
@@ -2445,9 +2473,7 @@
|
||||
<string name="relay_group_browse_popular">Relays populares</string>
|
||||
<string name="relay_group_no_messages_yet">Nenhuma mensagem ainda</string>
|
||||
<string name="channel_invite_title">Adicionado a %1$s</string>
|
||||
<string name="channel_invite_body">%1$s adicionou você a este canal em %2$s. Mostrar em Mensagens?</string>
|
||||
<string name="channel_invite_unknown_actor">Alguém</string>
|
||||
<string name="channel_invite_accept">Mostrar</string>
|
||||
<string name="channel_invite_ignore">Ignorar</string>
|
||||
<string name="channel_invite_leave">Sair</string>
|
||||
<string name="relay_group_join_to_post">Entre neste grupo para enviar mensagens.</string>
|
||||
@@ -2507,6 +2533,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 +3308,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 +3488,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>
|
||||
|
||||
@@ -2544,9 +2544,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="relay_group_browse_popular">Popularni releji</string>
|
||||
<string name="relay_group_no_messages_yet">Ni sporočil</string>
|
||||
<string name="channel_invite_title">Dodano v: %1$s</string>
|
||||
<string name="channel_invite_body">%1$s vas je dodal v ta kanal na %2$s. Želite to prikazati med sporočili?</string>
|
||||
<string name="channel_invite_unknown_actor">Nekdo</string>
|
||||
<string name="channel_invite_accept">Prikaži</string>
|
||||
<string name="channel_invite_ignore">Prezri</string>
|
||||
<string name="channel_invite_leave">Zapusti</string>
|
||||
<string name="relay_group_join_to_post">Pridružite se tej skupini, da boste lahko pošiljali sporočila.</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>
|
||||
@@ -2445,9 +2475,7 @@
|
||||
<string name="relay_group_browse_popular">Populära reläer</string>
|
||||
<string name="relay_group_no_messages_yet">Inga meddelanden ännu</string>
|
||||
<string name="channel_invite_title">Tillagd i %1$s</string>
|
||||
<string name="channel_invite_body">%1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden?</string>
|
||||
<string name="channel_invite_unknown_actor">Någon</string>
|
||||
<string name="channel_invite_accept">Visa</string>
|
||||
<string name="channel_invite_ignore">Ignorera</string>
|
||||
<string name="channel_invite_leave">Lämna</string>
|
||||
<string name="relay_group_join_to_post">Gå med i den här gruppen för att skicka meddelanden.</string>
|
||||
@@ -2507,6 +2535,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 +3310,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 +3490,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>
|
||||
@@ -2422,8 +2426,10 @@
|
||||
|
||||
<string name="hashtag_exclusive">Hashtag-exclusive Post</string>
|
||||
|
||||
<string name="external_content_title">External Content</string>
|
||||
<string name="external_url_scope">Comment on a website</string>
|
||||
<string name="external_id_scope">Comment on an external resource</string>
|
||||
<string name="url_preview_open_in_browser">Open in browser</string>
|
||||
|
||||
<string name="long_form_reading_minutes">%1$d min read</string>
|
||||
|
||||
@@ -2570,6 +2576,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 +2619,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>
|
||||
@@ -2693,9 +2702,7 @@
|
||||
<string name="relay_group_browse_popular">Popular relays</string>
|
||||
<string name="relay_group_no_messages_yet">No messages yet</string>
|
||||
<string name="channel_invite_title">Added to %1$s</string>
|
||||
<string name="channel_invite_body">%1$s added you to this channel on %2$s. Show it in Messages?</string>
|
||||
<string name="channel_invite_unknown_actor">Someone</string>
|
||||
<string name="channel_invite_accept">Show</string>
|
||||
<string name="channel_invite_ignore">Ignore</string>
|
||||
<string name="channel_invite_leave">Leave</string>
|
||||
<string name="relay_group_join_to_post">Join this group to send messages.</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())
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The two cases that motivated replacing the old `.m3u8`-substring predicate are
|
||||
* [recognisesAnExtensionlessBlossomPlaylistByMime] and [ignoresM3u8InAQueryString]; the rest pin the
|
||||
* behaviour that must not regress while fixing them.
|
||||
*/
|
||||
class IsHlsMediaTest {
|
||||
@Test
|
||||
fun recognisesAnExtensionlessBlossomPlaylistByMime() {
|
||||
// BUD-10: the URL is a bare sha256 with no extension, so the mime is the only signal. The
|
||||
// old substring predicate answered false here and the item was routed to the disk cache —
|
||||
// fatal for a live playlist.
|
||||
val blossom = "https://blossom.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
|
||||
assertTrue(isHlsMedia(blossom, "application/x-mpegurl"))
|
||||
assertTrue(isHlsMedia(blossom, "application/vnd.apple.mpegurl"))
|
||||
assertFalse("no mime and no extension leaves nothing to go on", isHlsMedia(blossom, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresM3u8InAQueryString() {
|
||||
// The other direction: progressive media permanently excluded from the cache because its
|
||||
// query string mentioned a playlist.
|
||||
assertFalse(isHlsMedia("https://host/video.mp4?ref=a.m3u8", null))
|
||||
assertFalse(isHlsMedia("https://host/video.mp4#a.m3u8", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recognisesAPlainM3u8Path() {
|
||||
assertTrue(isHlsMedia("https://host/live.m3u8", null))
|
||||
assertTrue(isHlsMedia("https://host/live.M3U8", null))
|
||||
assertTrue("query strings don't hide the path", isHlsMedia("https://host/live.m3u8?vt=abc", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExplicitMimeWins() {
|
||||
// A non-HLS mime is respected even on an .m3u8 path — the mime is the more specific signal,
|
||||
// and toExoPlayerMimeType only consults the path when no mime was supplied.
|
||||
assertFalse(isHlsMedia("https://host/odd.m3u8", "video/mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressiveMediaIsNotHls() {
|
||||
assertFalse(isHlsMedia("https://host/video.mp4", null))
|
||||
assertFalse(isHlsMedia("https://host/video.mp4", "video/mp4"))
|
||||
assertFalse(isHlsMedia("https://host/audio.m4a", "audio/mp4"))
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verbatim excerpt of a zap-stream-core LL-HLS media playlist (api-uk.zap.stream, 2026-07-26) — the
|
||||
* playlist that crashes media3 1.10.1 in `HlsMediaChunk.feedDataToExtractor`. The byte-range parts
|
||||
* are what produce the bounded chunk behind that crash.
|
||||
*/
|
||||
private val ZAP_STREAM_LL_PLAYLIST =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-PART-INF:PART-TARGET=0.49007290601730347
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:191184
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXT-X-SERVER-CONTROL:PART-HOLD-BACK=1.715,CAN-BLOCK-RELOAD=YES
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:32.611Z
|
||||
#EXTINF:1.961,
|
||||
191198.m4s
|
||||
#EXT-X-PART:URI="191199.m4s",DURATION=0.5009999999892898,INDEPENDENT=YES,BYTERANGE="359712@0"
|
||||
#EXT-X-PART:URI="191199.m4s",DURATION=0.5,BYTERANGE="153946@359712"
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:34.572Z
|
||||
#EXTINF:1.96,
|
||||
191199.m4s
|
||||
""".trimIndent()
|
||||
|
||||
class LowLatencyHlsStripperTest {
|
||||
@Test
|
||||
fun removesEveryLowLatencyTagFromARealPlaylist() {
|
||||
val result = stripLowLatencyTags(ZAP_STREAM_LL_PLAYLIST)
|
||||
|
||||
assertFalse("byte-range parts are the crash trigger", result.contains("#EXT-X-PART:"))
|
||||
assertFalse(result.contains("#EXT-X-PART-INF:"))
|
||||
assertFalse(result.contains("#EXT-X-SERVER-CONTROL:"))
|
||||
assertFalse("no BYTERANGE should survive", result.contains("BYTERANGE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsSegmentAndTagOrdering() {
|
||||
val result = stripLowLatencyTags(ZAP_STREAM_LL_PLAYLIST)
|
||||
|
||||
// Exact equality, so this also pins that everything a plain HLS player needs survives
|
||||
// untouched and in order: the header, MAP, PROGRAM-DATE-TIME, EXTINF and both segments.
|
||||
assertEquals(
|
||||
listOf(
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:6",
|
||||
"#EXT-X-TARGETDURATION:2",
|
||||
"#EXT-X-MEDIA-SEQUENCE:191184",
|
||||
"""#EXT-X-MAP:URI="init.mp4"""",
|
||||
"#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:32.611Z",
|
||||
"#EXTINF:1.961,",
|
||||
"191198.m4s",
|
||||
"#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:34.572Z",
|
||||
"#EXTINF:1.96,",
|
||||
"191199.m4s",
|
||||
),
|
||||
result.split("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAPlainPlaylistByteIdentical() {
|
||||
// The common case, on every playlist reload of every live stream: nothing to do.
|
||||
val plain =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:4230
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:2,
|
||||
4230.m4s
|
||||
""".trimIndent()
|
||||
|
||||
assertSame("unchanged playlists should not be rebuilt", plain, stripLowLatencyTags(plain))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesTrailingNewlineAndBlankLines() {
|
||||
val input = "#EXTM3U\n#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\n\n#EXTINF:2,\na.m4s\n"
|
||||
|
||||
assertEquals("#EXTM3U\n\n#EXTINF:2,\na.m4s\n", stripLowLatencyTags(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesCrLfLineEndings() {
|
||||
val input = "#EXTM3U\r\n#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\r\n#EXTINF:2,\r\na.m4s\r\n"
|
||||
|
||||
assertEquals("#EXTM3U\r\n#EXTINF:2,\r\na.m4s\r\n", stripLowLatencyTags(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsDeltaPlaylistAndRenditionReportTags() {
|
||||
// EXT-X-SKIP marks legitimately omitted segments; removing it would corrupt the playlist.
|
||||
// EXT-X-RENDITION-REPORT is inert once the parts are gone.
|
||||
val input =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXT-X-SKIP:SKIPPED-SEGMENTS=10
|
||||
#EXT-X-PART:URI="a.m4s",BYTERANGE="1@0"
|
||||
#EXT-X-RENDITION-REPORT:URI="../b/live.m3u8",LAST-MSN=42
|
||||
""".trimIndent()
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertTrue(result.contains("#EXT-X-SKIP:SKIPPED-SEGMENTS=10"))
|
||||
assertTrue(result.contains("#EXT-X-RENDITION-REPORT:"))
|
||||
assertFalse(result.contains("#EXT-X-PART:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripsPreloadHint() {
|
||||
// The *other* tag that yields a byte-range chunk, so it matters as much as EXT-X-PART.
|
||||
// Synthetic rather than folded into the capture above, which is labelled verbatim and did
|
||||
// not carry a hint; shaped per RFC 8216 §4.4.5.3.
|
||||
val input =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXTINF:1.96,
|
||||
191199.m4s
|
||||
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="191200.m4s",BYTERANGE-START=402501
|
||||
""".trimIndent()
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertFalse(result.contains("#EXT-X-PRELOAD-HINT"))
|
||||
assertFalse("no byte range should survive", result.contains("BYTERANGE-START"))
|
||||
assertEquals("#EXTM3U\n#EXTINF:1.96,\n191199.m4s", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byteFormPreservesBomAndMultibyteWhenStripping() {
|
||||
// Written as raw bytes rather than a "" literal so the fixture states the on-the-wire
|
||||
// encoding directly. 🎵 is a 4-byte sequence / surrogate pair, so it also covers the
|
||||
// decode-modify-re-encode round trip beyond the BMP.
|
||||
val bom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
|
||||
val input =
|
||||
bom +
|
||||
(
|
||||
"#EXTM3U\n" +
|
||||
"#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\n" +
|
||||
"#EXTINF:2,caffè 🎵\n" +
|
||||
"a.m4s\n"
|
||||
).toByteArray(Charsets.UTF_8)
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertArrayEquals(
|
||||
bom + "#EXTM3U\n#EXTINF:2,caffè 🎵\na.m4s\n".toByteArray(Charsets.UTF_8),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byteFormForwardsTheOriginalArrayWhenNothingToStrip() {
|
||||
val input = "#EXTM3U\n#EXTINF:2,\na.m4s\n".toByteArray(Charsets.UTF_8)
|
||||
|
||||
// Same array, not an equal copy: the common path must not re-encode.
|
||||
assertSame(input, stripLowLatencyTags(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotMatchTagsBySubstring() {
|
||||
// EXT-X-PARTY-TIME is fictional, but the point is that the match must be anchored: a bare
|
||||
// `contains("#EXT-X-PART")` without the colon would eat unrelated tags.
|
||||
val input = "#EXTM3U\n#EXT-X-PARTY-TIME:1\n#EXT-X-PART:URI=\"a.m4s\""
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertTrue(result.contains("#EXT-X-PARTY-TIME:1"))
|
||||
assertFalse(result.contains("#EXT-X-PART:"))
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
|
||||
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The "Can't reach this relay over Tor" banner used to be driven by a bare 6s timer, so it appeared on
|
||||
* a relay that was connected and delivering. These pin the gates that replaced it.
|
||||
*/
|
||||
class TorClearnetFallbackTest {
|
||||
private fun offer(
|
||||
usesTor: Boolean = true,
|
||||
torIsUp: Boolean = true,
|
||||
trustingMovesToClearnet: Boolean = true,
|
||||
disconnectedLongEnough: Boolean = true,
|
||||
) = shouldOfferTorClearnetFallback(usesTor, torIsUp, trustingMovesToClearnet, disconnectedLongEnough)
|
||||
|
||||
@Test
|
||||
fun offersWhenTorRoutedRelayStaysSilent() {
|
||||
assertTrue(offer())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhileTheRelayIsReachable() {
|
||||
// The regression: the socket is open and the relay is replying, so there is nothing to escape.
|
||||
// The screen models that as the debounce never completing.
|
||||
assertFalse(offer(disconnectedLongEnough = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhenTheRelayIsNotRoutedOverTor() {
|
||||
// Tor can be on globally while this relay is dialed over clearnet (onion/localhost/trusted
|
||||
// presets) — blaming Tor for that relay's silence would be wrong.
|
||||
assertFalse(offer(usesTor = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhileTorItselfIsStillBootstrapping() {
|
||||
// Nothing Tor-routed connects during bootstrap — that's Tor's failure, not the relay's.
|
||||
assertFalse(offer(torIsUp = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhenTrustingWouldNotChangeRouting() {
|
||||
assertFalse(offer(trustingMovesToClearnet = false))
|
||||
}
|
||||
|
||||
// --- the routing inputs the screen feeds the predicate, read off the real presets ---
|
||||
|
||||
private val relay = NormalizedRelayUrl("wss://buzz.example.com/")
|
||||
|
||||
private fun evaluation(preset: TorSettings) =
|
||||
TorRelayEvaluation(
|
||||
torSettings =
|
||||
TorRelaySettings(
|
||||
torType = preset.torType,
|
||||
onionRelaysViaTor = preset.onionRelaysViaTor,
|
||||
dmRelaysViaTor = preset.dmRelaysViaTor,
|
||||
newRelaysViaTor = preset.newRelaysViaTor,
|
||||
trustedRelaysViaTor = preset.trustedRelaysViaTor,
|
||||
moneyOperationsViaTor = preset.moneyOperationsViaTor,
|
||||
),
|
||||
trustedRelayList = emptySet(),
|
||||
dmRelayList = emptySet(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun defaultPresetRoutesANewRelayOverTorAndTrustingEscapesIt() {
|
||||
val eval = evaluation(torDefaultPreset)
|
||||
assertTrue(eval.useTor(relay))
|
||||
assertTrue(!eval.torSettings.trustedRelaysViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privacyPresetsKeepTrustedRelaysOnTorSoTheOfferIsWithheld() {
|
||||
// Both keep trusted relays on Tor: adding this relay to the Trusted list would move nothing,
|
||||
// so the banner must not offer it (the button would be inert).
|
||||
assertTrue(evaluation(torSmallPayloadsPreset).torSettings.trustedRelaysViaTor)
|
||||
assertTrue(evaluation(torFullyPrivate).torSettings.trustedRelaysViaTor)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -25,12 +25,19 @@ die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE"
|
||||
|
||||
# The PR base = the repo's default branch. Never operate on it directly.
|
||||
base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || echo main)"
|
||||
# `gh` can also succeed while printing nothing (or a literal "null") for a repo it can't
|
||||
# resolve, so fall back on the value, not just on the exit status.
|
||||
base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || true)"
|
||||
[[ -n "$base_branch" && "$base_branch" != "null" ]] || base_branch="main"
|
||||
case "$BUZZ_BRANCH" in
|
||||
"$base_branch" | main | master) die "refusing to operate on the default branch ($BUZZ_BRANCH)" ;;
|
||||
# Anything else is a per-run branch — the only thing this script is allowed to push.
|
||||
*) : ;;
|
||||
esac
|
||||
|
||||
title="$(git log -1 --format='%s' 2>/dev/null | cut -c1-72)"
|
||||
# `|| true` keeps `set -e` from killing the script when the worktree has no commits yet —
|
||||
# without it the fallback below is unreachable and the run fails with an empty stderr.
|
||||
title="$(git log -1 --format='%s' 2>/dev/null | cut -c1-72)" || true
|
||||
[[ -n "$title" ]] || title="Buzz run ${BUZZ_RUN:-}"
|
||||
|
||||
log "[workflow-ship] pushing $BUZZ_BRANCH"
|
||||
|
||||
@@ -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"]}"""))
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Login & Auth -->
|
||||
<string name="login_title">Welkom bij Amethyst</string>
|
||||
<string name="login_subtitle">Meld je aan bij je Nostr-account</string>
|
||||
<string name="login_subtitle_desktop">Een Nostr-client voor desktop</string>
|
||||
<string name="login_card_title">Inloggen met je Nostr-sleutel</string>
|
||||
<string name="login_card_subtitle">nsec voor volledige toegang, bunker:// voor remote signer, of npub voor alleen-lezen</string>
|
||||
<string name="login_with_key">Inloggen met sleutel</string>
|
||||
<string name="login_button">Inloggen</string>
|
||||
<string name="login_generate_new">Nieuwe sleutel genereren</string>
|
||||
<string name="login_generate_button">Nieuwe genereren</string>
|
||||
<string name="login_key_hint">Voer je privésleutel (nsec) of openbare sleutel (npub) in</string>
|
||||
<string name="login_key_label">nsec, bunker:// of npub</string>
|
||||
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
|
||||
<string name="login_show_key">Sleutel tonen</string>
|
||||
<string name="login_hide_key">Sleutel verbergen</string>
|
||||
<!-- New Key Warning -->
|
||||
<string name="new_key_warning_title">BELANGRIJK: Bewaar je sleutels!</string>
|
||||
<string name="new_key_warning_message">Je geheime sleutel (nsec) is de ENIGE manier om toegang te krijgen tot je account. Als je hem kwijtraakt, is je account voorgoed weg. Bewaar hem op een veilige plek!</string>
|
||||
<string name="new_key_public_label">Openbare sleutel (deelbaar):</string>
|
||||
<string name="new_key_secret_label">Geheime sleutel (deel dit NOOIT!):</string>
|
||||
<string name="new_key_continue_button">Ik heb mijn sleutels bewaard, doorgaan</string>
|
||||
<!-- Common Actions -->
|
||||
<string name="action_copy">Kopiëren</string>
|
||||
<string name="action_paste">Plakken</string>
|
||||
<string name="action_cancel">Annuleren</string>
|
||||
<string name="action_ok">OK</string>
|
||||
<string name="action_save">Opslaan</string>
|
||||
<string name="action_delete">Verwijderen</string>
|
||||
<string name="action_share">Delen</string>
|
||||
<!-- Errors -->
|
||||
<string name="error_invalid_key">Ongeldig sleutelformaat. Controleer het en probeer opnieuw.</string>
|
||||
<string name="error_network">Netwerkfout. Controleer je verbinding.</string>
|
||||
<string name="error_generic">Er is een fout opgetreden. Probeer het opnieuw.</string>
|
||||
<!-- Loading & Empty States -->
|
||||
<string name="action_refresh">Vernieuwen</string>
|
||||
<string name="action_try_again">Opnieuw proberen</string>
|
||||
<string name="feed_empty">Feed is empty</string>
|
||||
<string name="error_loading_feed">Error loading feed: %s</string>
|
||||
<!-- Placeholder Screens -->
|
||||
<string name="screen_search_title">Zoeken</string>
|
||||
<string name="screen_search_description">Zoek naar gebruikers, notes en hashtags.</string>
|
||||
<string name="screen_messages_title">Berichten</string>
|
||||
<string name="screen_messages_description">Je versleutelde directe berichten verschijnen hier.</string>
|
||||
<string name="screen_notifications_title">Meldingen</string>
|
||||
<string name="screen_notifications_description">Vermeldingen, antwoorden en reacties verschijnen hier.</string>
|
||||
<!-- Accessibility -->
|
||||
<string name="accessibility_user_avatar">Avatar van gebruiker</string>
|
||||
<string name="accessibility_navigate">Navigeren</string>
|
||||
<!-- Relay history paging (shared feed markers + status card) -->
|
||||
<string name="chats_history_loading_label">Laden:</string>
|
||||
<string name="chats_history_fully_loaded_label">Volledig geladen:</string>
|
||||
<string name="chats_history_fully_loaded">(volledig geladen)</string>
|
||||
<string name="chats_history_by_relay">Geschiedenis per relay</string>
|
||||
<string name="chats_history_stalled_retry">Wordt opnieuw geprobeerd als je dit scherm opnieuw opent</string>
|
||||
<string name="chats_history_older">Oudere %1$s berichten</string>
|
||||
<string name="chats_history_all_caught_up">Helemaal bij</string>
|
||||
<string name="chats_history_reached_start">Begin van je %1$s berichten bereikt</string>
|
||||
<string name="chats_history_subtitle">%1$s · %2$s · geladen sinds %3$s</string>
|
||||
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
|
||||
<string name="chats_history_waiting">waiting on %1$s</string>
|
||||
<string name="chats_history_incomplete">Sommige relays reageerden niet</string>
|
||||
<string name="chats_history_incomplete_sub">%1$s onbereikbaar · tik om te zien welke</string>
|
||||
<string name="chats_history_relays_title">%1$s · geschiedenis per relay</string>
|
||||
<string name="chats_history_relay_since">sinds %1$s</string>
|
||||
<string name="action_dismiss">Negeren</string>
|
||||
<plurals name="chats_history_relays">
|
||||
<item quantity="one">%1$d relay</item>
|
||||
<item quantity="other">%1$d relays</item>
|
||||
</plurals>
|
||||
<!-- Notes & Replies -->
|
||||
<string name="replying_to">replying to </string>
|
||||
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
|
||||
<string name="nsite_title">nSite: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_kind">nApplet</string>
|
||||
<string name="nsite_website_kind">nSite</string>
|
||||
<string name="napplet_card_permissions">Waar het toegang toe heeft</string>
|
||||
<string name="nsite_root_site">Root Site</string>
|
||||
<string name="nsite_source">Bron:</string>
|
||||
<string name="nsite_servers">Servers:</string>
|
||||
<string name="nsite_open">Openen</string>
|
||||
<!-- Custom emoji suggestions (NIP-30) -->
|
||||
<string name="use_direct_url">Directe URL gebruiken</string>
|
||||
<!-- Nicknames (NIP-85 contact cards) -->
|
||||
<string name="nickname_dialog_title">Bijnaam</string>
|
||||
<string name="nickname_dialog_explainer">Wordt aan jou getoond in plaats van de naam van deze gebruiker, overal in de app. Het wordt versleuteld opgeslagen in je contactkaart: alleen jij kunt het lezen. Typ : om je eigen emoji\'s te gebruiken.</string>
|
||||
<string name="nickname_label">Bijnaam</string>
|
||||
<string name="nickname_summary_label">Privénote over deze gebruiker</string>
|
||||
<string name="nickname_save">Opslaan</string>
|
||||
<string name="nickname_cancel">Annuleren</string>
|
||||
<string name="git_status_open">Openen</string>
|
||||
<string name="git_status_merged">Samengevoegd</string>
|
||||
<string name="git_status_closed">Gesloten</string>
|
||||
@@ -53,11 +131,16 @@
|
||||
<string name="road_event_traffic_jam">File</string>
|
||||
<string name="road_event_unknown">Weggebeurtenis</string>
|
||||
<string name="podcast_value_zap_split_hint">Zaps hiervoor worden verdeeld over:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_for_value">Value-for-Value</string>
|
||||
<string name="relay_monitor_rtt_open">Openen</string>
|
||||
<string name="relay_monitor_rtt_read">Lezen</string>
|
||||
<string name="relay_monitor_rtt_write">Schrijven</string>
|
||||
<string name="relay_monitor_network">Netwerk</string>
|
||||
<string name="relay_monitor_relay_type">Type</string>
|
||||
<string name="relay_monitor_requirements">Vereisten</string>
|
||||
<string name="relay_monitor_supported_nips">Ondersteunde NIPs</string>
|
||||
<string name="relay_monitor_ms">%1$d ms</string>
|
||||
<string name="relay_discovery_accepted_kinds">Geaccepteerde kinds</string>
|
||||
<string name="relay_discovery_geohash">Locatie</string>
|
||||
<string name="calendar_rsvp_going">Ga</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user