mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-31 20:16:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e01c8504 | ||
|
|
5231f5b051 | ||
|
|
0f4580ab91 | ||
|
|
cdef4e9658 | ||
|
|
51e388d2a1 | ||
|
|
9a5d47004c | ||
|
|
731f4369cb | ||
|
|
d93f4db451 | ||
|
|
50e61901f7 | ||
|
|
2360829e83 | ||
|
|
f09eef1470 | ||
|
|
78f0583b16 | ||
|
|
4234c4f45b | ||
|
|
a742cec495 | ||
|
|
3fe936bedb | ||
|
|
7cece7eafb | ||
|
|
dcf2807e75 | ||
|
|
ea2472468b | ||
|
|
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 | ||
|
|
b575f9078d | ||
|
|
52a8cc338a | ||
|
|
7927e1de1b | ||
|
|
55cc4ce9dc | ||
|
|
155ef8e46c | ||
|
|
9189955155 | ||
|
|
5b07817cf8 | ||
|
|
927cc9449c | ||
|
|
1f35339866 | ||
|
|
69c3e3accc |
@@ -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.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,808 @@
|
||||
# Location: foreground-gate the listener, trim the request, fix the meter
|
||||
|
||||
Date: 2026-07-29
|
||||
Module: `amethyst`
|
||||
Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY,
|
||||
Pixel 9a / Android 17)
|
||||
Revision: 2 — incorporates spec review of 2026-07-29
|
||||
|
||||
## Context
|
||||
|
||||
The 1.13.0 resource report showed **7.13 h of location listening against 9.1
|
||||
seconds of app foreground**, and the accompanying analysis called it the leading
|
||||
suspect for that day's 11 pp of background battery drain. Root cause given: 30
|
||||
`SharingStarted.Eagerly` top-nav filter states on the account scope
|
||||
(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because
|
||||
`AccountSettings.kt:256` ships that as the Products default. The `AroundMe`
|
||||
branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds
|
||||
`LocationState`'s `WhileSubscribed(5000)` open for the life of the process.
|
||||
|
||||
That chain is real. **The battery conclusion drawn from it is not**, and this
|
||||
spec is written against the measurement rather than the inference.
|
||||
|
||||
### What the device reports
|
||||
|
||||
`amethyst/src/main/AndroidManifest.xml:80` declares **only**
|
||||
`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no
|
||||
`ACCESS_BACKGROUND_LOCATION` — and no service declares
|
||||
`foregroundServiceType="location"` (the declared types are `mediaPlayback`,
|
||||
`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`).
|
||||
`targetSdk = 37`.
|
||||
|
||||
`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the
|
||||
`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate
|
||||
Location Provider Data* block — not system-wide totals:
|
||||
|
||||
| provider | registration held | **active** | **foreground** | fixes |
|
||||
|---|---|---|---|---|
|
||||
| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 |
|
||||
| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 |
|
||||
| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 |
|
||||
| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 |
|
||||
|
||||
Roughly **11 minutes of background-active location in three weeks**, and about
|
||||
100 delivered fixes per provider. With the app backgrounded at the time of the
|
||||
dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware:
|
||||
mStarted=false`.
|
||||
|
||||
The cleanest assumption-free comparison: the ledger's **two-day** `location.ms`
|
||||
total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total**
|
||||
(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies.
|
||||
|
||||
**One figure does not reconcile, and is left open.** Registration-held is
|
||||
9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day
|
||||
across the two ledger days. If `location.ms` measured subscription existence
|
||||
these should agree; they are ~2× apart. Candidates: segments open at process
|
||||
death are lost (the pre-flush hook does not run on a kill, and the report shows
|
||||
6 process starts across the two days); the ledger covers 2 days while dumpsys
|
||||
spans 21 with different usage. Not investigated. It does not affect the
|
||||
conclusion below, which rests on `location.ms` versus OS-*active* time, not
|
||||
versus registration-held.
|
||||
|
||||
### How far the "no background location" claim generalises
|
||||
|
||||
This matters because the "no behaviour change" argument rests on it, so it is
|
||||
scoped rather than asserted universally:
|
||||
|
||||
- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background
|
||||
access, and for `targetSdk ≥ 29` an FGS additionally needs
|
||||
`foregroundServiceType="location"`. Amethyst has neither, so background
|
||||
registrations are suspended. This is the case the Pixel 9a measurement above
|
||||
covers.
|
||||
- **API 26–28 (Android 8–9):** `ACCESS_BACKGROUND_LOCATION` does not exist.
|
||||
Amethyst *can* receive background location there, throttled by the platform to
|
||||
a few updates per hour. The gate is a genuine, if small, improvement on these
|
||||
releases rather than a no-op.
|
||||
|
||||
So "structural" applies to API 29+; on 26–28 the change has real effect.
|
||||
|
||||
### What is actually wrong
|
||||
|
||||
1. **The meter lies.** `location.ms` measures how long a *subscription* existed,
|
||||
not how long anything listened. That is what made Finding 1 read as the
|
||||
top-priority battery bug.
|
||||
2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates
|
||||
`locationManager.allProviders` and calls `requestLocationUpdates` on each —
|
||||
passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every
|
||||
one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash.
|
||||
This burns during the 8 h 14 m the app genuinely is foreground.
|
||||
3. **The subscription is held for 45 % of device uptime** doing nothing, because
|
||||
`Eagerly` never lets go.
|
||||
4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no
|
||||
opt-in.
|
||||
5. **Location may be entirely broken on Android 8–11** — see Hypothesis H1.
|
||||
|
||||
## Hypothesis H1 — location is dead below API 31 (unverified)
|
||||
|
||||
Through Android 11, AOSP's `getMinimumPermissionForProvider` required
|
||||
`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only
|
||||
`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a
|
||||
coarse-only app request any provider and receive a fuzzed result, is an Android
|
||||
12 (API 31) change.
|
||||
|
||||
Amethyst holds coarse only. So on API 26–30 today's `allProviders` loop should
|
||||
throw `SecurityException` on three of the four providers. Two consequences:
|
||||
|
||||
- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState` →
|
||||
`LackPermission`. Location would be **non-functional** on Android 8–11.
|
||||
- The builder aborting means `awaitClose` never runs, so `removeUpdates` is
|
||||
never called and any registration made before the throw **leaks** for the life
|
||||
of the process.
|
||||
|
||||
**The leak is iteration-order dependent, and may not occur at all.**
|
||||
`getLastKnownLocation` is permission-checked per provider too, and
|
||||
`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each
|
||||
iteration. If a fine-only provider comes first in `allProviders` — `passive`
|
||||
does, in the common AOSP ordering — the throw lands before any registration
|
||||
exists: dead, but not leaking. A leak requires `network` to precede a fine-only
|
||||
provider.
|
||||
|
||||
`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint
|
||||
warning, not the runtime check, so this would not have been caught statically.
|
||||
|
||||
**Not reproduced.** Verify before implementing, on the existing
|
||||
`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes,
|
||||
and capture **both** the `SecurityException` *and* the actual
|
||||
`locationManager.allProviders` order (log it). The PR should claim only what that
|
||||
run observed — "location is dead on Android 8–11" and "registrations leak" are
|
||||
separate claims and the second may not hold.
|
||||
|
||||
The design below is written to be correct either way (§B excludes
|
||||
permission-incompatible providers by API level, and catches `SecurityException`
|
||||
per provider). If H1 holds, this change also **fixes location on Android 8–11**,
|
||||
which should be called out in the PR.
|
||||
|
||||
### H1 verification result (2026-07-30, Pixel 9a, API 37)
|
||||
|
||||
Partial. The API-level claim could **not** be tested on this hardware: API 31+
|
||||
grants coarse-only apps access to every provider, so no `SecurityException` can
|
||||
appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it.
|
||||
|
||||
What *was* settled is the ordering, which decides the leak sub-claim.
|
||||
`dumpsys location` recent-events shows the same iteration order on every
|
||||
registration cycle across two days, all four sharing one registration id
|
||||
(`88A8E679`), confirming a single `LocationFlow` subscription:
|
||||
|
||||
```
|
||||
07-30 07:09:58.282: passive provider +registration .../88A8E679
|
||||
07-30 07:09:58.291: network provider +registration .../88A8E679
|
||||
07-30 07:09:58.293: fused provider +registration .../88A8E679
|
||||
07-30 07:09:58.299: gps provider +registration .../88A8E679
|
||||
```
|
||||
|
||||
`allProviders` yields **passive first**, and `passive` is one of the fine-only
|
||||
providers below API 31. So on Android 8–11 the throw would land on the first
|
||||
iteration, before any registration exists:
|
||||
|
||||
- "location is dead on Android 8–11" — **still unverified**, needs API ≤ 30.
|
||||
- "registrations leak" — **disproved for this ordering**. Dead, but not leaking.
|
||||
|
||||
The PR must not claim the leak. Caveat: the ordering is observed on API 37 and
|
||||
`getAllProviders()` could order differently on API 26.
|
||||
|
||||
**Unrelated but decisive "before" datum, same session:** with
|
||||
`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in
|
||||
`mLastPausedActivity`, Amethyst held **four** live registrations at
|
||||
`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to
|
||||
eliminate, captured on the owner's daily-driver device rather than an emulator.
|
||||
|
||||
## Goals
|
||||
|
||||
- Release the location registration whenever no activity is started.
|
||||
- Register on one appropriate, permission-compatible provider at an interval
|
||||
matched to the precision actually needed.
|
||||
- Make `location.ms` reflect real listening time, correctly, under concurrency.
|
||||
- No user-visible behaviour change to the "Around Me" feed or geohash chats.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the
|
||||
gate in place its cost is bounded to foreground use. Worth revisiting
|
||||
separately as a product decision.
|
||||
- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`.
|
||||
- Findings 2–6 of the source analysis. Finding 2 (the relay reconnect storm,
|
||||
1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for
|
||||
the background battery drain and should be taken next.
|
||||
|
||||
## Design
|
||||
|
||||
### A. The gate
|
||||
|
||||
`LocationState` gains an `isForeground: StateFlow<Boolean>` parameter, wired in
|
||||
`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`,
|
||||
registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so
|
||||
initialisation order is safe.
|
||||
|
||||
Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate
|
||||
over *permission × foreground*, applied identically to `geohashStateFlow` and
|
||||
`preciseGeohashStateFlow`:
|
||||
|
||||
| gate state | behaviour |
|
||||
|---|---|
|
||||
| no permission | emit `LackPermission` (unchanged) |
|
||||
| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` |
|
||||
| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value |
|
||||
|
||||
**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses
|
||||
to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate
|
||||
would make the "Around Me" feed flash empty on **every** return to foreground — a
|
||||
new, frequent, user-visible regression introduced by this change. (It also fixes
|
||||
the same flash on permission grant, which exists today.)
|
||||
|
||||
**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's
|
||||
code emits `LackPermission` without touching `latestLocation`
|
||||
(`LocationState.kt:94-96`), and that stays. Clearing it is superficially
|
||||
attractive — a revoked permission arguably should not leave a fix readable — but
|
||||
consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is
|
||||
private and its only jobs are seeding `stateIn` and deciding whether `Loading` is
|
||||
emitted. Clearing it would therefore buy no privacy and would cost an
|
||||
empty-feed flash on every permission flap, which is precisely what R1 exists to
|
||||
prevent. The cache is in-memory and dies with the process regardless.
|
||||
|
||||
The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry
|
||||
looks arbitrary next to the paragraph above, so to be explicit: it is inherited,
|
||||
not introduced. Both branches preserve today's behaviour exactly
|
||||
(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing
|
||||
permission). This corollary argues against *adding* a clear, not for removing
|
||||
the existing one — changing it would be an unmotivated behaviour change. The
|
||||
asymmetry is also defensible on its own terms: a source that failed mid-stream
|
||||
says something about the fix's provenance, whereas a permission known to be
|
||||
absent says nothing about a fix already taken.
|
||||
|
||||
**R2 — grace period on the background edge.** The gate must delay the
|
||||
`foreground → background` transition by **5 s** before tearing down. Without it a
|
||||
one-second app switch destroys and rebuilds the registration, including a full
|
||||
`getLastKnownLocation` sweep, so a user flipping between apps pays more than the
|
||||
steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same
|
||||
intent. The `background → foreground` edge is **not** delayed.
|
||||
|
||||
Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)`
|
||||
delays both edges, and the duration-selector overload that would allow an
|
||||
asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file
|
||||
and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`:
|
||||
|
||||
```kotlin
|
||||
isForeground.transformLatest { fg ->
|
||||
if (!fg) delay(BACKGROUND_GRACE_MS)
|
||||
emit(fg)
|
||||
}
|
||||
```
|
||||
|
||||
`transformLatest` cancels the pending `delay` if foreground returns first, which
|
||||
is exactly the stated semantics, with no preview opt-in.
|
||||
|
||||
**R3 — the retained-value contract.** The "emit nothing" branch is what keeps
|
||||
this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60
|
||||
synchronous `.value` reads across the feed filters
|
||||
(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`,
|
||||
`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km
|
||||
cell does not meaningfully decay while backgrounded.
|
||||
|
||||
**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation`
|
||||
(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial
|
||||
values. R1 promotes them to control flow, read from a different coroutine than
|
||||
the `onEach` that writes them. They must become `@Volatile` (or
|
||||
`MutableStateFlow`).
|
||||
|
||||
**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly`
|
||||
to `WhileSubscribed`. Roughly 60 call sites read
|
||||
`account.live*FollowLists.value` synchronously rather than collecting; under
|
||||
`WhileSubscribed` those reads would silently serve a stale or initial value
|
||||
whenever no collector happened to be active. That is a correctness regression,
|
||||
not a battery fix.
|
||||
|
||||
**Rejected alternative:** gating only at the `AppModules` wiring point
|
||||
(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw
|
||||
`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for
|
||||
`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the
|
||||
same data.
|
||||
|
||||
### B. Request shape
|
||||
|
||||
`LocationFlow.get` registers on **one** provider, chosen by a ladder over
|
||||
**provider existence and permission compatibility** — both static facts:
|
||||
|
||||
```
|
||||
chooseProviders(sdkInt, hasFine, exists) -> List<String>:
|
||||
API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists
|
||||
API < 31, coarse → [NETWORK] filtered by exists (see H1)
|
||||
```
|
||||
|
||||
It returns the **ordered candidate list**, not a single choice, because the
|
||||
per-provider `SecurityException` fall-through below needs somewhere to fall to.
|
||||
An empty list means no compatible provider exists.
|
||||
|
||||
**The ladder deliberately does not consult `isProviderEnabled`.** Today's code
|
||||
registers regardless of enabled state, and such a registration goes live by
|
||||
itself when the user enables location — including from the quick-settings shade
|
||||
without leaving the app, which is exactly what someone does after seeing "Around
|
||||
Me" empty. A guard evaluated once at subscription start would lose that, and the
|
||||
foreground-transition restart does not cover the in-app path. Selecting on
|
||||
existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If
|
||||
field reports show dead feeds on devices where the chosen provider exists but is
|
||||
disabled while another is enabled, adding that receiver is the follow-up.
|
||||
|
||||
`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch
|
||||
that falls through to the next rung, so H1 cannot abort the builder and leak
|
||||
registrations regardless of how the AOSP check actually behaves.
|
||||
|
||||
**When no provider can be registered** — the candidate list was empty, or every
|
||||
rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit
|
||||
`LackPermission`: the seam is `(Long, Float) -> Flow<Location>`, and
|
||||
`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no
|
||||
way to express. Throwing routes it through the `.catch` already present in
|
||||
`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets
|
||||
`latestLocation = LackPermission` and emits it — the existing, unchanged path.
|
||||
|
||||
Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag
|
||||
guard: the throw happens before the acquire, so `onListening(true)` cannot fire
|
||||
without a live registration and no separate flag is needed. That is only half of
|
||||
R5's pairing, though — see R5 for the release half, which the throw does **not**
|
||||
cover and which needs `try`/`finally`.
|
||||
|
||||
**Stated decision: `LackPermission` stays conflated with "no usable provider".**
|
||||
That value renders `R.string.lack_location_permissions` — "No Location
|
||||
Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`,
|
||||
which is wrong for a coarse-only pre-31 device that has no `network` provider.
|
||||
The conflation is accepted rather than introduced: if H1 holds, today's
|
||||
`SecurityException` already lands in the same `.catch` and shows the same wrong
|
||||
message. Adding an `Unavailable` state would ripple through four UI `when`s plus
|
||||
`LocationState` (10 references across 5 files) and belongs with the H1 fix
|
||||
messaging, not here. Recorded as a follow-up.
|
||||
|
||||
The `getLastKnownLocation` seed stays a sweep across all providers, taking the
|
||||
freshest result. It requires no registration and is what makes the first geohash
|
||||
appear immediately rather than after a fix.
|
||||
|
||||
`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call:
|
||||
|
||||
| flow | precision | interval / distance | provider set |
|
||||
|---|---|---|---|
|
||||
| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 |
|
||||
| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 |
|
||||
|
||||
Both rows change: the ladder narrows the precise flow's provider set too, and
|
||||
below API 31 that means `network` only, no GPS. Academic while the app holds
|
||||
coarse only (see Follow-ups), but it is not "unchanged".
|
||||
|
||||
At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no
|
||||
observable effect on the feed.
|
||||
|
||||
**Rejected alternative — one shared source at the fine profile,** deriving the
|
||||
coarse geohash by prefix truncation. It halves registrations and removes the need
|
||||
for `RefCountedSession` entirely, but it upgrades the **common** case — the
|
||||
"Around Me" feed alone, which is always on via the Products default — from
|
||||
60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one.
|
||||
|
||||
**Rejected alternative — one shared source whose profile tracks the finest
|
||||
active subscriber.** Recovers the above and is the best of the three on both
|
||||
axes, but it is refcounting with the counter moved from the meter into the
|
||||
request path, for a benefit bounded by how often the two flows overlap. They
|
||||
overlap only while one of three composable-scoped, foreground-only screens is
|
||||
open (`GeohashChatScreen`, `NewGeohashChatScreen`,
|
||||
`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes.
|
||||
|
||||
### C. The meter
|
||||
|
||||
`AppModules.kt:251` hands both flows the same non-refcounted
|
||||
`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment
|
||||
while the other is still listening. Both can be live at once — the "Around Me"
|
||||
feed plus an open geohash chat.
|
||||
|
||||
**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today
|
||||
`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`,
|
||||
so it fires on *collection* whether or not anything was registered — meaning a
|
||||
device with no usable provider accrues `location.ms` with nothing listening,
|
||||
reintroducing the exact defect this section exists to fix. The hook must instead
|
||||
fire from inside the `callbackFlow`, after `requestLocationUpdates` returns
|
||||
without throwing, and again on the way out.
|
||||
|
||||
The obvious "way out" is `awaitClose`, and that would introduce a worse bug than
|
||||
it fixes — twice over. First, `awaitClose` runs on every normal
|
||||
completion, including one where no rung ever registered, so it would fire an
|
||||
**unpaired** `onListening(false)`. With R6 that does not merely under-count — it
|
||||
decrements a holder it never acquired, stealing another flow's. Concretely:
|
||||
`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to
|
||||
register and closes (`holders = 0`), and the session latches off while the coarse
|
||||
flow is still listening. `coerceAtLeast(0)` does not help; the count never went
|
||||
negative.
|
||||
|
||||
Second — and this is the one that survives fixing the first — `awaitClose` also
|
||||
fails to run at all on some paths that *did* register. See below.
|
||||
|
||||
The pair therefore has to be guaranteed from **both** ends, and the two ends need
|
||||
different mechanisms.
|
||||
|
||||
*No acquire without a registration* is §B's **throw**: if no rung registers, the
|
||||
builder throws before reaching the acquire at all.
|
||||
|
||||
*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This
|
||||
is the subtlest point in the document, so the justification below is the one that
|
||||
was **demonstrated**, not the one that sounds most obvious.
|
||||
|
||||
Anything between the acquire and `awaitClose` that unwinds skips cleanup parked
|
||||
inside `awaitClose`, because `awaitClose` is never reached to register it. The
|
||||
registration then leaks and the refcount sticks at ≥ 1 for the life of the
|
||||
process, so `location.ms` accrues forever with nothing listening — this exact
|
||||
defect, arrived at from the other direction, and unrecoverable once hit.
|
||||
|
||||
The **proven** path is the seed throwing a non-cancellation exception:
|
||||
`getLastKnownLocation` is a binder call and can fail. The regression test
|
||||
`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched
|
||||
failing against an `awaitClose`-only implementation
|
||||
(`expected:<[true, false]> but was:<[true]>`).
|
||||
|
||||
A cancellation during the seed is *in principle* a second such path, since `send`
|
||||
is a suspending call. Recorded honestly: **this one could not be reproduced.**
|
||||
Two attempts during implementation both produced tests that passed against a
|
||||
deliberately broken implementation, because `callbackFlow`'s channel is buffered,
|
||||
so `send` returns without suspending and never observes the cancel. Do not treat
|
||||
the cancellation story as the reason for the `try`/`finally`; a future reader who
|
||||
tries to reproduce it, fails, and concludes the guard is unnecessary would
|
||||
reintroduce the leak.
|
||||
|
||||
```kotlin
|
||||
var registered: String? = null
|
||||
for (provider in candidates) {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper())
|
||||
registered = provider
|
||||
break
|
||||
} catch (e: SecurityException) { /* next rung */ }
|
||||
}
|
||||
if (registered == null) throw SecurityException("no usable location provider")
|
||||
|
||||
onListening?.invoke(true) // cannot fire without a registration
|
||||
try {
|
||||
freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable
|
||||
awaitClose { } // only to satisfy callbackFlow's contract
|
||||
} finally {
|
||||
locationManager.removeUpdates(callback)
|
||||
onListening?.invoke(false) // cannot be skipped
|
||||
}
|
||||
```
|
||||
|
||||
`onListening?.invoke(true)` sits immediately before the `try`, with no suspension
|
||||
between them, so the acquire cannot happen outside the block that guarantees its
|
||||
release.
|
||||
|
||||
`trySend` for the seed would also close this particular hole, being
|
||||
non-suspending. It is rejected because it leaves the invariant resting on nobody
|
||||
adding a suspending call to that block later — vigilance rather than
|
||||
impossibility, which is the standard the rest of R5 is held to.
|
||||
|
||||
**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not
|
||||
sufficient: two threads can leave the counter at 1 while the last
|
||||
`setActive(false)` lands after the `setActive(true)`, latching the session off.
|
||||
The count and the transition must move under one lock. New class in
|
||||
`service/resourceusage/`:
|
||||
|
||||
```kotlin
|
||||
class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) {
|
||||
private val lock = Any()
|
||||
private var holders = 0
|
||||
|
||||
fun setActive(active: Boolean) =
|
||||
synchronized(lock) {
|
||||
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
|
||||
setSessionActive(holders > 0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It takes the setter as a lambda rather than a `SessionTimeIntegrator` because
|
||||
that is all it needs, and because constructing a real integrator in a unit test
|
||||
would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp
|
||||
file to observe one boolean.
|
||||
|
||||
`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes
|
||||
`onListening = { locationRefCount.setActive(it) }`. The outer
|
||||
lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is
|
||||
then nested but never acquired in the reverse order, so there is no deadlock.
|
||||
`coerceAtLeast(0)` guards an unmatched release.
|
||||
|
||||
### What `location.ms` means after this change
|
||||
|
||||
Stated plainly, because the finding that opened this spec is "the meter lies"
|
||||
and the next reader should not over-trust the fixed number the way the last one
|
||||
over-trusted the broken one:
|
||||
|
||||
> `location.ms` measures **how long a location registration was held while the
|
||||
> app was in the foreground**. It is not radio-on time and not an energy
|
||||
> figure. A `network`-provider registration at 60 s costs close to nothing; a
|
||||
> `gps` registration at 10 s costs a great deal. The counter cannot tell them
|
||||
> apart.
|
||||
|
||||
Reading it as a battery signal requires knowing which provider was chosen —
|
||||
which the ledger does not record. Recording the chosen provider as a separate
|
||||
counter is a possible follow-up.
|
||||
|
||||
## Testing
|
||||
|
||||
JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric,
|
||||
alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`.
|
||||
|
||||
**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow<Location>`,
|
||||
defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get`
|
||||
(see *Registration pairing* for why `LocationFlow` now takes the manager rather
|
||||
than the `Context`). That is the seam:
|
||||
`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure
|
||||
Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a
|
||||
`mockk<Location>` with stubbed `latitude`/`longitude` suffices. Against a
|
||||
counting fake source:
|
||||
|
||||
- backgrounded + permitted → source never subscribed
|
||||
- foreground + permitted → subscribed exactly once
|
||||
- foreground → background → subscription released after the R2 grace period,
|
||||
last `Success` still readable via `.value`
|
||||
- background edge shorter than the grace period → subscription **not** torn down
|
||||
- return to foreground with a cached `Success` → **no** `Loading` emission (R1)
|
||||
- return to foreground with no cached fix → `Loading` first
|
||||
- permission revoked → `LackPermission` regardless of foreground state
|
||||
|
||||
**Provider ladder.** Extracted as a pure function
|
||||
`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean):
|
||||
List<String>` so §B is covered rather than sitting below the seam. Cases: rungs
|
||||
returned in order; missing rungs filtered out; API < 31 coarse-only yields
|
||||
`[network]`; API < 31 with fine yields the full ladder; no compatible provider
|
||||
yields an empty list.
|
||||
|
||||
`hasFine` is **always `false` in production** — the non-goals rule out ever
|
||||
requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so
|
||||
that the function is total over the permission axis and the API < 31 branch can
|
||||
be tested from both sides, not because fine access is anticipated. If that
|
||||
changes, the ladder is already correct.
|
||||
|
||||
R5 sits below the `locationSource` seam, so a fake source never fires it. It gets
|
||||
its own tests against a mocked `LocationManager` — see *Registration pairing*
|
||||
below.
|
||||
|
||||
**Meter.** `RefCountedSession`: overlapping holders keep the session open;
|
||||
balanced pairs close it; an unmatched release does not drive the count negative.
|
||||
|
||||
Note what this class **cannot** do: it cannot distinguish an unpaired release
|
||||
from a legitimate one, so `acquire → unpaired release` closes the session even
|
||||
while another holder is listening. That is precisely the R5 bug, and
|
||||
`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to
|
||||
`LocationFlow`, not here** — which is why it needs its own test below.
|
||||
|
||||
**Registration pairing (R5).** To make this testable rather than device-only,
|
||||
`LocationFlow` takes a `LocationManager` instead of a `Context`
|
||||
(`LocationFlow(context)` → `LocationFlow(locationManager)`; the caller in
|
||||
`LocationState` does the `getSystemService` lookup). A `mockk<LocationManager>`
|
||||
then covers:
|
||||
|
||||
- every rung throws `SecurityException` → the flow throws and `onListening` fires
|
||||
**neither** edge
|
||||
- `chooseProviders` returns an empty list → same: throws, neither edge
|
||||
- an earlier rung throws and a later one succeeds → registration falls through,
|
||||
exactly one `true`
|
||||
- a successful registration → exactly one `true`, and exactly one `false` plus
|
||||
`removeUpdates` on cancellation
|
||||
- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight
|
||||
→ both edges still fire and `removeUpdates` is still called. This is the case
|
||||
the `try`/`finally` exists for; without it the test fails by hanging the
|
||||
refcount at 1 rather than by throwing, so assert on the edges, not on the
|
||||
absence of an exception.
|
||||
These cover the *semantics* only — the two-thread interleaving that motivates the
|
||||
lock is made unobservable by the lock itself and is not reproduced by any test
|
||||
here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
On device, re-running the measurement above:
|
||||
|
||||
- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location`
|
||||
shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`,
|
||||
and a `-registration` in the recent-events log.
|
||||
- **Foregrounded:** **one registration per actively-collected flow — at most
|
||||
two**, and one in the steady state where only the "Around Me" feed is live
|
||||
(§C exists precisely because the two flows may overlap). Not four. The coarse
|
||||
registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than
|
||||
`@+10s0ms` / `100.0`.
|
||||
- The historical aggregates are cumulative since boot; compare deltas across a
|
||||
foreground/background cycle, not absolute totals.
|
||||
- **Invariant:** a subsequent in-app Resource Usage Report shows
|
||||
|
||||
```
|
||||
location.ms ≤ app.fgms + 5 s × (background transitions)
|
||||
```
|
||||
|
||||
Both counters are driven by the same `foregroundTracker.isForeground` flow, so
|
||||
without R2 this would hold exactly. R2 is deliberately the error term: the
|
||||
registration really *is* live during the grace period, so counting it is the
|
||||
honest reading, and a stated fudge factor beats an invariant quietly known to
|
||||
be false. The term is not negligible — the source report shows 6 process
|
||||
starts across two days, and app switches are far more frequent than that — so
|
||||
writing `location.ms ≤ app.fgms` would guarantee that the first person to
|
||||
check it files a bug against this change.
|
||||
|
||||
Second caveat: Finding 4 of the source analysis suspects `app.fgms` of
|
||||
under-reporting, so a violation beyond the grace term indicts that counter
|
||||
rather than this one.
|
||||
- If H1 holds: location works on the API 26 AVD after the change and did not
|
||||
before.
|
||||
|
||||
### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37)
|
||||
|
||||
Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with
|
||||
the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark`
|
||||
beside the untouched Play install. The Play install was force-stopped for the
|
||||
duration so its own (unfixed) registrations could not be mistaken for these.
|
||||
|
||||
| criterion | before | after |
|
||||
|---|---|---|
|
||||
| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) |
|
||||
| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` |
|
||||
| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** |
|
||||
|
||||
Event trace for one full cycle, process alive throughout (pid 28682):
|
||||
|
||||
```
|
||||
17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0
|
||||
17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace
|
||||
17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0
|
||||
(HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives)
|
||||
```
|
||||
|
||||
- **§A gate** — zero registrations while backgrounded, with the process still
|
||||
alive. That is the state the change exists to create; before, four
|
||||
registrations survived screen-off and doze.
|
||||
- **§B request shape** — one provider, top of the ladder (`fused`), at exactly
|
||||
`COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and
|
||||
coalesces the effective service request to `@+10m0s0ms LOW_POWER`.
|
||||
- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild
|
||||
pair, so a brief switch no longer costs a re-registration and a fresh
|
||||
`getLastKnownLocation` sweep.
|
||||
|
||||
**The OS aggregate after four foreground/background cycles is the headline
|
||||
result**, because it is the same counter shape `location.ms` measures:
|
||||
|
||||
```
|
||||
com.vitorpamplona.amethyst.benchmark:
|
||||
min/max interval = 60s/60s
|
||||
total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms
|
||||
locations = 4
|
||||
```
|
||||
|
||||
Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's
|
||||
`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while
|
||||
only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the
|
||||
aggregate exactly, so nothing is held outside them. Registration-held time now
|
||||
*equals* foreground time, which is precisely what makes `location.ms` honest: the
|
||||
counter measures registration lifetime, and that quantity is no longer divorced
|
||||
from reality.
|
||||
|
||||
**A fix arrives within milliseconds of every re-registration**, which bounds the
|
||||
staleness the coarser profile was feared to introduce:
|
||||
|
||||
```
|
||||
17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms)
|
||||
17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms)
|
||||
17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms)
|
||||
18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms)
|
||||
```
|
||||
|
||||
The `fused` provider hands over its cached fix on registration, so the window in
|
||||
which a returning user could act on a stale geohash is milliseconds, not the 60 s
|
||||
poll interval. Caveat: that cache is warm on this device because Maps and GMS
|
||||
keep it fresh; on a device with no other location consumer it could be colder,
|
||||
which is what `freshestLastKnownLocation` exists to cover.
|
||||
|
||||
**Side-by-side A/B, same device, same instant, both clients backgrounded and
|
||||
running.** The unmodified release client (1.13.1, installed via Obtainium, pid
|
||||
5552) and the benchmark build of this branch (pid 28682) were sampled together:
|
||||
|
||||
```
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive)
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
|
||||
← com.vitorpamplona.amethyst.benchmark: no rows at all
|
||||
```
|
||||
|
||||
Four held registrations versus zero. Note the release client's rows are all
|
||||
`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10
|
||||
minutes and suspended delivery, exactly as §"What the device reports" describes —
|
||||
but the **registration is still held**, and registration-held time is precisely
|
||||
what `location.ms` counts. That is the inflation, visible in one frame.
|
||||
|
||||
Naming note for anyone re-reading the numbers above: both artifacts are `play`
|
||||
**flavor** builds and differ only by buildType, so "the Play install" is an
|
||||
ambiguous label. The unmodified client here is the *release* build, and on this
|
||||
device it came from Obtainium rather than Google Play.
|
||||
|
||||
### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report)
|
||||
|
||||
The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks
|
||||
out against accumulated data:
|
||||
|
||||
| | `location.ms` | `app.fgms` | ratio |
|
||||
|---|---|---|---|
|
||||
| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** |
|
||||
| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× |
|
||||
| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** |
|
||||
|
||||
Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which
|
||||
requires ≥ 3 background transitions to fall inside the grace allowance — met by
|
||||
the report navigation plus an `am start`. Day 20664 independently reconciles with
|
||||
the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace =
|
||||
~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the
|
||||
measurement ended. **The ledger and the OS now agree**, where before they were
|
||||
irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks).
|
||||
|
||||
**Limitation:** this is not a within-package before/after. `location.ms` is
|
||||
absent from days 20648–20663 because the benchmark client had location permission
|
||||
*denied* until 2026-07-30; the "before" is no data, not inflated data.
|
||||
|
||||
### The same data closes the battery question
|
||||
|
||||
Over days 20659–20665 on this device: **5m18s** of location listening against
|
||||
**596 pp** of background battery drain (~85 pp/day). Location cannot be a
|
||||
meaningful contributor at that ratio — Finding 1 is settled, and not in the
|
||||
direction the original analysis assumed.
|
||||
|
||||
Three consumers visible in the same report, none of them location:
|
||||
|
||||
- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on
|
||||
foreground service running essentially continuously. Largest structural
|
||||
difference from a stock client; worth confirming it is deliberately enabled.
|
||||
- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone:
|
||||
9,831 successful dials against 25,133 failures = **71.9 %**, matching the
|
||||
original report's 75 %.
|
||||
- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems =
|
||||
52,392,613` — **14.6 h** of background mobile active time with **0 requests and
|
||||
0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so
|
||||
the fg/bg split in this report still cannot be trusted.
|
||||
|
||||
Caveat: the benchmark client's round-the-clock always-on service makes its
|
||||
battery figures non-comparable to a stock install. The relay and `net.other`
|
||||
figures do match the release client's original report closely.
|
||||
|
||||
### Smoke test on a clean install (2026-07-31, benchmark client)
|
||||
|
||||
Uninstalled and reinstalled from branch HEAD so the account, ledger and
|
||||
permission grant all started empty — which is what makes the first-grant and
|
||||
empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the
|
||||
new data.
|
||||
|
||||
- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly:
|
||||
granted the permission, set a feed to Around Me, backgrounded for 10 s,
|
||||
reopened — the geohash was present immediately with no blank feed. This was the
|
||||
last open acceptance criterion on the branch and the only one no test could
|
||||
cover. `dumpsys` shows why it works: the fix is delivered 1–6 ms after each
|
||||
re-registration.
|
||||
- **Precise profile live and distinct: PASS.** Geohash screens produce
|
||||
`@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max
|
||||
interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in
|
||||
production, not just in the unit tests.
|
||||
- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4`
|
||||
came up at 15:49:10 and survived **four complete precise-flow cycles**
|
||||
untouched (15:49:41–46, 15:50:47–52, 15:52:07–15:53:05, 15:53:17–22), with
|
||||
both live simultaneously during the first. Opening and closing geohash chats
|
||||
never closes the "Around Me" registration — `RefCountedSession` doing on a real
|
||||
device what `LocationLedgerCompositionTest` asserts.
|
||||
- **No hang in the location picker.** Every precise registration received a fix
|
||||
within ~1 ms; none stuck open. That path does
|
||||
`preciseGeohashStateFlow.first { it is Success }`, which would hang rather than
|
||||
crash if the gate failed to yield.
|
||||
- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground
|
||||
5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions,
|
||||
≈ 2.6 s each — the grace period, visible at a scale where it is easy to check.
|
||||
|
||||
**Measurement note:** counting listeners with `grep -c` on the package name is
|
||||
unreliable — the `service: ProviderRequest[…]` summary line also names the
|
||||
package when it is the only requester, inflating the count by one. List the rows
|
||||
and exclude that line instead. A count of zero is unambiguous either way.
|
||||
|
||||
Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms +
|
||||
5 s × transitions` invariant was separately confirmed from accumulated ledger
|
||||
data (see above); the clean install reset that counter, so it will need another
|
||||
day of use to re-check at scale.
|
||||
|
||||
## Follow-ups (not in this change)
|
||||
|
||||
- **`preciseGeohashStateFlow` is not actually building-level.** With only
|
||||
`ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so
|
||||
the 8-char geohash and the location chat channels built on it are far coarser
|
||||
than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`,
|
||||
`NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent
|
||||
survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request
|
||||
it, or to stop advertising building-level precision, is a separate decision.
|
||||
- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls
|
||||
`setLocationPermission(true)` inside an `if (isGranted)` rather than passing
|
||||
the boolean, as every other caller does (`LoggedInPage.kt:144`,
|
||||
`LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`,
|
||||
`GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never
|
||||
reflected back into the shared `LocationState`. Small, in the blast radius,
|
||||
and cheap.
|
||||
- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a
|
||||
device with no usable provider stops being told "No Location Permissions" when
|
||||
it has them. Ten references across five files
|
||||
(`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`,
|
||||
`NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix
|
||||
messaging — see the stated decision in §B.
|
||||
- Recording the chosen provider as a ledger counter, so `location.ms` can be
|
||||
read as a cost signal.
|
||||
- Whether Products should still default to `TopFilter.AroundMe`.
|
||||
- Finding 2 — the relay reconnect storm.
|
||||
@@ -295,6 +295,93 @@
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
|
||||
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
|
||||
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
|
||||
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsPictureAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_picture"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_picture">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_picture">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
|
||||
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
|
||||
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
|
||||
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsShortVideoAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_short_video"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_short_video">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_short_video">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
|
||||
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
|
||||
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
|
||||
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsVideoAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_video"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_video">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_video">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
|
||||
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
|
||||
passage, so no image/video filters here. The android:name simple class
|
||||
("ShareAsHighlightAlias") is matched at runtime by
|
||||
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME; keep the two in sync. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsHighlightAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_highlight"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_highlight">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
|
||||
the permission request fails silently (no dialog appears). The system launches it,
|
||||
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
|
||||
|
||||
@@ -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
|
||||
@@ -104,6 +105,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
|
||||
@@ -245,10 +247,17 @@ class AppModules(
|
||||
OtsSharedPreferences(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// App services that should be run as soon as there are subscribers to their flows
|
||||
// App services that should be run as soon as there are subscribers to their
|
||||
// flows. Location additionally releases its OS registration whenever no
|
||||
// activity is started — see the foreground gate inside LocationState.
|
||||
val locationManager by lazy {
|
||||
Log.d("AppModules", "LocationManager Init")
|
||||
LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) })
|
||||
LocationState(
|
||||
appContext,
|
||||
applicationIOScope,
|
||||
isForeground = foregroundTracker.isForeground,
|
||||
onListening = { locationRefCount.setActive(it) },
|
||||
)
|
||||
}
|
||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||
|
||||
@@ -287,6 +296,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()
|
||||
|
||||
@@ -368,6 +382,11 @@ class AppModules(
|
||||
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
|
||||
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
|
||||
|
||||
// LocationState exposes two independent flows that can both be listening at
|
||||
// once (the "Around Me" feed plus an open geohash chat). Refcounting keeps
|
||||
// either one stopping from closing the other's segment.
|
||||
private val locationRefCount = RefCountedSession(locationSession::setActive)
|
||||
|
||||
// Time-per-screen (route base names only — arguments never reach the
|
||||
// ledger). Fed by the navigation listener in AppNavigation; foreground
|
||||
// gating means backgrounding on a screen closes its segment.
|
||||
@@ -1224,6 +1243,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
|
||||
@@ -132,6 +133,7 @@ private object PrefKeys {
|
||||
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
|
||||
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
|
||||
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
|
||||
const val DEFAULT_HIGHLIGHTS_FOLLOW_LIST = "defaultHighlightsFollowList"
|
||||
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
|
||||
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
|
||||
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
|
||||
@@ -324,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()
|
||||
@@ -522,6 +527,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
|
||||
@@ -710,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)) {
|
||||
@@ -935,6 +942,7 @@ object LocalPreferences {
|
||||
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
|
||||
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
|
||||
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
|
||||
defaultHighlightsFollowList = MutableStateFlow(followListPrefs.highlights),
|
||||
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
|
||||
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
|
||||
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
|
||||
@@ -1014,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
|
||||
}
|
||||
|
||||
@@ -1044,6 +1056,7 @@ object LocalPreferences {
|
||||
val nsites: TopFilter,
|
||||
val workouts: TopFilter,
|
||||
val gitRepositories: TopFilter,
|
||||
val highlights: TopFilter,
|
||||
val calendars: TopFilter,
|
||||
val products: TopFilter,
|
||||
val shorts: TopFilter,
|
||||
@@ -1101,6 +1114,7 @@ object LocalPreferences {
|
||||
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
|
||||
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
|
||||
@@ -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
|
||||
@@ -873,6 +874,9 @@ class Account(
|
||||
val liveGitRepositoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultGitRepositoriesFollowList)
|
||||
val liveGitRepositoriesFollowListsPerRelay = OutboxLoaderState(liveGitRepositoriesFollowLists, cache, scope).flow
|
||||
|
||||
val liveHighlightsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultHighlightsFollowList)
|
||||
val liveHighlightsFollowListsPerRelay = OutboxLoaderState(liveHighlightsFollowLists, cache, scope).flow
|
||||
|
||||
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
|
||||
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
|
||||
|
||||
@@ -3462,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3668,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,
|
||||
@@ -3679,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))
|
||||
@@ -4119,6 +4145,7 @@ class Account(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
originalHash: String? = null,
|
||||
videoKind: VideoPostKind = VideoPostKind.AUTO,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -4162,7 +4189,10 @@ class Account(
|
||||
thumbhash = headerInfo.thumbHash?.thumbhash,
|
||||
)
|
||||
|
||||
if (headerInfo.dim.height > headerInfo.dim.width) {
|
||||
// The composer forces the kind when it was opened from a feed that only reads one of
|
||||
// them (Shorts, Longs) or from that feed's share target, so the post lands where the
|
||||
// user asked for it. Everywhere else the orientation decides.
|
||||
if (videoKind.isShort(headerInfo.dim)) {
|
||||
VideoShortEvent.build(videoMeta, alt ?: "") {
|
||||
contentWarningReason?.let { contentWarning(contentWarningReason) }
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@ class AccountSettings(
|
||||
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultHighlightsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
|
||||
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
@@ -895,6 +896,17 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultHighlightsFollowList(name: FeedDefinition) {
|
||||
changeDefaultHighlightsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultHighlightsFollowList(name: TopFilter) {
|
||||
if (defaultHighlightsFollowList.value != name) {
|
||||
defaultHighlightsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
|
||||
changeDefaultCalendarsFollowList(name.code)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
|
||||
/**
|
||||
* Which NIP-71 kind a video upload is published as. Only videos are affected — whether an upload
|
||||
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
|
||||
* overridden.
|
||||
*
|
||||
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
|
||||
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
|
||||
* and the Video feed reads both.
|
||||
*/
|
||||
enum class VideoPostKind {
|
||||
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
|
||||
AUTO,
|
||||
|
||||
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
|
||||
SHORT,
|
||||
|
||||
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
|
||||
NORMAL,
|
||||
|
||||
;
|
||||
|
||||
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
|
||||
fun isShort(dim: DimensionTag): Boolean =
|
||||
when (this) {
|
||||
SHORT -> true
|
||||
NORMAL -> false
|
||||
AUTO -> dim.height > dim.width
|
||||
}
|
||||
}
|
||||
+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")
|
||||
}
|
||||
}
|
||||
+107
-26
@@ -21,56 +21,137 @@
|
||||
package com.vitorpamplona.amethyst.service.location
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [LocationManager] update registration as a cold [Flow].
|
||||
*
|
||||
* Registers on **one** provider, chosen by [LocationProviderLadder], rather than
|
||||
* on every provider the device reports. The previous shotgun cost four
|
||||
* simultaneous registrations — passive, network, fused and gps, the last at
|
||||
* HIGH_ACCURACY — to produce a 5 km geohash.
|
||||
*
|
||||
* Takes a [LocationManager] rather than a `Context` so the registration
|
||||
* behaviour is unit-testable; the caller does the `getSystemService` lookup.
|
||||
*
|
||||
* [onListening] is fired from inside the flow, after a registration succeeds, and
|
||||
* released again from the `try`/`finally` that wraps everything after it, never
|
||||
* as an `onStart`/`onCompletion` pair on the returned flow. The distinction
|
||||
* matters: an `onStart` fires on collection even when nothing registered, so a
|
||||
* device with no usable provider would accrue location time with no location
|
||||
* running, and — because the ledger refcounts the two [LocationState] flows
|
||||
* together — the unpaired close would steal the other flow's holder.
|
||||
*
|
||||
* The pair is kept honest from both ends. The acquire cannot fire without a
|
||||
* registration, because a failure to register throws before reaching it. The
|
||||
* release cannot be skipped, because everything after the acquire runs inside a
|
||||
* `try`/`finally` rather than inside `awaitClose` — [freshestLastKnownLocation]
|
||||
* (the seed sweep below) can throw a non-cancellation exception and unwind
|
||||
* before `awaitClose` is ever reached, and `try`/`finally` is what still runs
|
||||
* the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in
|
||||
* `LocationFlowTest`. (In principle a collector cancelling mid-seed would also
|
||||
* unwind past `awaitClose` the same way, but that path could not be
|
||||
* reproduced — `callbackFlow`'s buffer is empty at this point, so the single
|
||||
* seed send returns without suspending and never observes the cancellation.)
|
||||
*/
|
||||
class LocationFlow(
|
||||
private val context: Context,
|
||||
private val locationManager: LocationManager,
|
||||
private val sdkInt: Int = Build.VERSION.SDK_INT,
|
||||
) {
|
||||
@SuppressLint("MissingPermission")
|
||||
fun get(
|
||||
minTimeMs: Long = MIN_TIME,
|
||||
minDistanceM: Float = MIN_DISTANCE,
|
||||
minTimeMs: Long,
|
||||
minDistanceM: Float,
|
||||
onListening: ((Boolean) -> Unit)? = null,
|
||||
): Flow<Location> =
|
||||
callbackFlow {
|
||||
Log.i("LocationFlow", "Start")
|
||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
val locationCallback =
|
||||
LocationListener { location ->
|
||||
Log.d("LocationFlow") { "onLocationChanged $location" }
|
||||
launch { send(location) }
|
||||
}
|
||||
|
||||
locationManager.allProviders.forEach {
|
||||
val location = locationManager.getLastKnownLocation(it)
|
||||
Log.d("LocationFlow") { "Last Known location is $location" }
|
||||
if (location != null) {
|
||||
send(location)
|
||||
}
|
||||
Log.d("LocationFlow", "Requesting Updates")
|
||||
locationManager.requestLocationUpdates(
|
||||
it,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
locationCallback,
|
||||
Looper.getMainLooper(),
|
||||
)
|
||||
}
|
||||
// One binder call, reused for both the ladder filter and the seed.
|
||||
val providers = locationManager.allProviders
|
||||
|
||||
awaitClose {
|
||||
Log.i("LocationFlow", "Stop")
|
||||
val candidates = LocationProviderLadder.chooseProviders(sdkInt) { it in providers }
|
||||
|
||||
val registered =
|
||||
candidates.firstOrNull { requestUpdates(it, minTimeMs, minDistanceM, locationCallback) }
|
||||
?: throw SecurityException("No usable location provider. Candidates: $candidates")
|
||||
|
||||
Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" }
|
||||
onListening?.invoke(true)
|
||||
|
||||
// Cleanup lives in this finally, not in awaitClose — see the class
|
||||
// KDoc, and `releasesTheRegistrationWhenTheSeedThrows` in
|
||||
// LocationFlowTest, which fails if it moves.
|
||||
try {
|
||||
// Seeded after registration so the no-provider path throws
|
||||
// without having emitted anything; seeding first would show the
|
||||
// consumer Success -> LackPermission on a device with no
|
||||
// compatible provider.
|
||||
freshestLastKnownLocation(candidates)?.let {
|
||||
Log.d("LocationFlow") { "Last known location is $it" }
|
||||
send(it)
|
||||
}
|
||||
|
||||
awaitClose { }
|
||||
} finally {
|
||||
Log.i("LocationFlow") { "Stopped listening on $registered" }
|
||||
locationManager.removeUpdates(locationCallback)
|
||||
onListening?.invoke(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the registration was accepted; false when the provider refused it. */
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun requestUpdates(
|
||||
provider: String,
|
||||
minTimeMs: Long,
|
||||
minDistanceM: Float,
|
||||
locationCallback: LocationListener,
|
||||
): Boolean =
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
locationCallback,
|
||||
Looper.getMainLooper(),
|
||||
)
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("LocationFlow", "Provider $provider refused the update request", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* The freshest cached fix across the providers the ladder deemed usable.
|
||||
* Sweeping every provider the device reports instead would, on the
|
||||
* coarse-only legacy path, pay a guaranteed-to-throw binder call per
|
||||
* fine-only provider on every flow start. Still guarded per provider, like
|
||||
* the update request is — on a device where one refuses us, the others
|
||||
* should still seed.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun freshestLastKnownLocation(providers: List<String>): Location? =
|
||||
providers
|
||||
.mapNotNull { provider ->
|
||||
try {
|
||||
locationManager.getLastKnownLocation(provider)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("LocationFlow", "No permission to read the last known location of $provider", e)
|
||||
null
|
||||
}
|
||||
}.maxByOrNull { it.time }
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.location
|
||||
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Picks which location providers to try, in order.
|
||||
*
|
||||
* Deliberately selects on **provider existence**, never on
|
||||
* [LocationManager.isProviderEnabled]. A registration on a disabled provider
|
||||
* goes live by itself when the user enables location — including from the
|
||||
* quick-settings shade without leaving the app, which is exactly what someone
|
||||
* does after seeing an empty "Around Me" feed. An enabled-state guard evaluated
|
||||
* once at subscription start would lose that.
|
||||
*
|
||||
* Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`;
|
||||
* only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which
|
||||
* lets a coarse-only app request any provider and receive a fuzzed result, is an
|
||||
* Android 12 change. Amethyst declares coarse only, so the legacy branch is
|
||||
* unconditional below API 31.
|
||||
*
|
||||
* Adding `ACCESS_FINE_LOCATION` later would **not** widen this on its own — the
|
||||
* branch below has no permission input, so pre-31 devices would keep getting
|
||||
* `network` alone and silently lose the precision the new permission was granted
|
||||
* for. Whoever adds it must widen the condition here too.
|
||||
*
|
||||
* Returns the ordered candidate list rather than a single choice so the caller
|
||||
* can fall through to the next rung if a registration is refused. An empty list
|
||||
* means no compatible provider exists.
|
||||
*/
|
||||
object LocationProviderLadder {
|
||||
// Compile-time String constants, inlined by the compiler, so naming
|
||||
// FUSED_PROVIDER (added in API 31) is safe on older runtimes.
|
||||
private val FULL_LADDER =
|
||||
listOf(
|
||||
LocationManager.FUSED_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.PASSIVE_PROVIDER,
|
||||
)
|
||||
|
||||
private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER)
|
||||
|
||||
fun chooseProviders(
|
||||
sdkInt: Int,
|
||||
exists: (String) -> Boolean,
|
||||
): List<String> {
|
||||
val ladder = if (sdkInt >= Build.VERSION_CODES.S) FULL_LADDER else COARSE_ONLY_LEGACY_LADDER
|
||||
|
||||
return ladder.filter(exists)
|
||||
}
|
||||
}
|
||||
+162
-65
@@ -21,32 +21,83 @@
|
||||
package com.vitorpamplona.amethyst.service.location
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same
|
||||
// package, so it needs no import.
|
||||
|
||||
/**
|
||||
* Turns the device's location into geohashes, listening **only while the app is
|
||||
* in the foreground**.
|
||||
*
|
||||
* The gate is not an optimisation of last resort: `Account` builds 30
|
||||
* `SharingStarted.Eagerly` top-nav filter states on the account scope, and
|
||||
* `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so
|
||||
* without it every user with location permission holds a registration for the
|
||||
* life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`.
|
||||
*
|
||||
* Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60
|
||||
* call sites read `account.live*FollowLists.value` synchronously rather than
|
||||
* collecting, and would silently serve a stale or initial value.
|
||||
*/
|
||||
class LocationState(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
|
||||
private val scope: CoroutineScope,
|
||||
private val isForeground: StateFlow<Boolean>,
|
||||
/**
|
||||
* Resource-ledger hook: true while location updates are actively
|
||||
* registered. Reaches the OS only through the default [locationSource],
|
||||
* which hands it to [LocationFlow] — a caller that overrides
|
||||
* [locationSource] (the tests do) is responsible for firing it, or not.
|
||||
*/
|
||||
private val onListening: ((Boolean) -> Unit)? = null,
|
||||
private val locationSource: (Long, Float) -> Flow<Location> = { minTimeMs, minDistanceM ->
|
||||
LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
|
||||
.get(minTimeMs, minDistanceM, onListening)
|
||||
},
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 10000L
|
||||
const val MIN_DISTANCE: Float = 100.0f
|
||||
/** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */
|
||||
const val COARSE_MIN_TIME: Long = 60_000L
|
||||
const val COARSE_MIN_DISTANCE: Float = 500.0f
|
||||
|
||||
/** Building-level geohashes need the tighter profile. */
|
||||
const val PRECISE_MIN_TIME: Long = 10_000L
|
||||
const val PRECISE_MIN_DISTANCE: Float = 100.0f
|
||||
|
||||
/**
|
||||
* How long to keep listening after the last activity stops, so a
|
||||
* one-second app switch doesn't destroy and rebuild the registration.
|
||||
* Same intent as [SUBSCRIPTION_STOP_TIMEOUT_MS], on the other axis.
|
||||
*/
|
||||
const val BACKGROUND_GRACE_MS: Long = 5_000L
|
||||
|
||||
/**
|
||||
* How long `stateIn` keeps the upstream alive after the last collector
|
||||
* leaves, so a screen rotation or a tab switch doesn't rebuild the
|
||||
* registration either.
|
||||
*/
|
||||
const val SUBSCRIPTION_STOP_TIMEOUT_MS: Long = 5_000L
|
||||
}
|
||||
|
||||
sealed class LocationResult {
|
||||
@@ -59,9 +110,16 @@ class LocationState(
|
||||
object Loading : LocationResult()
|
||||
}
|
||||
|
||||
private enum class Gate { NoPermission, Paused, Listen }
|
||||
|
||||
private var hasLocationPermission = MutableStateFlow(false)
|
||||
private var latestLocation: LocationResult = LocationResult.Loading
|
||||
private var latestPreciseLocation: LocationResult = LocationResult.Loading
|
||||
|
||||
// Read by R1 below to decide whether to emit Loading, from a different
|
||||
// coroutine than the onEach that writes it — hence a StateFlow rather than
|
||||
// a plain field.
|
||||
private val latestLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
|
||||
|
||||
private val latestPreciseLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
|
||||
|
||||
fun setLocationPermission(newValue: Boolean) {
|
||||
if (newValue != hasLocationPermission.value) {
|
||||
@@ -69,36 +127,92 @@ class LocationState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground with an asymmetric delay: leaving the foreground waits out
|
||||
* [BACKGROUND_GRACE_MS], returning to it is immediate.
|
||||
*
|
||||
* `debounce(5000)` would delay both edges, and the duration-selector
|
||||
* overload that allows an asymmetric delay is `@FlowPreview`.
|
||||
* `transformLatest` cancels the pending `delay` when foreground returns
|
||||
* first, which is exactly the semantics wanted, with no preview opt-in.
|
||||
*
|
||||
* Known and harmless: [ForegroundTracker] starts at `false`, so on a
|
||||
* process that starts backgrounded the first emission — and therefore the
|
||||
* first gate verdict, including `LackPermission` — is delayed by
|
||||
* [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process
|
||||
* that starts into the foreground emits immediately, because the activity's
|
||||
* `onStart` cancels the pending delay.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val geohashStateFlow by lazy {
|
||||
hasLocationPermission
|
||||
.transformLatest {
|
||||
if (it) {
|
||||
emit(LocationResult.Loading)
|
||||
val result =
|
||||
LocationFlow(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.onStart { onListening?.invoke(true) }
|
||||
.onCompletion { onListening?.invoke(false) }
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
|
||||
}.onEach {
|
||||
latestLocation = it
|
||||
}.catch { e ->
|
||||
Log.w("GeohashStateFlow", "Exception in the flow", e)
|
||||
latestLocation = LocationResult.LackPermission
|
||||
emit(LocationResult.LackPermission)
|
||||
}
|
||||
private val settledForeground: Flow<Boolean> =
|
||||
isForeground.transformLatest { foreground ->
|
||||
if (!foreground) delay(BACKGROUND_GRACE_MS)
|
||||
emit(foreground)
|
||||
}
|
||||
|
||||
emitAll(result)
|
||||
} else {
|
||||
emit(LocationResult.LackPermission)
|
||||
private val gate: Flow<Gate> =
|
||||
combine(hasLocationPermission, settledForeground) { permitted, foreground ->
|
||||
when {
|
||||
!permitted -> Gate.NoPermission
|
||||
foreground -> Gate.Listen
|
||||
else -> Gate.Paused
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun buildGeohashStateFlow(
|
||||
tag: String,
|
||||
charsCount: Int,
|
||||
minTimeMs: Long,
|
||||
minDistanceM: Float,
|
||||
cache: MutableStateFlow<LocationResult>,
|
||||
): StateFlow<LocationResult> =
|
||||
gate
|
||||
.transformLatest { state ->
|
||||
when (state) {
|
||||
// Deliberately does NOT write to the cache. Today's code emits
|
||||
// LackPermission without touching the cache, and wiping it
|
||||
// here would cost a Loading emission — and so an empty-feed
|
||||
// flash — on every permission flap, which is the regression R1
|
||||
// exists to prevent. Consumers already see LackPermission from
|
||||
// the StateFlow; the cache is internal and only decides whether
|
||||
// Loading is emitted.
|
||||
Gate.NoPermission -> emit(LocationResult.LackPermission)
|
||||
|
||||
// Emit nothing: stateIn keeps the last value, so every
|
||||
// synchronous .value reader still sees the last known geohash
|
||||
// while the OS registration is released.
|
||||
Gate.Paused -> Unit
|
||||
|
||||
Gate.Listen -> {
|
||||
// Only when there is nothing cached. Emitting Loading on
|
||||
// every foreground return would flash the "Around Me" feed
|
||||
// empty, because AroundMeFeedFlow.convert maps anything
|
||||
// that is not Success to an empty geotag set.
|
||||
if (cache.value !is LocationResult.Success) emit(LocationResult.Loading)
|
||||
|
||||
emitAll(
|
||||
locationSource(minTimeMs, minDistanceM)
|
||||
.map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult }
|
||||
.onEach { cache.value = it }
|
||||
.catch { e ->
|
||||
Log.w(tag, "Exception in the flow", e)
|
||||
cache.value = LocationResult.LackPermission
|
||||
emit(LocationResult.LackPermission)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
latestLocation,
|
||||
)
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value)
|
||||
|
||||
val geohashStateFlow: StateFlow<LocationResult> by lazy {
|
||||
buildGeohashStateFlow(
|
||||
tag = "GeohashStateFlow",
|
||||
charsCount = GeohashPrecision.KM_5_X_5.digits,
|
||||
minTimeMs = COARSE_MIN_TIME,
|
||||
minDistanceM = COARSE_MIN_DISTANCE,
|
||||
cache = latestLocation,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,36 +221,19 @@ class LocationState(
|
||||
* to every coarser level (a geohash is a prefix code), so one fix yields the
|
||||
* whole region→building ladder. Kept separate so the coarser
|
||||
* [geohashStateFlow] the "around me" feed relies on is unchanged.
|
||||
*
|
||||
* Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android
|
||||
* fuzzes every fix to roughly a 3 km grid and this is not in fact
|
||||
* building-level today. The profile is kept so the intent survives if the
|
||||
* app ever requests `ACCESS_FINE_LOCATION`.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val preciseGeohashStateFlow by lazy {
|
||||
hasLocationPermission
|
||||
.transformLatest {
|
||||
if (it) {
|
||||
emit(LocationResult.Loading)
|
||||
val result =
|
||||
LocationFlow(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.onStart { onListening?.invoke(true) }
|
||||
.onCompletion { onListening?.invoke(false) }
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(GeohashChannelLevel.BUILDING.chars)) as LocationResult
|
||||
}.onEach {
|
||||
latestPreciseLocation = it
|
||||
}.catch { e ->
|
||||
Log.w("GeohashStateFlow", "Exception in the precise flow", e)
|
||||
latestPreciseLocation = LocationResult.LackPermission
|
||||
emit(LocationResult.LackPermission)
|
||||
}
|
||||
|
||||
emitAll(result)
|
||||
} else {
|
||||
emit(LocationResult.LackPermission)
|
||||
}
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
latestPreciseLocation,
|
||||
)
|
||||
val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
|
||||
buildGeohashStateFlow(
|
||||
tag = "PreciseGeohashStateFlow",
|
||||
charsCount = GeohashChannelLevel.BUILDING.chars,
|
||||
minTimeMs = PRECISE_MIN_TIME,
|
||||
minDistanceM = PRECISE_MIN_DISTANCE,
|
||||
cache = latestPreciseLocation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
+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 =====")
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashF
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
|
||||
@@ -170,6 +171,7 @@ class RelaySubscriptionsCoordinator(
|
||||
val pictures = PicturesFilterAssembler(client)
|
||||
val workouts = WorkoutsFilterAssembler(client)
|
||||
val gitRepositories = GitRepositoriesFilterAssembler(client)
|
||||
val highlights = HighlightsFilterAssembler(client)
|
||||
val calendars = CalendarsFilterAssembler(client)
|
||||
val products = ProductsFilterAssembler(client)
|
||||
val shorts = ShortsFilterAssembler(client)
|
||||
@@ -235,6 +237,7 @@ class RelaySubscriptionsCoordinator(
|
||||
pictures,
|
||||
workouts,
|
||||
gitRepositories,
|
||||
highlights,
|
||||
calendars,
|
||||
products,
|
||||
shorts,
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.resourceusage
|
||||
|
||||
/**
|
||||
* Refcounts a boolean session so overlapping holders don't close each other's
|
||||
* segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState]
|
||||
* exposes two independent location flows that can both be listening at once —
|
||||
* the "Around Me" feed plus an open geohash chat — and a bare
|
||||
* [SessionTimeIntegrator] would close the segment when either one stops.
|
||||
*
|
||||
* The count and the transition it drives are taken under one lock. An
|
||||
* [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is
|
||||
* not enough: two threads can leave the counter at 1 while the last
|
||||
* `setActive(false)` lands after the `setActive(true)`, latching the session
|
||||
* off with a holder still active.
|
||||
*
|
||||
* Takes the setter as a lambda rather than a [SessionTimeIntegrator] because
|
||||
* that is all it needs, and so tests need no accountant or store file.
|
||||
*
|
||||
* Reports **transitions only**, not every call — the contract the name implies,
|
||||
* and what keeps the class honest for a future caller that reacts to the
|
||||
* callback rather than integrating it. (For [SessionTimeIntegrator] specifically
|
||||
* a 1 -> 2 re-entry would have been harmless: it splits one segment into two
|
||||
* contiguous pieces that `account()` re-adds, and its starts increment is
|
||||
* already guarded on `prev == null`.)
|
||||
*
|
||||
* Releases must be paired with acquires. This class cannot tell an unpaired
|
||||
* release from a real one, so callers guarantee the pairing; see `LocationFlow`,
|
||||
* which throws rather than reaching `awaitClose` when nothing registered.
|
||||
*/
|
||||
class RefCountedSession(
|
||||
private val setSessionActive: (Boolean) -> Unit,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private var holders = 0
|
||||
|
||||
fun setActive(active: Boolean) {
|
||||
synchronized(lock) {
|
||||
val wasActive = holders > 0
|
||||
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
|
||||
val isActive = holders > 0
|
||||
if (isActive != wasActive) setSessionActive(isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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()
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
@@ -73,15 +74,21 @@ open class NewMediaModel : ViewModel() {
|
||||
// Strip location and sensitive metadata from files before upload
|
||||
var stripMetadata by mutableStateOf(true)
|
||||
|
||||
// Which NIP-71 kind videos in this batch are published as. Set by the composer's host feed.
|
||||
var videoKind: VideoPostKind = VideoPostKind.AUTO
|
||||
|
||||
open fun load(
|
||||
account: Account,
|
||||
uris: ImmutableList<SelectedMedia>,
|
||||
videoKind: VideoPostKind = VideoPostKind.AUTO,
|
||||
caption: String = "",
|
||||
) {
|
||||
this.caption = ""
|
||||
this.caption = caption
|
||||
this.account = account
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
this.videoKind = videoKind
|
||||
}
|
||||
|
||||
fun isImage(
|
||||
@@ -180,6 +187,7 @@ open class NewMediaModel : ViewModel() {
|
||||
alt = caption,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
originalHash = it.uploadedHash,
|
||||
videoKind = videoKind,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
@@ -79,14 +80,16 @@ fun NewMediaView(
|
||||
postViewModel: NewMediaModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
videoKind: VideoPostKind = VideoPostKind.AUTO,
|
||||
initialCaption: String = "",
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(uris) {
|
||||
postViewModel.load(account, uris)
|
||||
LaunchedEffect(uris, videoKind, initialCaption) {
|
||||
postViewModel.load(account, uris, videoKind, initialCaption)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
+20
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -40,3 +43,20 @@ suspend fun resolveSharedMedia(
|
||||
SelectedMedia(uri, context.contentResolver.getType(uri))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch form for SEND_MULTIPLE shares: resolves every URI in one trip to the IO dispatcher
|
||||
* instead of one context switch per file. Blank entries are dropped, so an empty list in means
|
||||
* an empty list out and the composer stays closed.
|
||||
*/
|
||||
suspend fun resolveSharedMedia(
|
||||
context: Context,
|
||||
uriStrings: List<String>,
|
||||
): ImmutableList<SelectedMedia> {
|
||||
val uris = uriStrings.mapNotNull { it.ifBlank { null }?.toUri() }
|
||||
if (uris.isEmpty()) return persistentListOf()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
uris.map { SelectedMedia(it, context.contentResolver.getType(it)) }.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -69,6 +69,7 @@ object ScrollStateKeys {
|
||||
const val PICTURES_SCREEN = "PicturesFeed"
|
||||
const val WORKOUTS_SCREEN = "WorkoutsFeed"
|
||||
const val GIT_REPOSITORIES_SCREEN = "GitRepositoriesFeed"
|
||||
const val HIGHLIGHTS_SCREEN = "HighlightsFeed"
|
||||
const val RELAY_GROUPS_DISCOVERY_SCREEN = "RelayGroupsDiscoveryFeed"
|
||||
const val CALENDARS_SCREEN = "CalendarsFeed"
|
||||
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinato
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.MediaFeedRoute
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
|
||||
@@ -193,6 +194,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.HighlightsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.NewHighlightScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
|
||||
@@ -310,6 +313,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip84Highlights.parse.SharedHighlightParser
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
@@ -425,7 +429,7 @@ fun BuildNavigation(
|
||||
) {
|
||||
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
|
||||
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
|
||||
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||
composableArgs<Route.Video> { VideoScreen(accountViewModel, nav, it.attachments, it.message) }
|
||||
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
|
||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||
@@ -436,9 +440,11 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileAppRecommendations> { ProfileAppRecommendationsScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachments, it.message) }
|
||||
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Highlights> { HighlightsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
|
||||
@@ -464,7 +470,7 @@ fun BuildNavigation(
|
||||
}
|
||||
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Shorts> { ShortsScreen(accountViewModel, nav, it.attachments, it.message) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
|
||||
@@ -785,6 +791,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
relayUrl = it.relayUrl,
|
||||
isForum = it.isForum,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -935,6 +942,22 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.NewHighlight> {
|
||||
NewHighlightScreen(
|
||||
quote = it.quote,
|
||||
url = it.url,
|
||||
prefix = it.prefix,
|
||||
suffix = it.suffix,
|
||||
comment = it.comment,
|
||||
context = it.context,
|
||||
sourceAddress = it.sourceAddress,
|
||||
sourceEventId = it.sourceEventId,
|
||||
author = it.author,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.VoiceReply> {
|
||||
VoiceReplyScreen(
|
||||
replyToNoteId = it.replyToNoteId,
|
||||
@@ -949,6 +972,42 @@ fun BuildNavigation(
|
||||
}
|
||||
}
|
||||
|
||||
/** True for both share flavors: a single file/text (SEND) and a multi-file selection (SEND_MULTIPLE). */
|
||||
private fun Intent.isShareAction(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
|
||||
|
||||
/** The text Android sent with the share — a caption, a URL, or the whole payload of a text-only share. */
|
||||
private fun Intent.sharedText(): String? = getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
|
||||
|
||||
/**
|
||||
* Every content URI the share carries, in the order the sending app listed them. SEND_MULTIPLE
|
||||
* puts them in a parcelable ArrayList; plain SEND has at most one. Only the media targets declare
|
||||
* SEND_MULTIPLE, so every other caller can just take the first.
|
||||
*/
|
||||
private fun Intent.sharedStreamUris(): List<String> =
|
||||
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
IntentCompat
|
||||
.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.map { it.toString() }
|
||||
.orEmpty()
|
||||
} else {
|
||||
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)?.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a media feed on a share. Sharing again while an earlier share is still on screen replaces
|
||||
* it instead of stacking a second copy of the feed — but only when the entry on top is itself a
|
||||
* share (it carries attachments). A feed the user opened from the bottom bar carries none, so it
|
||||
* stays put underneath and keeps its tab-root marker.
|
||||
*/
|
||||
private inline fun <reified T> Nav.navToSharedFeed(route: T) where T : Route, T : MediaFeedRoute {
|
||||
val current = getRouteWithArguments(T::class, controller)
|
||||
if (current is MediaFeedRoute && current.attachments.isNotEmpty()) {
|
||||
popUpTo(route, T::class)
|
||||
} else {
|
||||
newStack(route)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavigateIfIntentRequested(
|
||||
nav: Nav,
|
||||
@@ -965,36 +1024,43 @@ private fun NavigateIfIntentRequested(
|
||||
|
||||
val activity = LocalContext.current.getActivity()
|
||||
|
||||
if (activity.intent.action == Intent.ACTION_SEND) {
|
||||
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
|
||||
if (activity.intent.isShareAction()) {
|
||||
val target = ShareIntentRouting.targetOf(activity.intent.component?.className)
|
||||
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (isShareAsDm) {
|
||||
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
|
||||
} else {
|
||||
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
|
||||
// Microsoft's swift key sends Gifs as new actions.
|
||||
// The media targets land on a feed the user may well be standing on already, so they can't
|
||||
// guard on the destination — they rely on the intent being consumed below instead.
|
||||
when (target) {
|
||||
ShareTarget.HIGHLIGHT -> if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
|
||||
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
|
||||
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
|
||||
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
|
||||
}
|
||||
|
||||
// saves the intent to avoid processing again
|
||||
var message by remember {
|
||||
mutableStateOf(
|
||||
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
it.ifBlank { null }
|
||||
},
|
||||
)
|
||||
}
|
||||
val message = remember { activity.intent.sharedText() }
|
||||
val attachments = remember { activity.intent.sharedStreamUris() }
|
||||
|
||||
var media by remember {
|
||||
mutableStateOf(
|
||||
IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java),
|
||||
)
|
||||
}
|
||||
|
||||
if (isShareAsDm) {
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
|
||||
} else {
|
||||
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
|
||||
when (target) {
|
||||
ShareTarget.HIGHLIGHT -> {
|
||||
val parsed = message?.let { SharedHighlightParser.parse(it) }
|
||||
nav.newStack(
|
||||
Route.NewHighlight(
|
||||
quote = parsed?.quote,
|
||||
url = parsed?.url,
|
||||
prefix = parsed?.prefix,
|
||||
suffix = parsed?.suffix,
|
||||
),
|
||||
)
|
||||
}
|
||||
// The single-file composers take the first URI: only the media targets declare
|
||||
// SEND_MULTIPLE, so anything else can only be carrying one.
|
||||
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = attachments.firstOrNull()))
|
||||
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
|
||||
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
|
||||
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
|
||||
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
|
||||
}
|
||||
|
||||
// Consume the launch intent so a later recomposition can't re-fire
|
||||
@@ -1061,25 +1127,44 @@ private fun NavigateIfIntentRequested(
|
||||
DisposableEffect(nav, activity) {
|
||||
val consumer =
|
||||
Consumer<Intent> { intent ->
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (isShareAsDm) {
|
||||
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
|
||||
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
|
||||
val attachment =
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
|
||||
}
|
||||
} else if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
nav.newStack(Route.NewShortNote(message = it))
|
||||
}
|
||||
if (intent.isShareAction()) {
|
||||
val target = ShareIntentRouting.targetOf(intent.component?.className)
|
||||
val message = intent.sharedText()
|
||||
val attachments = intent.sharedStreamUris()
|
||||
val attachment = attachments.firstOrNull()
|
||||
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
|
||||
nav.newStack(Route.NewShortNote(attachment = it.toString()))
|
||||
}
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions.
|
||||
// The media targets land on a feed the user may well be standing on already, so
|
||||
// they always navigate: the route carries the attachment, so the composer opens
|
||||
// on the newly shared file even when the feed itself is already on screen.
|
||||
when (target) {
|
||||
ShareTarget.HIGHLIGHT ->
|
||||
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
|
||||
val parsed = message?.let { SharedHighlightParser.parse(it) }
|
||||
nav.newStack(
|
||||
Route.NewHighlight(
|
||||
quote = parsed?.quote,
|
||||
url = parsed?.url,
|
||||
prefix = parsed?.prefix,
|
||||
suffix = parsed?.suffix,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
ShareTarget.DIRECT_MESSAGE ->
|
||||
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
|
||||
}
|
||||
|
||||
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
|
||||
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
|
||||
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
|
||||
|
||||
ShareTarget.NEW_POST ->
|
||||
if (!isBaseRoute<Route.NewShortNote>(nav.controller) && (message != null || attachment != null)) {
|
||||
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val uri = intent.data?.toString()
|
||||
|
||||
+62
-4
@@ -20,10 +20,31 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
/** Which entry of Android's share sheet the user picked. */
|
||||
enum class ShareTarget {
|
||||
/** The default "New Post" target: a kind-1 note with the shared text/media. */
|
||||
NEW_POST,
|
||||
|
||||
/** "Send as DM": a NIP-17 private message. */
|
||||
DIRECT_MESSAGE,
|
||||
|
||||
/** "New Highlight": a NIP-84 highlight of the shared passage. */
|
||||
HIGHLIGHT,
|
||||
|
||||
/** "New Picture": a NIP-68 picture post, straight into the picture feed. */
|
||||
PICTURE,
|
||||
|
||||
/** "New Short": a NIP-71 kind 22 video, straight into the Shorts feed. */
|
||||
SHORT_VIDEO,
|
||||
|
||||
/** "New Video": a NIP-71 video, straight into the Video feed. */
|
||||
VIDEO,
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes the "Send as DM" share target from the default "New Post" share
|
||||
* target. Both intent-filters resolve to MainActivity; they are told apart by the
|
||||
* component class name of the launching intent (the activity-alias name).
|
||||
* Tells the share targets apart. Every SEND intent-filter resolves to MainActivity; which target
|
||||
* the user picked is only visible in the component class name of the launching intent (the
|
||||
* `<activity-alias>` name), so each alias below maps to the composer it opens.
|
||||
*/
|
||||
object ShareIntentRouting {
|
||||
/**
|
||||
@@ -34,5 +55,42 @@ object ShareIntentRouting {
|
||||
*/
|
||||
const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias"
|
||||
|
||||
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
|
||||
/**
|
||||
* Simple class name of the `<activity-alias>` declared in AndroidManifest.xml
|
||||
* (android:name=".ui.ShareAsHighlightAlias"). MUST stay in sync with the manifest —
|
||||
* see the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME].
|
||||
*/
|
||||
const val SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME = "ShareAsHighlightAlias"
|
||||
|
||||
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
|
||||
const val SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME = "ShareAsPictureAlias"
|
||||
|
||||
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
|
||||
const val SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsShortVideoAlias"
|
||||
|
||||
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
|
||||
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
|
||||
|
||||
private val TARGET_BY_ALIAS =
|
||||
mapOf(
|
||||
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
|
||||
SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME to ShareTarget.HIGHLIGHT,
|
||||
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
|
||||
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
|
||||
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves the launching component to its target. Flavors can change the resolved package
|
||||
* prefix, so the match is on the simple name; anything that isn't one of our aliases (chiefly
|
||||
* MainActivity itself) is the default New Post target.
|
||||
*/
|
||||
fun targetOf(componentClassName: String?): ShareTarget {
|
||||
if (componentClassName == null) return ShareTarget.NEW_POST
|
||||
|
||||
return TARGET_BY_ALIAS.entries
|
||||
.firstOrNull { componentClassName.endsWith(".${it.key}") }
|
||||
?.value
|
||||
?: ShareTarget.NEW_POST
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -55,6 +55,7 @@ enum class NavBarItem {
|
||||
PICTURES,
|
||||
WORKOUTS,
|
||||
GIT_REPOSITORIES,
|
||||
HIGHLIGHTS,
|
||||
SOFTWARE_APPS,
|
||||
NAPPLETS,
|
||||
NSITES,
|
||||
@@ -111,7 +112,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
id = NavBarItem.VIDEO,
|
||||
labelRes = R.string.route_video,
|
||||
icon = MaterialSymbols.Subscriptions,
|
||||
resolveRoute = { Route.Video },
|
||||
resolveRoute = { Route.Video() },
|
||||
),
|
||||
NavBarItem.DISCOVER to
|
||||
NavBarItemDef(
|
||||
@@ -230,7 +231,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
id = NavBarItem.PICTURES,
|
||||
labelRes = R.string.pictures,
|
||||
icon = MaterialSymbols.Photo,
|
||||
resolveRoute = { Route.Pictures },
|
||||
resolveRoute = { Route.Pictures() },
|
||||
),
|
||||
NavBarItem.WORKOUTS to
|
||||
NavBarItemDef(
|
||||
@@ -246,6 +247,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Code,
|
||||
resolveRoute = { Route.GitRepositories },
|
||||
),
|
||||
NavBarItem.HIGHLIGHTS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.HIGHLIGHTS,
|
||||
labelRes = R.string.highlights,
|
||||
icon = MaterialSymbols.FormatQuote,
|
||||
resolveRoute = { Route.Highlights },
|
||||
),
|
||||
NavBarItem.SOFTWARE_APPS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.SOFTWARE_APPS,
|
||||
@@ -300,7 +308,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
id = NavBarItem.SHORTS,
|
||||
labelRes = R.string.shorts,
|
||||
icon = MaterialSymbols.PlayCircle,
|
||||
resolveRoute = { Route.Shorts },
|
||||
resolveRoute = { Route.Shorts() },
|
||||
),
|
||||
NavBarItem.MUSIC_TRACKS to
|
||||
NavBarItemDef(
|
||||
@@ -534,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.CALENDARS,
|
||||
@@ -574,6 +583,7 @@ val DrawerFeedsItems: List<NavBarItem> =
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
|
||||
@@ -30,12 +30,32 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* A feed whose composer can be pre-loaded from an Android share. [attachments] holds the shared
|
||||
* content URIs and [message] the text sent alongside them; both are empty/null for every other way
|
||||
* of reaching the feed, which is how the share flow tells its own entries apart from an ordinary
|
||||
* bottom-bar visit.
|
||||
*/
|
||||
sealed interface MediaFeedRoute {
|
||||
val attachments: List<String>
|
||||
val message: String?
|
||||
}
|
||||
|
||||
sealed class Route {
|
||||
@Serializable object Home : Route()
|
||||
|
||||
@Serializable object Message : Route()
|
||||
|
||||
@Serializable object Video : Route()
|
||||
/**
|
||||
* The mixed media feed. [attachments] and [message] are set by the "New Video" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Video(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable data class Discover(
|
||||
val initialTab: DiscoverTab? = null,
|
||||
@@ -81,12 +101,23 @@ sealed class Route {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Pictures : Route()
|
||||
/**
|
||||
* The picture feed. [attachments] and [message] are set by the "New Picture" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Pictures(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable object Workouts : Route()
|
||||
|
||||
@Serializable object GitRepositories : Route()
|
||||
|
||||
@Serializable object Highlights : Route()
|
||||
|
||||
@Serializable object SoftwareApps : Route()
|
||||
|
||||
@Serializable object Napplets : Route()
|
||||
@@ -173,7 +204,16 @@ sealed class Route {
|
||||
|
||||
@Serializable object Products : Route()
|
||||
|
||||
@Serializable object Shorts : Route()
|
||||
/**
|
||||
* The short-video feed. [attachments] and [message] are set by the "New Short" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Shorts(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable object PublicChats : Route()
|
||||
|
||||
@@ -739,6 +779,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(
|
||||
@@ -987,6 +1030,26 @@ sealed class Route {
|
||||
val draft: String? = null,
|
||||
) : Route()
|
||||
|
||||
/**
|
||||
* The NIP-84 highlight composer. Opened by the "Add highlight" action (all fields null) or
|
||||
* when a browser shares a text selection to Amethyst — in which case the shared string has
|
||||
* already been run through SharedHighlightParser and the pieces arrive pre-split here.
|
||||
*/
|
||||
@Serializable
|
||||
data class NewHighlight(
|
||||
val quote: String? = null,
|
||||
val url: String? = null,
|
||||
val prefix: String? = null,
|
||||
val suffix: String? = null,
|
||||
val comment: String? = null,
|
||||
val context: String? = null,
|
||||
// A nostr source (set when highlighting a nostr article/note rather than a web page):
|
||||
// an addressable coordinate (`a`), a specific event id (`e`) and the author (`p`).
|
||||
val sourceAddress: String? = null,
|
||||
val sourceEventId: String? = null,
|
||||
val author: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data object NewHlsVideo : Route()
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -171,6 +171,25 @@ fun noteActionSections(
|
||||
}
|
||||
},
|
||||
)
|
||||
// Highlight this note/article as its source: opens the NIP-84 composer with the
|
||||
// nostr source pre-tagged (`a` for an addressable article, else `e`) plus the author,
|
||||
// and the passage left for the user to type or paste. Prose kinds only, and never a
|
||||
// private rumor (a public highlight would e-tag the unsigned rumor onto relays).
|
||||
if (!isPrivateRumor && (note.event is TextNoteEvent || note.event is LongTextNoteEvent)) {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.FormatQuote, stringRes(R.string.highlight_action)) {
|
||||
val author = note.author?.pubkeyHex
|
||||
val route =
|
||||
if (note is AddressableNote) {
|
||||
Route.NewHighlight(sourceAddress = note.address.toValue(), author = author)
|
||||
} else {
|
||||
Route.NewHighlight(sourceEventId = note.idHex, author = author)
|
||||
}
|
||||
nav.nav(route)
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!isPrivateRumor) {
|
||||
add(NoteAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = handlers.onShare))
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -337,6 +337,24 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
private val _highlightsRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
liveInterestFlows,
|
||||
) { peopleLists, interests ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
// Highlights can be narrowed by author, hashtag and geohash, so this mirrors
|
||||
// the kind3 catalog plus "Mine" — the user's own highlights.
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow),
|
||||
peopleLists,
|
||||
interests,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val _relayGroupsDiscoveryRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
@@ -470,6 +488,11 @@ class TopNavFilterState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val highlightsRoutes =
|
||||
_highlightsRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val relayGroupsDiscoveryRoutes =
|
||||
_relayGroupsDiscoveryRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
+5
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedF
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.dal.BrowseEmojiSetsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.dal.FollowPacksFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal.GitRepositoriesFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.dal.HighlightsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeEverythingFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
|
||||
@@ -117,6 +118,7 @@ class AccountFeedContentStates(
|
||||
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
||||
val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache)
|
||||
val gitRepositoriesFeed = FeedContentState(GitRepositoriesFeedFilter(account), scope, LocalCache)
|
||||
val highlightsFeed = FeedContentState(HighlightsFeedFilter(account), scope, LocalCache)
|
||||
val relayGroupsDiscoveryFeed = FeedContentState(RelayGroupDiscoveryFeedFilter(account), scope, LocalCache)
|
||||
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
|
||||
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
|
||||
@@ -327,6 +329,7 @@ class AccountFeedContentStates(
|
||||
picturesFeed.updateFeedWith(newNotes)
|
||||
workoutsFeed.updateFeedWith(newNotes)
|
||||
gitRepositoriesFeed.updateFeedWith(newNotes)
|
||||
highlightsFeed.updateFeedWith(newNotes)
|
||||
relayGroupsDiscoveryFeed.updateFeedWith(newNotes)
|
||||
productsFeed.updateFeedWith(newNotes)
|
||||
shortsFeed.updateFeedWith(newNotes)
|
||||
@@ -390,6 +393,7 @@ class AccountFeedContentStates(
|
||||
picturesFeed.deleteFromFeed(newNotes)
|
||||
workoutsFeed.deleteFromFeed(newNotes)
|
||||
gitRepositoriesFeed.deleteFromFeed(newNotes)
|
||||
highlightsFeed.deleteFromFeed(newNotes)
|
||||
relayGroupsDiscoveryFeed.deleteFromFeed(newNotes)
|
||||
productsFeed.deleteFromFeed(newNotes)
|
||||
shortsFeed.deleteFromFeed(newNotes)
|
||||
@@ -449,6 +453,7 @@ class AccountFeedContentStates(
|
||||
picturesFeed.trimToSize(maxItems)
|
||||
workoutsFeed.trimToSize(maxItems)
|
||||
gitRepositoriesFeed.trimToSize(maxItems)
|
||||
highlightsFeed.trimToSize(maxItems)
|
||||
relayGroupsDiscoveryFeed.trimToSize(maxItems)
|
||||
calendarAppointmentsFeed.trimToSize(maxItems)
|
||||
calendarCollectionsFeed.trimToSize(maxItems)
|
||||
|
||||
+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.
|
||||
|
||||
+3
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription
|
||||
@@ -100,6 +101,8 @@ private fun PreloadFor(
|
||||
|
||||
NavBarItem.GIT_REPOSITORIES -> GitRepositoriesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
NavBarItem.HIGHLIGHTS -> HighlightsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
// Napplets & nSites read directly from the local cache; their screens open the discovery
|
||||
|
||||
+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
|
||||
},
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssemblerSubscription
|
||||
|
||||
@Composable
|
||||
fun HighlightsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
HighlightsScreen(
|
||||
highlightsFeedContentState = accountViewModel.feedStates.highlightsFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HighlightsScreen(
|
||||
highlightsFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(highlightsFeedContentState)
|
||||
WatchAccountForHighlightsScreen(highlightsFeedContentState = highlightsFeedContentState, accountViewModel = accountViewModel)
|
||||
HighlightsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
HighlightsTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Highlights, nav, accountViewModel) { route ->
|
||||
if (route == Route.Highlights) {
|
||||
highlightsFeedContentState.sendToTop()
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
NewHighlightButton(nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableBox(highlightsFeedContentState, true) {
|
||||
SaveableFeedContentState(highlightsFeedContentState, scrollStateKey = ScrollStateKeys.HIGHLIGHTS_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = highlightsFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "HighlightsFeed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForHighlightsScreen(
|
||||
highlightsFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val listState by accountViewModel.account.liveHighlightsFollowLists.collectAsStateWithLifecycle()
|
||||
val hiddenUsers =
|
||||
accountViewModel.account.hiddenUsers.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
|
||||
highlightsFeedContentState.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun HighlightsTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
val list by accountViewModel.account.settings.defaultHighlightsFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
HighlightsTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultHighlightsFollowList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HighlightsTopNavFilterBar(
|
||||
followListsModel: TopNavFilterState,
|
||||
listName: TopFilter,
|
||||
accountViewModel: AccountViewModel,
|
||||
onChange: (FeedDefinition) -> Unit,
|
||||
) {
|
||||
val allLists by followListsModel.highlightsRoutes.collectAsStateWithLifecycle()
|
||||
|
||||
FeedFilterSpinner(
|
||||
placeholderCode = listName,
|
||||
explainer = stringRes(R.string.select_list_to_filter),
|
||||
options = allLists,
|
||||
onSelect = onChange,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.highlights
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun NewHighlightButton(nav: INav) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
nav.nav(Route.NewHighlight())
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.ic_compose, 4),
|
||||
contentDescription = stringRes(R.string.new_highlight_title),
|
||||
modifier = Size26Modifier,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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.highlights
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
|
||||
/**
|
||||
* Backs the "New Highlight" composer. A NIP-84 highlight is a quoted passage (the event
|
||||
* `content`), its source, and an optional annotation. The annotation reuses the short-note
|
||||
* composer's rich [message] field via [IMessageField], so it gets @-mention and custom-emoji
|
||||
* autocomplete and inline previews; on publish it becomes the highlight's `comment` tag with
|
||||
* the mentions, emoji, URLs, hashtags and quotes it references emitted as their own tags.
|
||||
*
|
||||
* The passage, the `textquoteselector` prefix/suffix, the surrounding `context`, and the
|
||||
* nostr source (`a`/`e`/`p`) are carried through from the share or the "Highlight this note"
|
||||
* action. When a nostr event is the source, [originalNote] is resolved so the screen can
|
||||
* render it as a reply-style preview instead of showing a URL field.
|
||||
*/
|
||||
@Stable
|
||||
class NewHighlightPostViewModel :
|
||||
ViewModel(),
|
||||
IMessageField {
|
||||
private var accountViewModel: AccountViewModel? = null
|
||||
private var account: Account? = null
|
||||
|
||||
/** The highlighted passage — becomes the event `content`. */
|
||||
var quote by mutableStateOf("")
|
||||
|
||||
/** The source URL — becomes an `r` tag. Hidden when a nostr event is the source. */
|
||||
var url by mutableStateOf("")
|
||||
|
||||
/** The user's annotation — the rich comment field; becomes a `comment` tag. */
|
||||
override val message = TextFieldState()
|
||||
|
||||
/** The source note, when highlighting a nostr article/note, for the reply-style preview. */
|
||||
var originalNote by mutableStateOf<Note?>(null)
|
||||
private set
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
private var prefix: String? = null
|
||||
private var suffix: String? = null
|
||||
private var context: String? = null
|
||||
private var sourceAddress: String? = null
|
||||
private var sourceEventId: String? = null
|
||||
private var author: String? = null
|
||||
|
||||
private var loaded = false
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
if (this.accountViewModel == accountViewModel) return
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
|
||||
emojiSuggestions = EmojiSuggestionState(accountViewModel.account.emoji)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the incoming source once, and resolves [originalNote] for a nostr source. Guarded
|
||||
* so a recomposition can't clobber edits the user already made.
|
||||
*/
|
||||
fun load(
|
||||
quote: String?,
|
||||
url: String?,
|
||||
prefix: String?,
|
||||
suffix: String?,
|
||||
comment: String?,
|
||||
context: String?,
|
||||
sourceAddress: String?,
|
||||
sourceEventId: String?,
|
||||
author: String?,
|
||||
) {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
|
||||
this.quote = quote.orEmpty()
|
||||
this.url = url.orEmpty()
|
||||
comment?.ifBlank { null }?.let { message.setTextAndPlaceCursorAtEnd(it) }
|
||||
this.prefix = prefix
|
||||
this.suffix = suffix
|
||||
this.context = context
|
||||
this.sourceAddress = sourceAddress
|
||||
this.sourceEventId = sourceEventId
|
||||
this.author = author
|
||||
|
||||
val accountViewModel = accountViewModel
|
||||
if (accountViewModel != null) {
|
||||
originalNote =
|
||||
when {
|
||||
!sourceAddress.isNullOrBlank() -> Address.parse(sourceAddress)?.let { accountViewModel.getOrCreateAddressableNote(it) }
|
||||
!sourceEventId.isNullOrBlank() -> accountViewModel.getOrCreateNote(sourceEventId)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageChanged() {
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
} else {
|
||||
userSuggestions?.reset()
|
||||
}
|
||||
emojiSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
}
|
||||
|
||||
fun autocompleteWithUser(item: User) {
|
||||
userSuggestions?.let {
|
||||
val lastWord = message.currentWord()
|
||||
it.replaceCurrentWord(message, lastWord, item)
|
||||
it.reset()
|
||||
}
|
||||
}
|
||||
|
||||
fun autocompleteWithEmoji(item: EmojiMedia) {
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
}
|
||||
|
||||
fun autocompleteWithEmojiUrl(item: EmojiMedia) {
|
||||
message.replaceCurrentWord(item.link + " ")
|
||||
emojiSuggestions?.reset()
|
||||
}
|
||||
|
||||
fun canPost(): Boolean = quote.isNotBlank()
|
||||
|
||||
suspend fun sendHighlight() {
|
||||
val account = account ?: return
|
||||
val dao = accountViewModel ?: return
|
||||
if (!canPost()) return
|
||||
|
||||
// Resolve @mentions, nostr: refs, emoji, URLs and hashtags out of the annotation the same
|
||||
// way the short-note composer does, so a highlight comment behaves like any other note.
|
||||
val tagger = NewMessageTagger(message.text.toString().trim(), null, null, dao)
|
||||
tagger.run()
|
||||
|
||||
val commentText = tagger.message.ifBlank { null }
|
||||
val mentions = tagger.directMentionsUsers.map { it.toPTag() }
|
||||
val emojiTags = account.emoji.findEmojiTags(tagger.message)
|
||||
val urls = findURLs(tagger.message)
|
||||
val tags = findHashtags(tagger.message)
|
||||
val quotes = findNostrUris(tagger.message)
|
||||
|
||||
account.signAndComputeBroadcast(
|
||||
HighlightEvent.build(
|
||||
quote = quote.trim(),
|
||||
url = url.trim().ifBlank { null },
|
||||
prefix = prefix,
|
||||
suffix = suffix,
|
||||
comment = commentText,
|
||||
context = context,
|
||||
address = sourceAddress,
|
||||
event = sourceEventId,
|
||||
author = author,
|
||||
) {
|
||||
if (mentions.isNotEmpty()) pTags(mentions)
|
||||
references(urls)
|
||||
hashtags(tags)
|
||||
quotes(quotes)
|
||||
emojis(emojiTags)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* 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.highlights
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
|
||||
/** A warm highlighter amber — the highlight metaphor reads as yellow regardless of theme. */
|
||||
private val MarkerAccent = Color(0xFFF5C518)
|
||||
|
||||
/**
|
||||
* The "New Highlight" composer. Reached either from the "Add highlight" action, a browser
|
||||
* share, or the "Highlight" note-action, routed in as
|
||||
* [com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewHighlight].
|
||||
*
|
||||
* A NIP-84 highlight is a quoted passage, its source, and an optional annotation:
|
||||
* - the passage is a pull-quote you craft (accent bar + quotation-mark watermark),
|
||||
* - the source is either a nostr event — rendered as a reply-style preview — or a web URL,
|
||||
* - the annotation is the same rich composer field the short-note screen uses, so it gets
|
||||
* @-mention and custom-emoji autocomplete and inline previews.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewHighlightScreen(
|
||||
quote: String? = null,
|
||||
url: String? = null,
|
||||
prefix: String? = null,
|
||||
suffix: String? = null,
|
||||
comment: String? = null,
|
||||
context: String? = null,
|
||||
sourceAddress: String? = null,
|
||||
sourceEventId: String? = null,
|
||||
author: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val postViewModel: NewHighlightPostViewModel = viewModel()
|
||||
postViewModel.init(accountViewModel)
|
||||
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(quote, url, prefix, suffix, comment, context, sourceAddress, sourceEventId, author)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
PostingTopBar(
|
||||
titleRes = R.string.new_highlight_title,
|
||||
isActive = postViewModel::canPost,
|
||||
onCancel = { nav.popBack() },
|
||||
onPost = {
|
||||
// Uses the accountViewModel scope so releasing the post ViewModel on
|
||||
// popBack can't cancel the in-flight publish.
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendHighlight()
|
||||
nav.popBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding()
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
HighlightEditorCard(
|
||||
passage = postViewModel.quote,
|
||||
onPassageChange = { postViewModel.quote = it },
|
||||
)
|
||||
|
||||
val source = postViewModel.originalNote
|
||||
if (source != null) {
|
||||
NoteCompose(
|
||||
baseNote = source,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
IconField(
|
||||
symbol = MaterialSymbols.Link,
|
||||
value = postViewModel.url,
|
||||
onValueChange = { postViewModel.url = it },
|
||||
label = stringRes(R.string.new_highlight_source_label),
|
||||
placeholder = "https://example.com/article",
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.EditNote,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.new_highlight_note_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
MessageField(
|
||||
placeholder = R.string.new_highlight_note_placeholder,
|
||||
viewModel = postViewModel,
|
||||
requestFocus = false,
|
||||
)
|
||||
}
|
||||
|
||||
postViewModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithUser,
|
||||
accountViewModel,
|
||||
modifier = SuggestionListDefaultHeightPage,
|
||||
)
|
||||
}
|
||||
|
||||
postViewModel.emojiSuggestions?.let {
|
||||
ShowEmojiSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithEmoji,
|
||||
postViewModel::autocompleteWithEmojiUrl,
|
||||
modifier = SuggestionListDefaultHeightPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero: a rounded, tonal card carrying a quotation-mark watermark, a highlighter-yellow
|
||||
* accent bar, and the editable passage set in a large, comfortable type.
|
||||
*/
|
||||
@Composable
|
||||
private fun HighlightEditorCard(
|
||||
passage: String,
|
||||
onPassageChange: (String) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.FormatQuote,
|
||||
contentDescription = null,
|
||||
tint = MarkerAccent.copy(alpha = 0.20f),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset(x = 8.dp, y = (-6).dp)
|
||||
.size(80.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MarkerAccent),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = passage,
|
||||
onValueChange = onPassageChange,
|
||||
textStyle =
|
||||
LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 1.4.em,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
cursorBrush = SolidColor(MarkerAccent),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
|
||||
decorationBox = { inner ->
|
||||
if (passage.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.new_highlight_passage_placeholder),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 1.4.em,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "${passage.length}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun IconField(
|
||||
symbol: MaterialSymbol,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
placeholder: String,
|
||||
singleLine: Boolean,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
placeholder = { Text(placeholder) },
|
||||
leadingIcon = {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
+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.highlights.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
|
||||
/**
|
||||
* The client-side data-access layer for the Highlights feed. Mirrors
|
||||
* `GitRepositoriesFeedFilter`, but highlights (kind 9802) are *regular* events rather than
|
||||
* addressable ones, so it scans `LocalCache.notes` instead of `LocalCache.addressables`.
|
||||
* The `FilterByListParams` handles the selected top-nav follow-list, hidden users and
|
||||
* future-dated / spam checks.
|
||||
*/
|
||||
class HighlightsFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
|
||||
|
||||
override fun limit() = 200
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultHighlightsFollowList.value
|
||||
|
||||
fun TopFilter.isMuteList() = this is TopFilter.MuteList
|
||||
|
||||
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
|
||||
|
||||
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
|
||||
|
||||
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
val noteEvent = it.event
|
||||
noteEvent is HighlightEvent && params.match(noteEvent, it.relays)
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.liveHighlightsFollowLists.value,
|
||||
account.hiddenUsers.flow.value,
|
||||
)
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
return collection.filterTo(HashSet()) {
|
||||
val noteEvent = it.event
|
||||
noteEvent is HighlightEvent && params.match(noteEvent, it.relays)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.highlights.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByFollows
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByGeohashes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByHashtag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByMutedAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsGlobal
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
/**
|
||||
* Routes the resolved top-nav filter set (author/hashtag/geohash/global/follows, per relay,
|
||||
* via the outbox model) to the matching subassembly that pins kind 9802 to specific relays.
|
||||
*
|
||||
* Highlights are not community-scoped, so — unlike git repositories — the community filter
|
||||
* sets fall through to [emptyList]; those options aren't offered in the top-nav catalog
|
||||
* anyway (see TopNavFilterState._highlightsRoutes).
|
||||
*/
|
||||
fun makeHighlightsFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
when (feedSettings) {
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterHighlightsByFollows(feedSettings, since, defaultSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterHighlightsByAuthors(feedSettings, since, defaultSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterHighlightsGlobal(feedSettings, since, defaultSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterHighlightsByHashtag(feedSettings, since, defaultSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterHighlightsByGeohashes(feedSettings, since, defaultSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHighlightsByMutedAuthors(feedSettings, since, defaultSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.highlights.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class HighlightsQueryState(
|
||||
val account: Account,
|
||||
val feedStates: AccountFeedContentStates,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class HighlightsFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<HighlightsQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
HighlightsSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.highlights.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun HighlightsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
|
||||
HighlightsFilterAssemblerSubscription(
|
||||
accountViewModel.dataSources().highlights,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HighlightsFilterAssemblerSubscription(
|
||||
dataSource: HighlightsFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
HighlightsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.highlights.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HighlightsSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<HighlightsQueryState>,
|
||||
) : PerUserAndFollowListEoseManager<HighlightsQueryState, TopFilter>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: HighlightsQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
// "Mine" needs no special-case: the shared TopFilter.Mine flow resolves to an author
|
||||
// filter scoped to the user, so followsPerRelay() already carries authors=[me] against the
|
||||
// user's own outbox.
|
||||
val feedSettings = key.followsPerRelay()
|
||||
|
||||
return makeHighlightsFilter(feedSettings, since, key.feedStates.highlightsFeed.lastNoteCreatedAtIfFilled())
|
||||
}
|
||||
|
||||
override fun user(key: HighlightsQueryState) = key.account.userProfile()
|
||||
|
||||
override fun list(key: HighlightsQueryState) = key.listName()
|
||||
|
||||
fun HighlightsQueryState.listNameFlow() = account.settings.defaultHighlightsFollowList
|
||||
|
||||
fun HighlightsQueryState.listName() = listNameFlow().value
|
||||
|
||||
fun HighlightsQueryState.followsPerRelayFlow() = account.liveHighlightsFollowListsPerRelay
|
||||
|
||||
fun HighlightsQueryState.followsPerRelay() = followsPerRelayFlow().value
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: HighlightsQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.listNameFlow().collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.followsPerRelayFlow().sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedStates.highlightsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
|
||||
fun filterHighlightsByAuthors(
|
||||
relay: NormalizedRelayUrl,
|
||||
authors: Set<HexKey>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val authorList = authors.sorted()
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authorList,
|
||||
kinds = listOf(HighlightEvent.KIND),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterHighlightsByAuthors(
|
||||
authorSet: AuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return authorSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.authors.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterHighlightsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun filterHighlightsByMutedAuthors(
|
||||
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return authorSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.authors.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterHighlightsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+23
-2
@@ -18,6 +18,27 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
|
||||
|
||||
fun isLiveStreaming(url: String) = url.contains(".m3u8", true)
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
fun filterHighlightsByFollows(
|
||||
followsSet: AllFollowsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (followsSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return followsSet.set.flatMap {
|
||||
val since = since?.get(it.key)?.time ?: defaultSince
|
||||
val relay = it.key
|
||||
|
||||
listOfNotNull(
|
||||
it.value.authors?.let {
|
||||
filterHighlightsByAuthors(relay, it, since)
|
||||
},
|
||||
).flatten()
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
|
||||
fun filterHighlightsByGeohashes(
|
||||
relay: NormalizedRelayUrl,
|
||||
geotags: Set<String>,
|
||||
since: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geotags.isEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(HighlightEvent.KIND),
|
||||
tags = mapOf("g" to geotags.sorted()),
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterHighlightsByGeohashes(
|
||||
geoSet: LocationTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geoSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return geoSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.geotags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterHighlightsByGeohashes(
|
||||
relay = it.key,
|
||||
geotags = it.value.geotags,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+67
@@ -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.ui.screen.loggedIn.highlights.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
|
||||
fun filterHighlightsByHashtag(
|
||||
relay: NormalizedRelayUrl,
|
||||
hashtags: Set<String>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(HighlightEvent.KIND),
|
||||
tags = mapOf("t" to hashtags.toList()),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun filterHighlightsByHashtag(
|
||||
hashtagSet: HashtagTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (hashtagSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return hashtagSet.set
|
||||
.mapNotNull { relayHashSet ->
|
||||
if (relayHashSet.value.hashtags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterHighlightsByHashtag(
|
||||
relay = relayHashSet.key,
|
||||
hashtags = relayHashSet.value.hashtags,
|
||||
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.highlights.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
fun filterHighlightsGlobal(
|
||||
relays: GlobalTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.set.isEmpty()) return emptyList()
|
||||
|
||||
return relays.set.map {
|
||||
val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo()
|
||||
RelayBasedFilter(
|
||||
relay = it.key,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(HighlightEvent.KIND),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+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 =
|
||||
|
||||
+8
@@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
@@ -63,6 +64,12 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The Longs feed's composer. Every *video* posted from here is a NIP-71 kind-21 video — recorded or
|
||||
* picked alike — so portrait footage lands in the Longs feed too instead of being routed to kind 22
|
||||
* by its orientation. Images are unaffected: the gallery picker accepts them and they still post as
|
||||
* NIP-68 kind-20 pictures, which the Longs feed does not read.
|
||||
*/
|
||||
@Composable
|
||||
fun NewLongVideoButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -106,6 +113,7 @@ fun NewLongVideoButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
videoKind = VideoPostKind.NORMAL,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+21
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -50,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -63,11 +66,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The picture feed's composer. Images post as NIP-68 kind-20 pictures and land in this feed; a
|
||||
* video picked from the gallery still posts as a NIP-71 video and shows up in the video feeds
|
||||
* instead, since the file's mime type — not the host feed — decides picture vs. video.
|
||||
*
|
||||
* @param sharedAttachments content URIs handed over by the "New Picture" share target. When
|
||||
* non-empty the composer opens on them right away, so the share lands as a picture post instead
|
||||
* of a text note with a link.
|
||||
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
|
||||
*/
|
||||
@Composable
|
||||
fun NewPictureButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
sharedAttachments: List<String> = emptyList(),
|
||||
sharedCaption: String? = null,
|
||||
) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
var wantsToPostFromCamera by remember { mutableStateOf(false) }
|
||||
@@ -83,6 +98,11 @@ fun NewPictureButton(
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(sharedAttachments) {
|
||||
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
|
||||
}
|
||||
|
||||
if (wantsToPostFromCamera) {
|
||||
TakePicture { uri ->
|
||||
wantsToPostFromCamera = false
|
||||
@@ -106,6 +126,7 @@ fun NewPictureButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
initialCaption = sharedCaption.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
|
||||
fun PicturesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
PicturesScreen(
|
||||
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
attachments = attachments,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +59,8 @@ fun PicturesScreen(
|
||||
picturesFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(picturesFeedContentState)
|
||||
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
|
||||
@@ -66,8 +72,8 @@ fun PicturesScreen(
|
||||
PicturesTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Pictures, nav, accountViewModel) { route ->
|
||||
if (route == Route.Pictures) {
|
||||
AppBottomBar(Route.Pictures(), nav, accountViewModel) { route ->
|
||||
if (route is Route.Pictures) {
|
||||
picturesFeedContentState.sendToTop()
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
@@ -76,7 +82,7 @@ fun PicturesScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop)
|
||||
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachments, message)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user