mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-11 13:12:23 +09:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d81dd407 | ||
|
|
6ef0f1fbe1 | ||
|
|
2106102fc5 | ||
|
|
e2d7460f14 | ||
|
|
e956e6fb4a | ||
|
|
4e9b8fa1a6 | ||
|
|
fca5f63b9e | ||
|
|
ea1fac588a | ||
|
|
fb4fd19d01 | ||
|
|
cb64c05129 | ||
|
|
72fd1728d1 | ||
|
|
6497e396c5 | ||
|
|
2943a8fe46 | ||
|
|
18cb423e95 | ||
|
|
6dcc137d0e | ||
|
|
8ff2eb0ca7 | ||
|
|
6e4c055ebd | ||
|
|
3588090f22 | ||
|
|
237b8ddfbf | ||
|
|
3dab64d040 | ||
|
|
0e4e03b879 | ||
|
|
b8218f906b | ||
|
|
dd6dd827b5 | ||
|
|
7922876b81 | ||
|
|
152f64aac7 | ||
|
|
08566fc244 | ||
|
|
beb906753f | ||
|
|
8a9b12e6a1 | ||
|
|
01432bc682 | ||
|
|
d56c1e5674 | ||
|
|
70509fb978 | ||
|
|
ab46d16d12 | ||
|
|
27f459e6bd | ||
|
|
5cc92469aa | ||
|
|
09ea748479 | ||
|
|
614ee3a54b | ||
|
|
7be5b0589d | ||
|
|
42cdc1f877 |
No files matched your search
@@ -38,11 +38,6 @@ services:
|
||||
target: /workspaces/fluxer/fluxer_desktop/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-marketing-node-modules
|
||||
target: /workspaces/fluxer/fluxer_marketing/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-admin-node-modules
|
||||
target: /workspaces/fluxer/fluxer_admin/node_modules
|
||||
@@ -358,7 +353,6 @@ volumes:
|
||||
fluxer-api-node-modules:
|
||||
fluxer-app-node-modules:
|
||||
fluxer-desktop-node-modules:
|
||||
fluxer-marketing-node-modules:
|
||||
fluxer-admin-node-modules:
|
||||
package-config-node-modules:
|
||||
package-constants-node-modules:
|
||||
|
||||
@@ -71,7 +71,7 @@ stage "rust: fmt" cargo fmt --all -- --check
|
||||
if [ "$QUICK" -eq 0 ]; then
|
||||
stage "rust: clippy (workspace)" cargo clippy --workspace --all-targets -- -D warnings
|
||||
else
|
||||
stage "rust: clippy (servers)" cargo clippy -p fluxer_app_proxy -p fluxer_admin -p fluxer_marketing --all-targets -- -D warnings
|
||||
stage "rust: clippy (servers)" cargo clippy -p fluxer_app_proxy -p fluxer_admin --all-targets -- -D warnings
|
||||
fi
|
||||
stage "rust: app proxy tests" cargo test -p fluxer_app_proxy
|
||||
|
||||
|
||||
+3
-2
@@ -5,8 +5,11 @@
|
||||
/.direnv/
|
||||
/.fluxer/
|
||||
/.git/
|
||||
**/.git
|
||||
**/.git/**
|
||||
/.github/
|
||||
/.pnpm-store/
|
||||
/fluxer_marketing
|
||||
|
||||
**/.env
|
||||
**/.env.*.local
|
||||
@@ -46,8 +49,6 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/release-input/
|
||||
/release-out/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/.github/CODEOWNERS @fluxerapp/developers
|
||||
/.github/workflows/ @fluxerapp/developers
|
||||
/fluxer_marketing @fluxerapp/developers
|
||||
/.gitmodules @fluxerapp/developers
|
||||
/.github/workflows/dispatch-private-marketing-build.yaml @fluxerapp/developers
|
||||
/packages/i18n/marketing/ @fluxerapp/developers
|
||||
/scripts/setup-private-marketing.sh @fluxerapp/developers
|
||||
@@ -89,3 +89,21 @@ Submit translations through [Weblate](https://weblate.fluxer.tools), not through
|
||||
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
|
||||
## Private marketing project
|
||||
|
||||
The marketing implementation is maintained in a private repository at the `fluxer_marketing` submodule path. The public workspace, bootstrap, checks, and development stack work without initializing it.
|
||||
|
||||
Authorized maintainers can initialize only that submodule and install its independent dependencies:
|
||||
|
||||
```sh
|
||||
./scripts/setup-private-marketing.sh
|
||||
pnpm --dir fluxer_marketing install --frozen-lockfile
|
||||
cargo metadata --locked --manifest-path fluxer_marketing/Cargo.toml
|
||||
```
|
||||
|
||||
To run the private marketing service in the local development stack and direct application links to it, add this override to the ignored `config/env/local.env` file:
|
||||
|
||||
```sh
|
||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
||||
```
|
||||
@@ -24,7 +24,9 @@ f:gateway:
|
||||
- any-glob-to-any-file: fluxer_gateway/**/*
|
||||
f:marketing:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_marketing/**/*
|
||||
- any-glob-to-any-file:
|
||||
- fluxer_marketing
|
||||
- packages/i18n/marketing/**/*
|
||||
f:media_proxy:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_media_proxy/**/*
|
||||
|
||||
@@ -32,17 +32,15 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this image fragment is uploaded. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ inputs.image }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -55,6 +53,8 @@ jobs:
|
||||
name: resolve metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
@@ -79,6 +79,10 @@ jobs:
|
||||
needs: meta
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -96,7 +100,7 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ github.token }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
@@ -119,6 +123,9 @@ jobs:
|
||||
needs: [meta, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -132,71 +139,45 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ github.token }}
|
||||
- name: create and push multi-arch manifest
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish
|
||||
--component "${{ inputs.image }}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
MOVING_TAGS: ${{ inputs.moving-tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_args=( "-t" "${IMAGE}:${VERSION}" )
|
||||
tag_args=()
|
||||
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
|
||||
for raw in "${moving[@]}"; do
|
||||
t="$(echo "$raw" | xargs)"
|
||||
[ -n "$t" ] && tag_args+=( "-t" "${IMAGE}:${t}" )
|
||||
tag="$(echo "$raw" | xargs)"
|
||||
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
|
||||
done
|
||||
docker buildx imagetools create "${tag_args[@]}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Write GitHub release image fragment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE_REF: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
MOVING_TAGS: ${{ inputs.moving-tags }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-image
|
||||
--build-version "${VERSION}"
|
||||
--image "${{ inputs.image }}"
|
||||
--image-ref "${IMAGE_REF}"
|
||||
--moving-tags "${MOVING_TAGS}"
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-${{ inputs.image }}
|
||||
path: release-out/fragments/fluxer-release-fragment-image-${{ inputs.image }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release
|
||||
if: ${{ inputs['finalise-release'] }}
|
||||
needs: [meta, merge]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: finalise GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${VERSION}"
|
||||
if (( ${#tag_args[@]} > 0 )); then
|
||||
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
|
||||
fi
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-admin
|
||||
dockerfile: fluxer_admin/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-api
|
||||
dockerfile: fluxer_api/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,12 +28,10 @@ jobs:
|
||||
|
||||
build:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-app-proxy-self-hosted
|
||||
dockerfile: fluxer_app_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
extra-build-args: |
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
@@ -9,41 +9,23 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-fluxer-app-proxy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -54,9 +36,10 @@ jobs:
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
@@ -79,6 +62,10 @@ jobs:
|
||||
needs: meta
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -117,13 +104,6 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
|
||||
--step generate_asset_manifest
|
||||
|
||||
- name: Upload asset manifest handoff
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: app-proxy-assets-manifest
|
||||
path: app-dist-output/dist/assets-manifest.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -139,6 +119,10 @@ jobs:
|
||||
needs: meta
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -172,6 +156,9 @@ jobs:
|
||||
needs: [meta, build, build-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -180,11 +167,6 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download app-proxy asset manifest
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
name: app-proxy-assets-manifest
|
||||
path: release-input/app-proxy
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
@@ -201,58 +183,30 @@ jobs:
|
||||
echo "amd64 digest: ${amd64_digest}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:v1" \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${IMAGE}@${amd64_digest}" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Write GitHub release app-proxy fragment
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-app-proxy
|
||||
publish
|
||||
--component fluxer-app-proxy
|
||||
--build-version "${VERSION}"
|
||||
--image fluxer-app-proxy
|
||||
--image-ref "${IMAGE_REF}"
|
||||
--moving-tags "v1,latest"
|
||||
--asset-manifest release-input/app-proxy/assets-manifest.txt
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-fluxer-app-proxy
|
||||
path: release-out/fragments/fluxer-release-fragment-app-proxy.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release
|
||||
if: ${{ inputs['finalise-release'] != false }}
|
||||
needs: [meta, merge]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: finalise GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${VERSION}"
|
||||
docker buildx imagetools create
|
||||
-t "${IMAGE}:v1"
|
||||
-t "${IMAGE}:latest"
|
||||
"${IMAGE}:${VERSION}"
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
skip_targets:
|
||||
description: Comma-separated platforms or targets to skip, such as windows, macos-arm64, linux-x64.
|
||||
description: Comma-separated platforms or targets to skip, such as windows, macos, linux-x64.
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -46,6 +46,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
@@ -81,6 +83,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
@@ -107,6 +111,10 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||
@@ -233,7 +241,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
targets: ${{ matrix.platform == 'macos' && (matrix.arch == 'arm64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin') || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||
|
||||
- name: Install MSVC ARM64 build tools
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
@@ -460,8 +468,8 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_artifacts_unix
|
||||
|
||||
- name: Normalize updater YAML (arm64)
|
||||
if: matrix.arch == 'arm64'
|
||||
- name: Normalize updater YAML (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step normalise_updater_yaml
|
||||
@@ -492,6 +500,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
DISPLAY_CHANNEL: ${{ needs.meta.outputs.channel }}
|
||||
@@ -544,34 +554,14 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_summary
|
||||
|
||||
- name: Write GitHub release desktop fragment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-desktop
|
||||
--build-version "${{ needs.meta.outputs.version }}"
|
||||
--channel "${{ needs.meta.outputs.build_channel }}"
|
||||
--test-build "${{ needs.meta.outputs.test_build }}"
|
||||
--s3-prefix "${{ needs.meta.outputs.s3_prefix }}"
|
||||
--payload-root s3_payload
|
||||
--source-sha "${{ needs.meta.outputs.source_sha }}"
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-desktop
|
||||
path: release-out/fragments/fluxer-release-fragment-desktop-${{ needs.meta.outputs.build_channel }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Cleanup S3 handoff
|
||||
if: ${{ success() }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step cleanup_handoff
|
||||
|
||||
finalise_release:
|
||||
name: Finalise GitHub desktop release
|
||||
publish_release:
|
||||
name: Publish GitHub desktop release
|
||||
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
|
||||
needs:
|
||||
- meta
|
||||
@@ -579,6 +569,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -589,18 +581,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
|
||||
- name: Finalise GitHub desktop release
|
||||
- name: Publish GitHub desktop release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ needs.meta.outputs.version }}"
|
||||
--source-sha "${{ needs.meta.outputs.source_sha }}"
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_args=(
|
||||
release publish
|
||||
--component "fluxer-desktop-${CHANNEL}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,12 +28,9 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-docs
|
||||
dockerfile: fluxer_docs/Dockerfile
|
||||
context: fluxer_docs
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-gateway
|
||||
dockerfile: fluxer_gateway/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-gifs
|
||||
dockerfile: fluxer_gifs/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -1,60 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: build marketing
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Build release approved."
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-marketing
|
||||
dockerfile: fluxer_marketing/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-media-proxy
|
||||
dockerfile: fluxer_media_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-messages
|
||||
dockerfile: fluxer_messages/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-snowflakes
|
||||
dockerfile: fluxer_snowflakes/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-static
|
||||
dockerfile: fluxer_static/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-unfurl
|
||||
dockerfile: fluxer_unfurl/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-users
|
||||
dockerfile: fluxer_users/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -1,473 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: deploy service
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: "Helm chart name to deploy"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- api
|
||||
- app-proxy
|
||||
- admin
|
||||
- docs
|
||||
- marketing
|
||||
- media-proxy
|
||||
- gateway
|
||||
- messages
|
||||
- search
|
||||
- snowflakes
|
||||
- users
|
||||
- unfurl
|
||||
- uploads
|
||||
- worker
|
||||
channel:
|
||||
description: "Release channel (stable or canary)"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- stable
|
||||
- canary
|
||||
image-tag:
|
||||
description: "Docker image tag to deploy (Fluxer CalVer: YYYY.MDD.MICRO)"
|
||||
type: string
|
||||
required: true
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version to inject into runtime env vars"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow-rollback:
|
||||
description: "Allow deploying an older image tag than the newest GHCR tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
service:
|
||||
description: "Helm chart name to deploy"
|
||||
type: string
|
||||
required: true
|
||||
channel:
|
||||
description: "Release channel (stable or canary)"
|
||||
type: string
|
||||
required: true
|
||||
image-tag:
|
||||
description: "Docker image tag to deploy (Fluxer CalVer: YYYY.MDD.MICRO)"
|
||||
type: string
|
||||
required: true
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version to inject into runtime env vars"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow-rollback:
|
||||
description: "Allow deploying an older image tag than the newest GHCR tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
KUBE_CONFIG:
|
||||
required: true
|
||||
GHCR_USERNAME:
|
||||
required: false
|
||||
GHCR_TOKEN:
|
||||
required: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy ${{ inputs.service }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
environment: ${{ inputs.channel }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: install helm
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310
|
||||
|
||||
- name: configure kubectl
|
||||
shell: bash
|
||||
env:
|
||||
KUBE_CONFIG_B64: ${{ secrets.KUBE_CONFIG }}
|
||||
run: |
|
||||
mkdir -p "$HOME/.kube"
|
||||
printf '%s' "$KUBE_CONFIG_B64" | base64 -d > "$HOME/.kube/config"
|
||||
chmod 600 "$HOME/.kube/config"
|
||||
|
||||
- name: resolve helm args
|
||||
id: helm
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_SERVICE: ${{ inputs.service }}
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
INPUT_IMAGE_TAG: ${{ inputs['image-tag'] }}
|
||||
INPUT_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: |
|
||||
SERVICE="$INPUT_SERVICE"
|
||||
CHANNEL="$INPUT_CHANNEL"
|
||||
TAG="$INPUT_IMAGE_TAG"
|
||||
BUILD_VERSION="$INPUT_BUILD_VERSION"
|
||||
GHCR_REGISTRY="${GHCR_REGISTRY:?GHCR_REGISTRY is required}"
|
||||
if [[ -z "$BUILD_VERSION" ]]; then
|
||||
BUILD_VERSION="$TAG"
|
||||
fi
|
||||
CALVER_RE='^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.(0|[1-9][0-9]{0,5})$'
|
||||
if [[ ! "$TAG" =~ $CALVER_RE ]]; then
|
||||
echo "::error::image-tag must be a Fluxer CalVer tag (YYYY.MDD.MICRO). Channel tags, latest tags, and suffixed tags are not deployable."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$BUILD_VERSION" =~ $CALVER_RE ]]; then
|
||||
echo "::error::build-version must be a Fluxer CalVer value (YYYY.MDD.MICRO)."
|
||||
exit 1
|
||||
fi
|
||||
CHART_DIR="./deploy/helm/${SERVICE}"
|
||||
VALUES_ARGS="-f ${CHART_DIR}/values.yaml"
|
||||
SETS=""
|
||||
BUILD_PATHS=""
|
||||
DEPLOY_IMAGE=""
|
||||
SYNC_WORKER_RELEASE=""
|
||||
SYNC_WORKER_CHART_DIR=""
|
||||
SYNC_WORKER_VALUES_ARGS=""
|
||||
SYNC_WORKER_SETS=""
|
||||
case "$SERVICE" in
|
||||
uploads)
|
||||
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::uploads deployments are stable-only (single relay serves both channels)."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-uploads"
|
||||
DEPLOY_IMAGE="fluxer-media-proxy"
|
||||
SETS="--set-string app.name=uploads --set-string app.image=fluxer-media-proxy --set-string app.tag=${TAG} --set-string app.config=stable"
|
||||
SETS="${SETS} --set-string app.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string app.build.channel=stable"
|
||||
;;
|
||||
api|app-proxy|admin|docs|marketing)
|
||||
BASE_IMAGE="fluxer-${SERVICE}"
|
||||
if [[ "$SERVICE" == "docs" && "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::docs deployments are stable-only."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CHANNEL" == "canary" ]]; then
|
||||
NAME="${SERVICE}-canary"
|
||||
else
|
||||
NAME="${SERVICE}"
|
||||
fi
|
||||
DEPLOY_IMAGE="${BASE_IMAGE}"
|
||||
RELEASE="fluxer-${SERVICE}-${CHANNEL}"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.${CHANNEL}.prod.yaml"
|
||||
SETS="--set-string app.name=${NAME} --set-string app.image=${DEPLOY_IMAGE} --set-string app.tag=${TAG}"
|
||||
SETS="${SETS} --set-string app.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string app.build.channel=${CHANNEL}"
|
||||
;;
|
||||
media-proxy)
|
||||
if [[ "$CHANNEL" != "canary" ]]; then
|
||||
echo "::error::Media-proxy deployments are only supported on the canary lane."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-media-proxy"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string mediaProxy.image=fluxer-media-proxy --set-string staticProxy.image=fluxer-media-proxy --set-string mediaProxy.tag=${TAG} --set-string staticProxy.tag=${TAG} --set mediaProxy.replicas=16 --set staticProxy.replicas=4 --set-string mediaProxy.nsfwServiceEndpoint=http://int.flx-nyc-misc1.srv.fluxer.dev:8000"
|
||||
BUILD_PATHS="mediaProxy staticProxy"
|
||||
;;
|
||||
gateway)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::gateway deployments are stable-only."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-gateway"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string gateway.image=${DEPLOY_IMAGE} --set-string gateway.tag=${TAG}"
|
||||
BUILD_PATHS="gateway"
|
||||
;;
|
||||
worker)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::Worker deployments are only supported on the stable lane."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-api"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string workerRealtime.image=fluxer-api --set-string workerUnfurl.image=fluxer-api --set-string workerLifecycle.image=fluxer-api --set-string workerBatch.image=fluxer-api --set-string workerRealtime.tag=${TAG} --set-string workerUnfurl.tag=${TAG} --set-string workerLifecycle.tag=${TAG} --set-string workerBatch.tag=${TAG}"
|
||||
BUILD_PATHS="workerRealtime workerUnfurl workerLifecycle workerBatch"
|
||||
;;
|
||||
messages|search|snowflakes|users|unfurl)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::Shared microservice deployments are stable-only; canary traffic selection is done by the callers."
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_IMAGE="fluxer-${SERVICE}"
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string svc.image=${DEPLOY_IMAGE} --set-string svc.tag=${TAG}"
|
||||
SETS="${SETS} --set-string svc.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string svc.build.channel=stable"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown service chart: ${SERVICE}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
for BUILD_PATH in $BUILD_PATHS; do
|
||||
SETS="${SETS} --set-string ${BUILD_PATH}.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string ${BUILD_PATH}.build.channel=${CHANNEL}"
|
||||
done
|
||||
SETS="--set-string global.registry=${GHCR_REGISTRY} ${SETS}"
|
||||
if [[ "$SERVICE" == "api" && "$CHANNEL" == "canary" ]]; then
|
||||
SYNC_WORKER_RELEASE="fluxer-worker"
|
||||
SYNC_WORKER_CHART_DIR="./deploy/helm/worker"
|
||||
SYNC_WORKER_VALUES_ARGS="-f ${SYNC_WORKER_CHART_DIR}/values.yaml -f ${SYNC_WORKER_CHART_DIR}/values.prod.yaml"
|
||||
SYNC_WORKER_SETS="--set-string workerRealtime.image=fluxer-api --set-string workerUnfurl.image=fluxer-api --set-string workerLifecycle.image=fluxer-api --set-string workerBatch.image=fluxer-api"
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string workerRealtime.tag=${TAG} --set-string workerUnfurl.tag=${TAG} --set-string workerLifecycle.tag=${TAG} --set-string workerBatch.tag=${TAG}"
|
||||
for BUILD_PATH in workerRealtime workerUnfurl workerLifecycle workerBatch; do
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string ${BUILD_PATH}.build.version=${BUILD_VERSION}"
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string ${BUILD_PATH}.build.channel=${CHANNEL}"
|
||||
done
|
||||
SYNC_WORKER_SETS="--set-string global.registry=${GHCR_REGISTRY} ${SYNC_WORKER_SETS}"
|
||||
fi
|
||||
{
|
||||
echo "chart-dir=${CHART_DIR}"
|
||||
echo "release=${RELEASE}"
|
||||
echo "values-args=${VALUES_ARGS}"
|
||||
echo "sets=${SETS}"
|
||||
echo "deploy-image=${DEPLOY_IMAGE}"
|
||||
echo "deploy-tag=${TAG}"
|
||||
echo "sync-worker-release=${SYNC_WORKER_RELEASE}"
|
||||
echo "sync-worker-chart-dir=${SYNC_WORKER_CHART_DIR}"
|
||||
echo "sync-worker-values-args=${SYNC_WORKER_VALUES_ARGS}"
|
||||
echo "sync-worker-sets=${SYNC_WORKER_SETS}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: helm dependency update
|
||||
shell: bash
|
||||
run: |
|
||||
helm dependency update "${{ steps.helm.outputs.chart-dir }}"
|
||||
if [[ -n "${{ steps.helm.outputs.sync-worker-chart-dir }}" ]]; then
|
||||
helm dependency update "${{ steps.helm.outputs.sync-worker-chart-dir }}"
|
||||
fi
|
||||
|
||||
- name: prepare docker config
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
run: |
|
||||
echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config" >> "$GITHUB_ENV"
|
||||
mkdir -p "${RUNNER_TEMP}/docker-config"
|
||||
|
||||
- name: configure ghcr auth
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
auth="$(printf '%s:%s' "$GHCR_USERNAME" "$GHCR_TOKEN" | base64 | tr -d '\n')"
|
||||
printf '{"auths":{"ghcr.io":{"auth":"%s"}}}\n' "$auth" > "$DOCKER_CONFIG/config.json"
|
||||
|
||||
- name: verify deploy image exists
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
run: |
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
echo "Verifying ${IMAGE_REF}"
|
||||
docker manifest inspect "${IMAGE_REF}" > /dev/null
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
- name: verify api deploy uses latest image
|
||||
if: ${{ steps.helm.outputs.deploy-image == 'fluxer-api' && !inputs['allow-rollback'] }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GHCR_OWNER: ${{ env.GHCR_OWNER }}
|
||||
DEPLOY_TAG: ${{ steps.helm.outputs.deploy-tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CALVER_RE='^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.(0|[1-9][0-9]{0,5})$'
|
||||
OWNER_TYPE="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL:-https://api.github.com}/repos/${GITHUB_REPOSITORY}" \
|
||||
| jq -r '.owner.type'
|
||||
)"
|
||||
case "$OWNER_TYPE" in
|
||||
Organization) PACKAGE_OWNER_PATH="orgs/${GHCR_OWNER}" ;;
|
||||
User) PACKAGE_OWNER_PATH="users/${GHCR_OWNER}" ;;
|
||||
*)
|
||||
echo "::error::Unsupported GitHub owner type for package lookup: ${OWNER_TYPE}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
LATEST_TAG="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL:-https://api.github.com}/${PACKAGE_OWNER_PATH}/packages/container/fluxer-api/versions?per_page=100" \
|
||||
| jq -r --arg re "$CALVER_RE" '
|
||||
[.[].metadata.container.tags[]? |
|
||||
select(test($re)) |
|
||||
{tag: ., parts: (split(".") | map(tonumber))}
|
||||
] | max_by(.parts) | .tag // empty
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ -z "$LATEST_TAG" ]]; then
|
||||
echo "::error::Could not resolve the latest fluxer-api CalVer tag from GHCR."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DEPLOY_TAG" != "$LATEST_TAG" ]]; then
|
||||
echo "::error::Refusing to deploy fluxer-api:${DEPLOY_TAG}; latest GHCR tag is fluxer-api:${LATEST_TAG}. Re-run with allow-rollback=true only for an intentional rollback."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: approve api image for admission policy
|
||||
if: ${{ inputs.service == 'api' }}
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
DEPLOYMENT="api"
|
||||
if [[ "$INPUT_CHANNEL" == "canary" ]]; then
|
||||
DEPLOYMENT="api-canary"
|
||||
fi
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
PREVIOUS_IMAGE="$(kubectl -n fluxer get deployment "$DEPLOYMENT" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true)"
|
||||
PREVIOUS_TAG=""
|
||||
if [[ -n "$PREVIOUS_IMAGE" && "$PREVIOUS_IMAGE" != "$IMAGE_REF" && "$PREVIOUS_IMAGE" == *:* ]]; then
|
||||
PREVIOUS_TAG="${PREVIOUS_IMAGE##*:}"
|
||||
else
|
||||
PREVIOUS_IMAGE=""
|
||||
fi
|
||||
kubectl -n fluxer create configmap fluxer-api-approved-image \
|
||||
--from-literal=tag="${{ steps.helm.outputs.deploy-tag }}" \
|
||||
--from-literal=image="${IMAGE_REF}" \
|
||||
--from-literal=previousTag="${PREVIOUS_TAG}" \
|
||||
--from-literal=previousImage="${PREVIOUS_IMAGE}" \
|
||||
--dry-run=client -o yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
- name: ensure api admission policy
|
||||
if: ${{ inputs.service == 'api' }}
|
||||
shell: bash
|
||||
run: kubectl apply -f deploy/k8s/fluxer-api-approved-image-policy.yaml
|
||||
|
||||
- name: helm upgrade
|
||||
shell: bash
|
||||
run: |
|
||||
RELEASE="${{ steps.helm.outputs.release }}"
|
||||
CHART_DIR="${{ steps.helm.outputs.chart-dir }}"
|
||||
VALUES_ARGS="${{ steps.helm.outputs.values-args }}"
|
||||
SETS="${{ steps.helm.outputs.sets }}"
|
||||
wait_for_release_idle() {
|
||||
local release="$1"
|
||||
local max_checks="$2"
|
||||
local check=0
|
||||
local status="unknown"
|
||||
while (( check < max_checks )); do
|
||||
check=$((check + 1))
|
||||
status=$(helm status "$release" -n fluxer -o json 2>/dev/null | jq -r '.info.status // "unknown"' || echo "unknown")
|
||||
if [[ "$status" != pending-* ]]; then
|
||||
echo "Release ${release} is ${status}; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "Release ${release} is ${status}; waiting 10s (${check}/${max_checks})."
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::Release ${release} still ${status} after ${max_checks} checks; forcing rollback."
|
||||
if helm rollback "$release" -n fluxer --wait --timeout 5m 2>&1; then
|
||||
echo "Rollback succeeded; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "::error::Release ${release} is stuck in ${status} and rollback failed."
|
||||
return 1
|
||||
}
|
||||
helm_upgrade_with_retries() {
|
||||
local release="$1"
|
||||
local chart_dir="$2"
|
||||
local values_args="$3"
|
||||
local sets="$4"
|
||||
local values_args_array=()
|
||||
local sets_array=()
|
||||
read -r -a values_args_array <<< "$values_args"
|
||||
read -r -a sets_array <<< "$sets"
|
||||
wait_for_release_idle "$release" 18
|
||||
local max_attempts=4
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
echo "Running helm upgrade for ${release}, attempt ${attempt}/${max_attempts}."
|
||||
set +e
|
||||
upgrade_output=$(helm upgrade --install "$release" \
|
||||
"$chart_dir" \
|
||||
"${values_args_array[@]}" \
|
||||
-n fluxer \
|
||||
"${sets_array[@]}" \
|
||||
--wait --timeout 20m --atomic --history-max 10 2>&1)
|
||||
exit_code=$?
|
||||
set -e
|
||||
printf '%s\n' "$upgrade_output"
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! grep -q "another operation (install/upgrade/rollback) is in progress" <<< "$upgrade_output"; then
|
||||
return "$exit_code"
|
||||
fi
|
||||
if [[ $attempt -eq $max_attempts ]]; then
|
||||
echo "::error::Helm upgrade failed for ${release} after ${max_attempts} attempts because another operation remained in progress."
|
||||
return "$exit_code"
|
||||
fi
|
||||
wait_for_release_idle "$release" 18
|
||||
done
|
||||
}
|
||||
helm_upgrade_with_retries "$RELEASE" "$CHART_DIR" "$VALUES_ARGS" "$SETS"
|
||||
if [[ -n "${{ steps.helm.outputs.sync-worker-release }}" ]]; then
|
||||
helm_upgrade_with_retries \
|
||||
"${{ steps.helm.outputs.sync-worker-release }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-chart-dir }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-values-args }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-sets }}"
|
||||
fi
|
||||
|
||||
- name: seal api admission approved image
|
||||
if: ${{ success() && inputs.service == 'api' }}
|
||||
shell: bash
|
||||
run: |
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
kubectl -n fluxer create configmap fluxer-api-approved-image \
|
||||
--from-literal=tag="${{ steps.helm.outputs.deploy-tag }}" \
|
||||
--from-literal=image="${IMAGE_REF}" \
|
||||
--from-literal=previousTag="" \
|
||||
--from-literal=previousImage="" \
|
||||
--dry-run=client -o yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
- name: recover stuck release on failure
|
||||
if: failure() || cancelled()
|
||||
shell: bash
|
||||
run: |
|
||||
RELEASE="${{ steps.helm.outputs.release }}"
|
||||
for RELEASE in "$RELEASE" "${{ steps.helm.outputs.sync-worker-release }}"; do
|
||||
if [[ -z "$RELEASE" ]]; then
|
||||
continue
|
||||
fi
|
||||
STATUS=$(helm status "$RELEASE" -n fluxer -o json 2>/dev/null | jq -r '.info.status' 2>/dev/null || echo "unknown")
|
||||
if [[ "$STATUS" == "pending-upgrade" || "$STATUS" == "pending-install" || "$STATUS" == "pending-rollback" ]]; then
|
||||
echo "::warning::Release ${RELEASE} stuck in ${STATUS}, rolling back..."
|
||||
helm rollback "$RELEASE" -n fluxer --wait --timeout 5m || true
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,220 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: Dispatch private marketing build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_marketing
|
||||
- Cargo.toml
|
||||
- fluxer_common/**
|
||||
- packages/fonts/manifest.json
|
||||
- packages/fonts/NOTICE.md
|
||||
- packages/fonts/LICENSE-IBM-PLEX.txt
|
||||
- packages/fonts/css/locale-fallbacks.css
|
||||
- packages/fonts/files/FluxerSans/**
|
||||
- packages/fonts/files/FluxerMono/**
|
||||
- packages/fonts/marketing/**
|
||||
- packages/i18n/marketing/**
|
||||
- fluxer_static/marketing/branding/**
|
||||
- .github/workflows/dispatch-private-marketing-build.yaml
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: private-marketing-dispatch
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: resolve exact private build metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
parent_sha: ${{ steps.inputs.outputs.parent_sha }}
|
||||
gitlink_sha: ${{ steps.inputs.outputs.gitlink_sha }}
|
||||
build_version: ${{ steps.inputs.outputs.build_version }}
|
||||
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
|
||||
steps:
|
||||
- name: Resolve trusted build inputs
|
||||
id: inputs
|
||||
env:
|
||||
EVENT_AFTER: ${{ github.event.after }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARENT_SHA: ${{ github.sha }}
|
||||
PUBLIC_REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$PUBLIC_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$EVENT_AFTER" == "$PARENT_SHA" ]]
|
||||
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
|
||||
(( 10#$RUN_ATTEMPT <= 10 ))
|
||||
|
||||
main_sha="$(gh api "repos/$PUBLIC_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
|
||||
[[ "$main_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
main_comparison="$(gh api "repos/$PUBLIC_REPOSITORY/compare/$PARENT_SHA...$main_sha")"
|
||||
main_status="$(jq -r .status <<<"$main_comparison")"
|
||||
[[ "$main_status" == "identical" || "$main_status" == "ahead" ]]
|
||||
[[ "$(jq -r .merge_base_commit.sha <<<"$main_comparison")" == "$PARENT_SHA" ]]
|
||||
|
||||
commit="$(gh api "repos/$PUBLIC_REPOSITORY/git/commits/$PARENT_SHA")"
|
||||
[[ "$(jq -r .sha <<<"$commit")" == "$PARENT_SHA" ]]
|
||||
tree_sha="$(jq -r .tree.sha <<<"$commit")"
|
||||
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
entry="$(
|
||||
gh api "repos/$PUBLIC_REPOSITORY/git/trees/$tree_sha" |
|
||||
jq -cer '[.tree[] | select(.path == "fluxer_marketing")] | if length == 1 then .[0] else error("expected exactly one marketing gitlink") end'
|
||||
)"
|
||||
mode="$(jq -r .mode <<<"$entry")"
|
||||
type="$(jq -r .type <<<"$entry")"
|
||||
gitlink_sha="$(jq -r .sha <<<"$entry")"
|
||||
path="$(jq -r .path <<<"$entry")"
|
||||
if [[ "$mode" != "160000" || "$type" != "commit" || "$path" != "fluxer_marketing" || ! "$gitlink_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Public parent does not contain a valid fluxer_marketing gitlink."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run="$(gh api "repos/$PUBLIC_REPOSITORY/actions/runs/$RUN_ID")"
|
||||
[[ "$(jq -r .id <<<"$run")" == "$RUN_ID" ]]
|
||||
[[ "$(jq -r .run_attempt <<<"$run")" == "$RUN_ATTEMPT" ]]
|
||||
[[ "$(jq -r .event <<<"$run")" == "push" ]]
|
||||
[[ "$(jq -r .head_sha <<<"$run")" == "$PARENT_SHA" ]]
|
||||
run_created_at="$(jq -r .created_at <<<"$run")"
|
||||
[[ "$run_created_at" =~ ^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
||||
run_created_epoch="$(date -u -d "$run_created_at" +%s)"
|
||||
[[ "$run_created_epoch" =~ ^[1-9][0-9]*$ ]]
|
||||
build_epoch=$((run_created_epoch + 10#$RUN_ATTEMPT - 1))
|
||||
read -r year month day time_segment <<<"$(date -u -d "@$build_epoch" '+%Y %m %d %H%M%S')"
|
||||
month="$((10#$month))"
|
||||
micro="$((10#$time_segment))"
|
||||
build_version="$year.$month$day.$micro"
|
||||
[[ "$build_version" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
correlation_id="public-${RUN_ID}-${RUN_ATTEMPT}"
|
||||
[[ "$correlation_id" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
{
|
||||
echo "parent_sha=$PARENT_SHA"
|
||||
echo "gitlink_sha=$gitlink_sha"
|
||||
echo "build_version=$build_version"
|
||||
echo "correlation_id=$correlation_id"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
dispatch:
|
||||
name: dispatch exact private build
|
||||
needs: metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 65
|
||||
environment: private-marketing-dispatch
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Validate trusted build inputs
|
||||
env:
|
||||
DISPATCH_ENABLED: ${{ vars.MARKETING_DISPATCH_ENABLED }}
|
||||
EXPECTED_PARENT_SHA: ${{ github.sha }}
|
||||
EXPECTED_CORRELATION_ID: public-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$GITHUB_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" == "$EXPECTED_PARENT_SHA" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$GITLINK_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$BUILD_VERSION" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
[[ "$CORRELATION_ID" == "$EXPECTED_CORRELATION_ID" ]]
|
||||
[[ "$CORRELATION_ID" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
if [[ "$DISPATCH_ENABLED" != "true" ]]; then
|
||||
echo "::error::Private marketing dispatch is intentionally disabled until the package cutover guard completes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create private dispatch token
|
||||
id: private-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: marketing
|
||||
permission-actions: write
|
||||
|
||||
- name: Dispatch exact private build
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api --method POST repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/dispatches \
|
||||
--field ref=main \
|
||||
--field "inputs[parent_sha]=$PARENT_SHA" \
|
||||
--field "inputs[gitlink_sha]=$GITLINK_SHA" \
|
||||
--field "inputs[build_version]=$BUILD_VERSION" \
|
||||
--field "inputs[correlation_id]=$CORRELATION_ID"
|
||||
|
||||
- name: Wait for private build conclusion
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_title="marketing-build correlation=$CORRELATION_ID parent=$PARENT_SHA gitlink=$GITLINK_SHA version=$BUILD_VERSION"
|
||||
deadline=$((SECONDS + 3600))
|
||||
run_id=""
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
count="$(jq 'length' <<<"$matches")"
|
||||
if [[ "$count" == "1" ]]; then
|
||||
run_id="$(jq -r '.[0].id' <<<"$matches")"
|
||||
break
|
||||
fi
|
||||
if [[ "$count" != "0" ]]; then
|
||||
echo "::error::Private build correlation matched multiple workflow runs."
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::error::Timed out waiting for the private build dispatch to appear."
|
||||
exit 1
|
||||
fi
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
if [[ "$(jq 'length' <<<"$matches")" != "1" || "$(jq -r '.[0].id' <<<"$matches")" != "$run_id" ]]; then
|
||||
echo "::error::Private build correlation is missing or ambiguous."
|
||||
exit 1
|
||||
fi
|
||||
run="$(jq '.[0]' <<<"$matches")"
|
||||
status="$(jq -r '.status' <<<"$run")"
|
||||
conclusion="$(jq -r '.conclusion // empty' <<<"$run")"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
echo "::error::Private marketing build concluded with $conclusion."
|
||||
exit 1
|
||||
fi
|
||||
echo "Private marketing build completed successfully."
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "::error::Timed out waiting for the private marketing build."
|
||||
exit 1
|
||||
@@ -1,51 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: finalise release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes)"
|
||||
type: string
|
||||
required: true
|
||||
fragment-run-id:
|
||||
description: "Workflow run id that produced the release-fragment-* artifacts"
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
finalise:
|
||||
name: finalise GitHub release manifest
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
gh run download "${{ inputs.fragment-run-id }}"
|
||||
--pattern "release-fragment-*"
|
||||
--dir release-out/fragments
|
||||
- name: Finalise release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ inputs.build-version }}"
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales fluxer_marketing/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
echo "No source catalog changes."
|
||||
exit 0
|
||||
fi
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
git config user.name "fluxer-ci[bot]"
|
||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||
git switch -c "$SOURCE_BRANCH"
|
||||
git add fluxer_app/src/features/i18n/locales fluxer_marketing/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git commit -m "chore(i18n): refresh source catalogs"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git fetch origin "$SOURCE_BRANCH" || true
|
||||
|
||||
@@ -77,14 +77,14 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
echo "No generated catalog changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "fluxer-ci[bot]"
|
||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git add fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git commit -m "i18n: compile Weblate catalogs"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git push origin "HEAD:$WEBLATE_BRANCH"
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: release all builds
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: release-all-${{ inputs['build-version'] || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve release build
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Release build approved."
|
||||
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: set variables
|
||||
id: vars
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- resolve-calver
|
||||
--github-output
|
||||
|
||||
build_admin:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-admin.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_api:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-api.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_app_proxy:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-app-proxy.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_app_proxy_self_hosted:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-app-proxy-self-hosted.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_docs:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-docs.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_gateway:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-gateway.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_gifs:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-gifs.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_marketing:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-marketing.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_media_proxy:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-media-proxy.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_messages:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-messages.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_snowflakes:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-snowflakes.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_static:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-static.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_unfurl:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-unfurl.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_users:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-users.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
release_assets:
|
||||
name: package Helm/self-hosting
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
needs:
|
||||
- meta
|
||||
- build_admin
|
||||
- build_api
|
||||
- build_app_proxy
|
||||
- build_app_proxy_self_hosted
|
||||
- build_docs
|
||||
- build_gateway
|
||||
- build_gifs
|
||||
- build_marketing
|
||||
- build_media_proxy
|
||||
- build_messages
|
||||
- build_snowflakes
|
||||
- build_static
|
||||
- build_unfurl
|
||||
- build_users
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310
|
||||
- name: Publish self-hosting bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-self-hosting
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
- name: Publish Helm chart bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-helm
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
- name: Upload release asset fragments
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-release-assets
|
||||
path: release-out/fragments/*.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release manifest
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
needs:
|
||||
- meta
|
||||
- build_admin
|
||||
- build_api
|
||||
- build_app_proxy
|
||||
- build_app_proxy_self_hosted
|
||||
- build_docs
|
||||
- build_gateway
|
||||
- build_gifs
|
||||
- build_marketing
|
||||
- build_media_proxy
|
||||
- build_messages
|
||||
- build_snowflakes
|
||||
- build_static
|
||||
- build_unfurl
|
||||
- build_users
|
||||
- release_assets
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: Finalise GitHub release manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
libwebp-dev
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm --filter fluxer_admin --filter fluxer_marketing install
|
||||
run: pnpm --filter fluxer_admin install
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/release-input/
|
||||
/release-out/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "fluxer_marketing"]
|
||||
path = fluxer_marketing
|
||||
url = https://github.com/fluxerapp/marketing.git
|
||||
update = none
|
||||
Generated
-443
@@ -2,12 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accept-language"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f27d075294830fcab6f66e320dab524bc6d048f4a151698e153205559113772"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -896,25 +890,6 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calendrical_calculations"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
"displaydoc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caseless"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
|
||||
dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -1103,29 +1078,6 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "comrak"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aac0b255932a9cd52fbfd664b67957f9f2e095ae4711cb0e41b4e291edef94c2"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"entities",
|
||||
"finl_unicode",
|
||||
"jetscii",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
"rustc-hash",
|
||||
"smallvec",
|
||||
"typed-arena",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concat-string"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1173,15 +1125,6 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1681,79 +1624,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding"
|
||||
version = "0.2.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec"
|
||||
dependencies = [
|
||||
"encoding-index-japanese",
|
||||
"encoding-index-korean",
|
||||
"encoding-index-simpchinese",
|
||||
"encoding-index-singlebyte",
|
||||
"encoding-index-tradchinese",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-japanese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-korean"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-simpchinese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-singlebyte"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-tradchinese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_index_tests"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"
|
||||
|
||||
[[package]]
|
||||
name = "entities"
|
||||
version = "1.0.1"
|
||||
@@ -1834,39 +1704,12 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flagset"
|
||||
version = "0.4.7"
|
||||
@@ -1895,14 +1738,12 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"hex",
|
||||
"md-5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
@@ -1975,17 +1816,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-marketing-update-gettext-catalogs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"serde_json",
|
||||
"syn",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-media-proxy"
|
||||
version = "0.1.0"
|
||||
@@ -2217,42 +2047,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_marketing"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accept-language",
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"comrak",
|
||||
"cookie",
|
||||
"email_address",
|
||||
"fluxer_common",
|
||||
"gettext",
|
||||
"hmac 0.13.0",
|
||||
"http-body-util",
|
||||
"icu_datetime",
|
||||
"icu_locale",
|
||||
"maud",
|
||||
"mime_guess",
|
||||
"moka",
|
||||
"polib",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"syn",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -2445,16 +2239,6 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gettext"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ebb594e753d5997e4be036e5a8cf048ab9414352870fb45c779557bbc9ba971"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
@@ -2816,29 +2600,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"displaydoc",
|
||||
"icu_calendar_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
"ixdtf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
@@ -2853,73 +2614,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_datetime"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989d56ea5bbc43ae2b4e0388874b002884eaf4ed3a76c84a6c8c5ad575e04d72"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_calendar",
|
||||
"icu_datetime_data",
|
||||
"icu_decimal",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"icu_time",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_datetime_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40d3cc1b690d9703202bc319692ac8a1f3a6390686f0930ff40542450fa34f0b"
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_decimal_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22"
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_locale_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
@@ -2928,18 +2622,11 @@ checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
@@ -2960,38 +2647,6 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c4c568054ffe735398a9f4c55aec37ad7c768844553cc0978f09cc9b933a1fb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_locale",
|
||||
"icu_plurals_data",
|
||||
"icu_provider",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
@@ -3020,8 +2675,6 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -3029,30 +2682,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_time"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec3af0c141da0a61d4f6970cd1d5f4b388b17ea22f8124f8f6049d3d5147586a"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"displaydoc",
|
||||
"icu_calendar",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
"icu_time_data",
|
||||
"ixdtf",
|
||||
"serde",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_time_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f2f8aeca682d874a5247084aa4fb7d1cef9ba45d889c21209a8818dcaaa0ec9"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
@@ -3177,18 +2806,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "ixdtf"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ceaf4c6c48465bead8cb6a0b7c4ee0c86ecbb31239032b9c66ab9a08d2f3ee1"
|
||||
|
||||
[[package]]
|
||||
name = "jetscii"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3278,12 +2895,6 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.17"
|
||||
@@ -3293,15 +2904,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linereader"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d921fea6860357575519aca014c6e22470585accdd543b370c404a8a72d0dd1d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify"
|
||||
version = "0.11.0"
|
||||
@@ -3934,16 +3536,6 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee83e5a284d919e51b071969bbf2d12d6943857aab02d84c5cc449373c9f3b7b"
|
||||
dependencies = [
|
||||
"concat-string",
|
||||
"linereader",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -3987,8 +3579,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5284,17 +4874,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5396,7 +4975,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5728,12 +5306,6 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
|
||||
[[package]]
|
||||
name = "typed-arena"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
@@ -6471,9 +6043,6 @@ name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyhash"
|
||||
@@ -6496,16 +6065,6 @@ dependencies = [
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
@@ -6605,7 +6164,6 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6614,7 +6172,6 @@ version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ members = [
|
||||
"fluxer_admin",
|
||||
"fluxer_app_proxy",
|
||||
"fluxer_common",
|
||||
"fluxer_marketing",
|
||||
"fluxer_media_proxy",
|
||||
"fluxer_gifs",
|
||||
"fluxer_svc",
|
||||
@@ -13,12 +12,12 @@ members = [
|
||||
"tools/content/update-frozen-snapshot",
|
||||
"tools/dev",
|
||||
"tools/i18n_auto",
|
||||
"tools/marketing/update-gettext-catalogs",
|
||||
"fluxer_users",
|
||||
"fluxer_unfurl",
|
||||
"packages/markdown_parser/rust",
|
||||
]
|
||||
exclude = [
|
||||
"fluxer_marketing",
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.19.16",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.23.1",
|
||||
|
||||
@@ -145,7 +145,6 @@
|
||||
"!fluxer_static",
|
||||
"!packages/fonts",
|
||||
"!fluxer_admin/static/htmx.min.js",
|
||||
"!fluxer_marketing/static/htmx.min.js",
|
||||
"!fluxer_api/src/api/openapi/openapi.json"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
|
||||
Vendored
+1
-1
@@ -17,7 +17,7 @@ FLUXER_GATEWAY_ENDPOINT=ws://localhost:8088/gateway
|
||||
FLUXER_MEDIA_ENDPOINT=http://localhost:8088/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT=http://localhost:8088
|
||||
FLUXER_ADMIN_ENDPOINT=http://localhost:8088/admin
|
||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
||||
FLUXER_MARKETING_ENDPOINT=https://fluxer.app
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
|
||||
|
||||
|
||||
@@ -276,6 +276,9 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
ipinfoApiKey: master.integrations.risk_integration.ipinfo_api_key || undefined,
|
||||
accountPolicyDsl: master.integrations.risk_integration.account_policy_dsl,
|
||||
},
|
||||
blocklistFeeds: {
|
||||
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
captcha: {
|
||||
enabled: master.integrations.captcha.enabled,
|
||||
provider: master.integrations.captcha.provider,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {createUserID} from '../BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, fetchPage, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import type {
|
||||
AdminAuditLogRow,
|
||||
BannedAvatarHashRow,
|
||||
@@ -30,13 +30,7 @@ import {
|
||||
} from '../Tables';
|
||||
import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils';
|
||||
import {canonicalizeStoredPhrase} from '../utils/PhraseBlocklistNormalization';
|
||||
import type {
|
||||
AdminAuditLog,
|
||||
BannedIpEntry,
|
||||
BannedIpKind,
|
||||
DisposableEmailDomainPage,
|
||||
IAdminRepository,
|
||||
} from './IAdminRepository';
|
||||
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from './IAdminRepository';
|
||||
|
||||
const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({
|
||||
where: AdminAuditLogs.where.eq('log_id'),
|
||||
@@ -51,8 +45,6 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
|
||||
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
|
||||
where: SuspiciousEmailDomains.where.eq('domain'),
|
||||
});
|
||||
const createLoadSuspiciousEmailDomainsQuery = (limit?: number) =>
|
||||
limit ? SuspiciousEmailDomains.select({limit}) : SuspiciousEmailDomains.select();
|
||||
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
|
||||
where: DisposableEmailDomains.where.eq('domain'),
|
||||
});
|
||||
@@ -273,13 +265,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
|
||||
}
|
||||
|
||||
async listSuspiciousEmailDomains(limit?: number): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{
|
||||
domain: string;
|
||||
}>(createLoadSuspiciousEmailDomainsQuery(limit).bind({}));
|
||||
return rows.map((row) => row.domain);
|
||||
}
|
||||
|
||||
async isEmailDomainDisposable(domain: string): Promise<boolean> {
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
@@ -306,19 +291,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return rows.map((row) => row.domain);
|
||||
}
|
||||
|
||||
async listDisposableEmailDomainsPage(limit: number, pageState?: string | null): Promise<DisposableEmailDomainPage> {
|
||||
const page = await fetchPage<{
|
||||
domain: string;
|
||||
}>(createLoadDisposableEmailDomainsQuery().bind({}), undefined, {
|
||||
pageSize: limit,
|
||||
pageState,
|
||||
});
|
||||
return {
|
||||
domains: page.rows.map((row) => row.domain),
|
||||
pageState: page.pageState,
|
||||
};
|
||||
}
|
||||
|
||||
async isPhraseBanned(phrase: string): Promise<boolean> {
|
||||
const phraseLower = canonicalizeStoredPhrase(phrase);
|
||||
const result = await fetchOne<{
|
||||
|
||||
@@ -32,11 +32,6 @@ export interface BannedIpEntry {
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
export interface DisposableEmailDomainPage {
|
||||
domains: Array<string>;
|
||||
pageState: string | null;
|
||||
}
|
||||
|
||||
export abstract class IAdminRepository {
|
||||
abstract createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog>;
|
||||
|
||||
@@ -68,8 +63,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
|
||||
|
||||
abstract listSuspiciousEmailDomains(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
|
||||
|
||||
abstract addDisposableEmailDomain(domain: string): Promise<void>;
|
||||
@@ -78,8 +71,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract listDisposableEmailDomains(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract listDisposableEmailDomainsPage(limit: number, pageState?: string | null): Promise<DisposableEmailDomainPage>;
|
||||
|
||||
abstract isPhraseBanned(phrase: string): Promise<boolean>;
|
||||
|
||||
abstract banPhrase(phrase: string): Promise<void>;
|
||||
|
||||
@@ -13,11 +13,7 @@ import type {ILogger} from '../ILogger';
|
||||
import {JobLedgerRepository} from '../jobs/JobLedgerRepository';
|
||||
import {startAbuseReplicationSubscriber, stopAbuseReplicationSubscriber} from '../middleware/AbusiveIpAutoBanner';
|
||||
import {ipBanCache} from '../middleware/IpBanMiddleware';
|
||||
import {
|
||||
getRiskCacheManagerInstance,
|
||||
initializeServiceSingletons,
|
||||
shutdownReportService,
|
||||
} from '../middleware/ServiceMiddleware';
|
||||
import {initializeServiceSingletons, shutdownReportService} from '../middleware/ServiceMiddleware';
|
||||
import {
|
||||
ensureVoiceResourcesInitialized,
|
||||
getKVClient,
|
||||
@@ -40,57 +36,6 @@ import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
|
||||
import {WorkerService} from '../worker/WorkerService';
|
||||
|
||||
let jsConnectionManager: JetStreamConnectionManager | null = null;
|
||||
let riskCacheRefreshInterval: NodeJS.Timeout | null = null;
|
||||
let riskCacheRefreshInFlight = false;
|
||||
|
||||
const RISK_CACHE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
async function refreshRiskCache(logger: ILogger, source: 'startup' | 'interval'): Promise<void> {
|
||||
if (riskCacheRefreshInFlight) {
|
||||
return;
|
||||
}
|
||||
riskCacheRefreshInFlight = true;
|
||||
try {
|
||||
const result = await getRiskCacheManagerInstance().refresh();
|
||||
if (result.subtaskErrors.length > 0) {
|
||||
logger.warn({source, errors: result.subtaskErrors}, 'Risk cache refresh completed with errors');
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
source,
|
||||
disposableDomainCount: result.disposableDomainCount,
|
||||
},
|
||||
source === 'startup' ? 'Risk cache initialized on API startup' : 'Risk cache refresh complete on API',
|
||||
);
|
||||
} catch (error) {
|
||||
if (source === 'startup') {
|
||||
logger.warn({error}, 'Risk cache initialisation failed on API startup');
|
||||
return;
|
||||
}
|
||||
logger.warn({error}, 'Periodic risk cache refresh failed on API');
|
||||
} finally {
|
||||
riskCacheRefreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRiskCacheRefreshLoop(logger: ILogger): void {
|
||||
if (riskCacheRefreshInterval) {
|
||||
return;
|
||||
}
|
||||
riskCacheRefreshInterval = setInterval(() => {
|
||||
void refreshRiskCache(logger, 'interval');
|
||||
}, RISK_CACHE_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopRiskCacheRefreshLoop(): void {
|
||||
if (!riskCacheRefreshInterval) {
|
||||
return;
|
||||
}
|
||||
clearInterval(riskCacheRefreshInterval);
|
||||
riskCacheRefreshInterval = null;
|
||||
}
|
||||
|
||||
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
@@ -171,8 +116,6 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
logger.info('Profile substring blocklist cache initialized');
|
||||
await initializeServiceSingletons();
|
||||
logger.info('Service singletons initialized');
|
||||
await refreshRiskCache(logger, 'startup');
|
||||
startRiskCacheRefreshLoop(logger);
|
||||
if (!config.dev.testModeEnabled) {
|
||||
jsConnectionManager = new JetStreamConnectionManager({
|
||||
url: config.nats.jetStreamUrl,
|
||||
@@ -285,12 +228,6 @@ export function createShutdown(logger: ILogger): () => Promise<void> {
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down search service');
|
||||
}
|
||||
try {
|
||||
stopRiskCacheRefreshLoop();
|
||||
logger.info('Risk cache refresh loop shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down risk cache refresh loop');
|
||||
}
|
||||
try {
|
||||
ipBanCache.shutdown();
|
||||
logger.info('IP ban cache shut down');
|
||||
|
||||
@@ -295,10 +295,7 @@ export class MessagePersistenceService {
|
||||
return null;
|
||||
}
|
||||
const uploadUserId = params.attachmentUploadUserId;
|
||||
assert(
|
||||
uploadUserId !== undefined,
|
||||
'Attachment upload actor must be resolved before processing new attachments',
|
||||
);
|
||||
assert(uploadUserId !== undefined, 'Attachment upload actor must be resolved before processing new attachments');
|
||||
return this.attachmentService.computeAttachments({
|
||||
message: {
|
||||
id: params.messageId,
|
||||
@@ -528,7 +525,8 @@ export class MessagePersistenceService {
|
||||
}
|
||||
hasChanges = true;
|
||||
}
|
||||
if (allowEmbeds && (data.embeds !== undefined || (data.content !== undefined && message.embeds.length === 0))) {
|
||||
const embedsExplicitlyProvided = data.embeds !== undefined;
|
||||
if (allowEmbeds && (embedsExplicitlyProvided || data.content !== undefined)) {
|
||||
const attachmentsForResolution = updatedRowData.attachments || [];
|
||||
const resolvedEmbeds = this.embedAttachmentResolver.resolveEmbedAttachmentUrls({
|
||||
embeds: data.embeds,
|
||||
@@ -546,7 +544,15 @@ export class MessagePersistenceService {
|
||||
nsfwMode: isNSFWAllowed ? 'allow' : 'block',
|
||||
isBugHunterBot: params.isBugHunterBot,
|
||||
});
|
||||
updatedRowData.embeds = initialEmbeds;
|
||||
if (embedsExplicitlyProvided) {
|
||||
updatedRowData.embeds = initialEmbeds;
|
||||
} else {
|
||||
const preservedEmbeds = message.embeds
|
||||
.map((embed) => embed.toMessageEmbed())
|
||||
.filter((embed) => embed.type === 'rich');
|
||||
const nextEmbeds = [...preservedEmbeds, ...(initialEmbeds ?? [])];
|
||||
updatedRowData.embeds = nextEmbeds.length > 0 ? nextEmbeds : null;
|
||||
}
|
||||
hasUncachedUrls = embedUrls;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
@@ -178,6 +178,9 @@ export interface APIConfig {
|
||||
ipinfoApiKey?: string;
|
||||
accountPolicyDsl?: unknown;
|
||||
};
|
||||
blocklistFeeds: {
|
||||
enabled: boolean;
|
||||
};
|
||||
captcha: {
|
||||
enabled: boolean;
|
||||
provider: 'hcaptcha' | 'turnstile' | 'none';
|
||||
|
||||
@@ -561,14 +561,14 @@ WHERE NOT $6`,
|
||||
|
||||
private async patch(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<void> {
|
||||
const key = rowKeyFromParams(meta, params);
|
||||
const existing = await this.getRow(meta, key, db);
|
||||
const base = existing ?? paramsRow(params, (meta.pkColumns ?? meta.table.primaryKey) as ReadonlyArray<string>);
|
||||
const stored = await this.getStoredRow(meta, key, db);
|
||||
const base = stored?.row ?? paramsRow(params, (meta.pkColumns ?? meta.table.primaryKey) as ReadonlyArray<string>);
|
||||
const next = {...base};
|
||||
for (const column of meta.patchKeys ?? []) {
|
||||
next[column] = column in params ? params[column] : null;
|
||||
}
|
||||
const ttl = ttlExpiresAt(meta, params);
|
||||
const expiresAt = ttl === undefined ? await this.getExpiresAt(meta, key, db) : ttl;
|
||||
const expiresAt = ttl === undefined ? (stored?.expiresAt ?? null) : ttl;
|
||||
await db.query(
|
||||
`INSERT INTO ${this.table} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
@@ -592,20 +592,20 @@ DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_da
|
||||
]);
|
||||
}
|
||||
|
||||
private async getRow(meta: KvQueryMeta, key: string, db: PostgresQueryable): Promise<Row | null> {
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
private async getStoredRow(
|
||||
meta: KvQueryMeta,
|
||||
key: string,
|
||||
db: PostgresQueryable,
|
||||
): Promise<{row: Row; expiresAt: Date | null} | null> {
|
||||
const result = await db.query<StoredRow & {expires_at: Date | null}>(
|
||||
`SELECT row_key, row_data, expires_at FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
[meta.table.name, key],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? decodeRow(row.row_data) : null;
|
||||
return row ? {row: decodeRow(row.row_data), expiresAt: row.expires_at ?? null} : null;
|
||||
}
|
||||
|
||||
private async getExpiresAt(meta: KvQueryMeta, key: string, db: PostgresQueryable): Promise<Date | null> {
|
||||
const result = await db.query<{expires_at: Date | null}>(
|
||||
`SELECT expires_at FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
[meta.table.name, key],
|
||||
);
|
||||
return result.rows[0]?.expires_at ?? null;
|
||||
private async getRow(meta: KvQueryMeta, key: string, db: PostgresQueryable): Promise<Row | null> {
|
||||
return (await this.getStoredRow(meta, key, db))?.row ?? null;
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,8 @@ type FormatMapping = {
|
||||
|
||||
const FORMAT_MAPPINGS: Record<DesktopFormat, Partial<Record<DesktopPlatform, FormatMapping>>> = {
|
||||
setup: {win32: {ext: '.exe', arch: {x64: 'x64', arm64: 'arm64'}}},
|
||||
dmg: {darwin: {ext: '.dmg', arch: {x64: 'x64', arm64: 'arm64'}}},
|
||||
zip: {darwin: {ext: '.zip', arch: {x64: 'x64', arm64: 'arm64'}}},
|
||||
dmg: {darwin: {ext: '.dmg', arch: {x64: ['universal', 'x64'], arm64: ['universal', 'arm64']}}},
|
||||
zip: {darwin: {ext: '.zip', arch: {x64: ['universal', 'x64'], arm64: ['universal', 'arm64']}}},
|
||||
appimage: {linux: {ext: '.AppImage', arch: {x64: 'x86_64', arm64: ['aarch64', 'arm64']}}},
|
||||
deb: {linux: {ext: '.deb', arch: {x64: 'amd64', arm64: 'arm64'}}},
|
||||
rpm: {linux: {ext: '.rpm', arch: {x64: 'x86_64', arm64: 'aarch64'}}},
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
|
||||
import {resolveClientGeoip} from '@pkgs/geoip/src/ResolveClientGeoip';
|
||||
import type {Hono} from 'hono';
|
||||
import {Config} from '../Config';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
|
||||
export function GeolocationController(app: Hono<HonoEnv>): void {
|
||||
app.get('/ip', RateLimitMiddleware(RateLimitConfigs.IP_GEO_LOOKUP), async (ctx) => {
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
const response = await resolveClientGeoip(ctx.req.raw, {
|
||||
maxmindDbPath: Config.geoip.maxmindDbPath,
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
return ctx.json(response);
|
||||
});
|
||||
app.get(
|
||||
'/ip',
|
||||
RateLimitMiddleware(RateLimitConfigs.IP_GEO_LOOKUP),
|
||||
OpenAPI({
|
||||
operationId: 'get_client_geolocation',
|
||||
summary: 'Get client geolocation',
|
||||
responseSchema: GeolocationResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Geolocation'],
|
||||
description:
|
||||
'Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
const response = await resolveClientGeoip(ctx.req.raw, {
|
||||
maxmindDbPath: Config.geoip.maxmindDbPath,
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.options('/ip', (ctx) => {
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
@@ -21,8 +21,8 @@ const DEFAULT_GIF_SERVICE_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS = 3_000;
|
||||
const GIF_PROVIDER_META: GifProviderMeta = {
|
||||
name: 'klipy',
|
||||
displayName: 'KLIPY',
|
||||
attributionRequired: true,
|
||||
displayName: 'Klipy',
|
||||
attributionRequired: false,
|
||||
};
|
||||
const KLIPY_SHARE_ORIGIN = 'https://klipy.com';
|
||||
const KLIPY_SHARE_HOSTS = new Set(['klipy.com', 'www.klipy.com']);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/Insta
|
||||
import type {Hono} from 'hono';
|
||||
import {Config} from '../Config';
|
||||
import type {GifService} from '../gif/GifService';
|
||||
import type {IGifProvider} from '../gif/IGifProvider';
|
||||
import type {LimitConfigService} from '../limits/LimitConfigService';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
@@ -24,7 +25,18 @@ function buildDiscoveryStaticInput(
|
||||
): DiscoveryStaticInput {
|
||||
const apiClientEndpoint = Config.endpoints.apiClient;
|
||||
const apiPublicEndpoint = Config.endpoints.apiPublic;
|
||||
const gifProvider = gifService?.getProvider();
|
||||
let gifProvider: IGifProvider | undefined;
|
||||
if (gifService !== undefined) {
|
||||
gifProvider = gifService.getProvider();
|
||||
}
|
||||
let gifProviderName = 'klipy';
|
||||
let gifDisplayName = 'Klipy';
|
||||
let gifAttributionRequired = false;
|
||||
if (gifProvider !== undefined) {
|
||||
gifProviderName = gifProvider.meta.name;
|
||||
gifDisplayName = gifProvider.meta.displayName;
|
||||
gifAttributionRequired = gifProvider.meta.attributionRequired;
|
||||
}
|
||||
return {
|
||||
apiCodeVersion: API_CODE_VERSION,
|
||||
endpoints: {
|
||||
@@ -53,9 +65,9 @@ function buildDiscoveryStaticInput(
|
||||
emails_enabled: runtime.emailEnabled,
|
||||
},
|
||||
gif: {
|
||||
provider: gifProvider?.meta.name ?? 'klipy',
|
||||
display_name: gifProvider?.meta.displayName ?? 'KLIPY',
|
||||
attribution_required: gifProvider?.meta.attributionRequired ?? true,
|
||||
provider: gifProviderName,
|
||||
display_name: gifDisplayName,
|
||||
attribution_required: gifAttributionRequired,
|
||||
},
|
||||
push: {
|
||||
public_vapid_key: Config.push.publicVapidKey ?? null,
|
||||
|
||||
@@ -60,8 +60,6 @@ import {CassandraHistoricalOutcomeRepository} from '../risk/HistoricalOutcomeRep
|
||||
import {buildIpInfoCache, buildIpInfoRequestAuditLogger} from '../risk/IpInfoCacheFactory';
|
||||
import {CassandraRegistrationEventsRepository} from '../risk/RegistrationEventsRepository';
|
||||
import {CassandraRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {buildRiskCacheLoaders} from '../risk/RiskCacheLoaders';
|
||||
import {RiskCacheManager} from '../risk/RiskCacheManager';
|
||||
import {createRiskToolbox} from '../risk/RiskToolboxFactory';
|
||||
import {CassandraSuspiciousIpRepository} from '../risk/SuspiciousIpRepository';
|
||||
import {RpcService} from '../rpc/RpcService';
|
||||
@@ -192,24 +190,6 @@ export function shutdownReportService(): void {
|
||||
}
|
||||
}
|
||||
|
||||
let _riskCacheManager: RiskCacheManager | null = null;
|
||||
|
||||
function getRiskCacheManager(): RiskCacheManager {
|
||||
if (!_riskCacheManager) {
|
||||
_riskCacheManager = new RiskCacheManager({
|
||||
logger: Logger,
|
||||
...buildRiskCacheLoaders({
|
||||
adminRepository: getAdminRepository(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return _riskCacheManager;
|
||||
}
|
||||
|
||||
export function getRiskCacheManagerInstance(): RiskCacheManager {
|
||||
return getRiskCacheManager();
|
||||
}
|
||||
|
||||
let _inboundSmsChallengeService: InboundSmsChallengeService | null = null;
|
||||
|
||||
function getInboundSmsChallengeService(): InboundSmsChallengeService {
|
||||
@@ -311,13 +291,12 @@ function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
|
||||
_registrationRiskEvaluator = noopRegistrationRiskEvaluator;
|
||||
return _registrationRiskEvaluator;
|
||||
}
|
||||
const cacheManager = getRiskCacheManager();
|
||||
const ipInfoService = getIpInfoService();
|
||||
const ipInfoChecker = Config.risk.ipinfoApiKey ? createIpInfoChecker({ipInfoService}) : undefined;
|
||||
const cacheService = getCacheService();
|
||||
const reverseDnsLookup = createReverseDnsLookup({cacheService});
|
||||
const toolbox = createRiskToolbox({
|
||||
disposableDomainsRef: cacheManager.disposableDomainsRef,
|
||||
adminRepository: getAdminRepository(),
|
||||
ipInfoChecker,
|
||||
reverseDnsLookup,
|
||||
ipInfoService,
|
||||
|
||||
@@ -10800,6 +10800,64 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/ip": {
|
||||
"get": {
|
||||
"operationId": "get_client_geolocation",
|
||||
"summary": "Get client geolocation",
|
||||
"tags": ["Geolocation"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GeolocationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Get client geolocation"}},
|
||||
"description": "Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable."
|
||||
}
|
||||
},
|
||||
"/oauth2/@me": {
|
||||
"get": {
|
||||
"operationId": "get_current_user_oauth2",
|
||||
@@ -31926,6 +31984,49 @@
|
||||
},
|
||||
"required": ["code", "type", "pack", "temporary"]
|
||||
},
|
||||
"GeolocationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"countryCode": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO 3166-1 alpha-2 country code resolved for the client, or null when it cannot be resolved"
|
||||
},
|
||||
"regionCode": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO 3166-2 subdivision code resolved for the client, or null when it cannot be resolved"
|
||||
},
|
||||
"latitude": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Approximate latitude of the client, or null when it cannot be resolved"
|
||||
},
|
||||
"longitude": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Approximate longitude of the client, or null when it cannot be resolved"
|
||||
},
|
||||
"ageRestrictedGeos": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/GeoEntry"},
|
||||
"description": "Locations where age restricted content requires an age check"
|
||||
},
|
||||
"ageBlockedGeos": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/GeoEntry"},
|
||||
"description": "Locations where age restricted content is unavailable"
|
||||
}
|
||||
},
|
||||
"required": ["countryCode", "regionCode", "latitude", "longitude", "ageRestrictedGeos", "ageBlockedGeos"]
|
||||
},
|
||||
"GeoEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"countryCode": {"type": "string", "description": "ISO 3166-1 alpha-2 country code"},
|
||||
"regionCode": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO 3166-2 subdivision code, or null when the entry covers the whole country"
|
||||
}
|
||||
},
|
||||
"required": ["countryCode", "regionCode"]
|
||||
},
|
||||
"OAuth2MeResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -38163,6 +38264,7 @@
|
||||
{"name": "Connections"},
|
||||
{"name": "Messages"},
|
||||
{"name": "Donations"},
|
||||
{"name": "Geolocation"},
|
||||
{"name": "Discovery"},
|
||||
{"name": "Emojis"},
|
||||
{"name": "Stickers"},
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IAdminRepository} from '../admin/IAdminRepository';
|
||||
import type {RiskCacheLoaders} from './RiskCacheManager';
|
||||
|
||||
interface BuildRiskCacheLoadersDeps {
|
||||
adminRepository: Pick<IAdminRepository, 'listSuspiciousEmailDomains' | 'listDisposableEmailDomains'>;
|
||||
}
|
||||
|
||||
export function buildRiskCacheLoaders(deps: BuildRiskCacheLoadersDeps): RiskCacheLoaders {
|
||||
return {
|
||||
loadDisposableDomains: async () => {
|
||||
const [suspicious, disposable] = await Promise.all([
|
||||
deps.adminRepository.listSuspiciousEmailDomains(),
|
||||
deps.adminRepository.listDisposableEmailDomains(),
|
||||
]);
|
||||
const set = new Set<string>();
|
||||
for (const d of suspicious) set.add(d.toLowerCase());
|
||||
for (const d of disposable) set.add(d.toLowerCase());
|
||||
return set;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
interface MutableRef<T> {
|
||||
current: T;
|
||||
}
|
||||
|
||||
export type ReadonlyRiskCacheRef<T> = {
|
||||
readonly current: T;
|
||||
};
|
||||
|
||||
interface RiskCacheManagerLogger {
|
||||
info(payload: object, msg: string): void;
|
||||
warn(payload: object, msg: string): void;
|
||||
}
|
||||
|
||||
export interface RiskCacheLoaders {
|
||||
loadDisposableDomains: () => Promise<ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
interface RiskCacheManagerOptions extends RiskCacheLoaders {
|
||||
logger?: RiskCacheManagerLogger;
|
||||
}
|
||||
|
||||
interface RiskCacheRefreshResult {
|
||||
disposableDomainCount: number;
|
||||
subtaskErrors: ReadonlyArray<{
|
||||
step: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class RiskCacheManager {
|
||||
private readonly _disposable: MutableRef<ReadonlySet<string>> = {current: new Set()};
|
||||
readonly disposableDomainsRef: ReadonlyRiskCacheRef<ReadonlySet<string>> = this._disposable;
|
||||
private readonly logger: RiskCacheManagerLogger | undefined;
|
||||
private readonly loaders: RiskCacheLoaders;
|
||||
|
||||
constructor(opts: RiskCacheManagerOptions) {
|
||||
this.logger = opts.logger;
|
||||
this.loaders = {
|
||||
loadDisposableDomains: opts.loadDisposableDomains,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(): Promise<RiskCacheRefreshResult> {
|
||||
const subtaskErrors: Array<{
|
||||
step: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
try {
|
||||
const set = await this.loaders.loadDisposableDomains();
|
||||
this._disposable.current = set;
|
||||
this.logger?.info({count: set.size}, 'RiskCacheManager: loaded disposable domains from DB');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger?.warn({step: 'disposable_domains', err: message}, 'RiskCacheManager: subtask failed');
|
||||
subtaskErrors.push({step: 'disposable_domains', error: message});
|
||||
}
|
||||
return {
|
||||
disposableDomainCount: this._disposable.current.size,
|
||||
subtaskErrors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {IAdminRepository} from '../admin/IAdminRepository';
|
||||
import {createDisposableDomainChecker} from './adapters/DisposableDomainChecker';
|
||||
import {createDnsMxChecker, type MxResolver, NodeDnsMxResolver} from './adapters/DnsMxChecker';
|
||||
import {createDomainAgeChecker} from './adapters/DomainAgeChecker';
|
||||
@@ -13,13 +14,12 @@ import {analyzeRegistrationTiming} from './adapters/RegistrationTimingAnalyzer';
|
||||
import {analyzeUserAgent} from './adapters/UserAgentAnalyzer';
|
||||
import {createVelocityAdapter, type IRegistrationEventsRepository} from './adapters/VelocityAdapter';
|
||||
import type {IRiskHistoryRepository} from './HistoricalOutcomeRepository';
|
||||
import type {ReadonlyRiskCacheRef} from './RiskCacheManager';
|
||||
import type {RiskToolbox} from './RiskToolbox';
|
||||
import type {IpInfoAnonymousResult, ReverseDnsResult} from './RiskTypes';
|
||||
import type {ISuspiciousIpRepository} from './SuspiciousIpRepository';
|
||||
|
||||
interface RiskToolboxFactoryOptions {
|
||||
disposableDomainsRef: ReadonlyRiskCacheRef<ReadonlySet<string>>;
|
||||
adminRepository: Pick<IAdminRepository, 'isEmailDomainSuspicious' | 'isEmailDomainDisposable'>;
|
||||
ipInfoChecker?: (ip: string) => Promise<IpInfoAnonymousResult>;
|
||||
reverseDnsLookup?: (ip: string) => Promise<ReverseDnsResult>;
|
||||
ipInfoService: IpInfoService;
|
||||
@@ -32,7 +32,7 @@ interface RiskToolboxFactoryOptions {
|
||||
}
|
||||
|
||||
export function createRiskToolbox(opts: RiskToolboxFactoryOptions): RiskToolbox {
|
||||
const checkDomainDisposable = createDisposableDomainChecker({disposableDomainsRef: opts.disposableDomainsRef});
|
||||
const checkDomainDisposable = createDisposableDomainChecker({adminRepository: opts.adminRepository});
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({ipInfoService: opts.ipInfoService});
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({ipInfoService: opts.ipInfoService});
|
||||
const checkMx = createDnsMxChecker({
|
||||
|
||||
@@ -97,7 +97,6 @@ export interface EmailSyntaxResult {
|
||||
export interface DisposableCheckResult {
|
||||
domain: string;
|
||||
isDisposable: boolean;
|
||||
listSize: number;
|
||||
}
|
||||
|
||||
export interface MxCheckResult {
|
||||
|
||||
@@ -54,7 +54,6 @@ function createToolbox(
|
||||
checkDomainDisposable: async ({domain}) => ({
|
||||
domain,
|
||||
isDisposable: false,
|
||||
listSize: 0,
|
||||
}),
|
||||
checkMx: async ({domain}) => ({
|
||||
domain,
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IAdminRepository} from '../../admin/IAdminRepository';
|
||||
import type {DisposableCheckResult} from '../RiskTypes';
|
||||
|
||||
interface DisposableDomainCheckerContext {
|
||||
disposableDomainsRef: {
|
||||
readonly current: ReadonlySet<string>;
|
||||
};
|
||||
adminRepository: Pick<IAdminRepository, 'isEmailDomainSuspicious' | 'isEmailDomainDisposable'>;
|
||||
}
|
||||
|
||||
export function createDisposableDomainChecker(ctx: DisposableDomainCheckerContext) {
|
||||
return async function checkDomainDisposable(args: {domain: string}): Promise<DisposableCheckResult> {
|
||||
const domain = args.domain.toLowerCase().trim();
|
||||
const set = ctx.disposableDomainsRef.current;
|
||||
const [suspicious, disposable] = await Promise.all([
|
||||
ctx.adminRepository.isEmailDomainSuspicious(domain),
|
||||
ctx.adminRepository.isEmailDomainDisposable(domain),
|
||||
]);
|
||||
return {
|
||||
domain,
|
||||
isDisposable: set.has(domain),
|
||||
listSize: set.size,
|
||||
isDisposable: suspicious || disposable,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface CronDefinition {
|
||||
taskType: WorkerTaskName;
|
||||
payload: WorkerJobPayload;
|
||||
cronExpression: string;
|
||||
ledger: boolean;
|
||||
lastFired: number;
|
||||
}
|
||||
|
||||
@@ -89,12 +90,19 @@ export class CronScheduler {
|
||||
this.kvClient = kvClient;
|
||||
}
|
||||
|
||||
upsert(id: string, taskType: WorkerTaskName, payload: WorkerJobPayload, cronExpression: string): void {
|
||||
upsert(
|
||||
id: string,
|
||||
taskType: WorkerTaskName,
|
||||
payload: WorkerJobPayload,
|
||||
cronExpression: string,
|
||||
options: {ledger: boolean},
|
||||
): void {
|
||||
this.definitions.set(id, {
|
||||
id,
|
||||
taskType,
|
||||
payload,
|
||||
cronExpression,
|
||||
ledger: options.ledger,
|
||||
lastFired: 0,
|
||||
});
|
||||
}
|
||||
@@ -133,7 +141,7 @@ export class CronScheduler {
|
||||
if (!acquired) {
|
||||
continue;
|
||||
}
|
||||
await this.workerService.addJob(def.taskType, def.payload, {jobKey});
|
||||
await this.workerService.addJob(def.taskType, def.payload, {jobKey, skipLedger: !def.ledger});
|
||||
this.logger.debug({cronId: def.id, taskType: def.taskType}, 'Cron job fired');
|
||||
} catch (error) {
|
||||
this.logger.error({err: error, cronId: def.id, taskType: def.taskType}, 'Failed to enqueue cron job');
|
||||
|
||||
@@ -41,21 +41,27 @@ const SEARCH_REQUIRED_TASKS = new Set<string>([
|
||||
]);
|
||||
|
||||
function registerCronJobs(cron: CronScheduler): void {
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *');
|
||||
cron.upsert('processBunnyPurgeQueue', 'processBunnyPurgeQueue', {}, '*/10 * * * * *');
|
||||
cron.upsert('processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions', {}, '0 */10 * * * *');
|
||||
cron.upsert('userProcessPendingDeletions', 'userProcessPendingDeletions', {}, '0 * * * * *');
|
||||
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *');
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *');
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *');
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *');
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *');
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *');
|
||||
cron.upsert('syncDisposableEmailDomains', 'syncDisposableEmailDomains', {}, '0 */30 * * * *');
|
||||
cron.upsert('syncUrlBlocklists', 'syncUrlBlocklists', {}, '0 0 */6 * * *');
|
||||
cron.upsert('syncFileShaBlocklists', 'syncFileShaBlocklists', {}, '0 0 */12 * * *');
|
||||
cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *');
|
||||
Logger.info('Cron jobs registered successfully');
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('processBunnyPurgeQueue', 'processBunnyPurgeQueue', {}, '*/10 * * * * *', {ledger: false});
|
||||
cron.upsert('processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions', {}, '0 */10 * * * *', {
|
||||
ledger: false,
|
||||
});
|
||||
cron.upsert('userProcessPendingDeletions', 'userProcessPendingDeletions', {}, '0 * * * * *', {ledger: false});
|
||||
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *', {
|
||||
ledger: false,
|
||||
});
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false});
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false});
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *', {ledger: false});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
cron.upsert('syncDisposableEmailDomains', 'syncDisposableEmailDomains', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncUrlBlocklists', 'syncUrlBlocklists', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncFileShaBlocklists', 'syncFileShaBlocklists', {}, '0 0 */12 * * *', {ledger: true});
|
||||
}
|
||||
cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *', {ledger: false});
|
||||
Logger.info({blocklistFeeds: Config.blocklistFeeds.enabled}, 'Cron jobs registered successfully');
|
||||
}
|
||||
|
||||
function workerLanesRequireSearch(activeWorkerLanes: ReadonlyArray<WorkerLaneDefinition>): boolean {
|
||||
@@ -191,10 +197,16 @@ export async function startWorkerMain(): Promise<void> {
|
||||
setInjectedWorkerService(workerService);
|
||||
dependencies = await initializeWorkerDependencies(snowflakeService);
|
||||
setWorkerDependencies(dependencies);
|
||||
const didClaimEmailSync = await dependencies.kvClient.setnx('sync:email_domains:initialized', '1');
|
||||
if (didClaimEmailSync) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await workerService.addJob('syncDisposableEmailDomains', {});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
const didClaimEmailSync = await dependencies.kvClient.setnx(
|
||||
'sync:email_domains:initialized',
|
||||
'1',
|
||||
ms('6 hours') / 1000,
|
||||
);
|
||||
if (didClaimEmailSync) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await workerService.addJob('syncDisposableEmailDomains', {});
|
||||
}
|
||||
}
|
||||
cron = new CronScheduler(workerService, Logger, dependencies.kvClient);
|
||||
registerCronJobs(cron);
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {MediaProxyNsfwMode} from '../../infrastructure/IMediaService';
|
||||
import {Logger} from '../../Logger';
|
||||
import type {Channel} from '../../models/Channel';
|
||||
import {Embed} from '../../models/Embed';
|
||||
import {Message} from '../../models/Message';
|
||||
import {deleteMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
|
||||
import * as UnfurlerUtils from '../../utils/UnfurlerUtils';
|
||||
@@ -368,13 +367,15 @@ async function updateMessageEmbeds(
|
||||
orderedEmbeds: Array<MessageEmbed>,
|
||||
): Promise<Message | null> {
|
||||
const existingEmbeds = (freshMessage.embeds ?? []).map((embed) => embed.toMessageEmbed());
|
||||
if (areEmbedsEquivalent(existingEmbeds, orderedEmbeds)) {
|
||||
const preservedEmbeds = existingEmbeds.filter((embed) => embed.type === 'rich');
|
||||
const nextEmbeds = [...preservedEmbeds, ...orderedEmbeds];
|
||||
if (areEmbedsEquivalent(existingEmbeds, nextEmbeds)) {
|
||||
Logger.debug({messageId: freshMessage.id.toString()}, 'Embeds unchanged, skipping update');
|
||||
return freshMessage;
|
||||
}
|
||||
const messageWithEmbeds = new Message({
|
||||
...freshMessage.toRow(),
|
||||
embeds: orderedEmbeds.length > 0 ? orderedEmbeds : null,
|
||||
embeds: nextEmbeds.length > 0 ? nextEmbeds : null,
|
||||
});
|
||||
await channelRepository.updateEmbeds(messageWithEmbeds);
|
||||
return messageWithEmbeds;
|
||||
@@ -382,7 +383,6 @@ async function updateMessageEmbeds(
|
||||
|
||||
interface DispatchEmbedUpdateParams {
|
||||
latestMessage: Message;
|
||||
orderedEmbeds: Array<MessageEmbed>;
|
||||
channel: Channel;
|
||||
guildId: GuildID | null;
|
||||
gatewayService: IGatewayService;
|
||||
@@ -390,15 +390,13 @@ interface DispatchEmbedUpdateParams {
|
||||
|
||||
async function dispatchEmbedUpdate({
|
||||
latestMessage,
|
||||
orderedEmbeds,
|
||||
channel,
|
||||
guildId,
|
||||
gatewayService,
|
||||
}: DispatchEmbedUpdateParams): Promise<void> {
|
||||
const embedObjects = orderedEmbeds.length > 0 ? orderedEmbeds.map((e) => new Embed(e)) : latestMessage.embeds;
|
||||
const messageWithUpdatedEmbeds = new Message({
|
||||
...latestMessage.toRow(),
|
||||
embeds: embedObjects.map((e) => e.toMessageEmbed()),
|
||||
embeds: latestMessage.embeds.map((e) => e.toMessageEmbed()),
|
||||
});
|
||||
const messageData = await buildBroadcastMessageData({
|
||||
channel,
|
||||
@@ -512,7 +510,6 @@ const extractEmbeds: WorkerTaskHandler = async (payload, helpers) => {
|
||||
if (!(latestMessage.flags & MessageFlags.SUPPRESS_EMBEDS)) {
|
||||
await dispatchEmbedUpdate({
|
||||
latestMessage,
|
||||
orderedEmbeds,
|
||||
channel,
|
||||
guildId,
|
||||
gatewayService,
|
||||
|
||||
@@ -21,7 +21,6 @@ const SOURCES = [
|
||||
'https://raw.githubusercontent.com/vrittech/disposable-email/main/disposable_domains.txt',
|
||||
'https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blocklist.conf',
|
||||
];
|
||||
const CURRENT_DOMAIN_PAGE_SIZE = 10000;
|
||||
const WRITE_PROGRESS_INTERVAL = 500;
|
||||
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
@@ -120,16 +119,7 @@ function normaliseDomain(raw: string): string | null {
|
||||
|
||||
async function loadCurrentDisposableEmailDomains(): Promise<Set<string>> {
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
const currentSet = new Set<string>();
|
||||
let pageState: string | null = null;
|
||||
do {
|
||||
const page = await adminRepository.listDisposableEmailDomainsPage(CURRENT_DOMAIN_PAGE_SIZE, pageState);
|
||||
for (const domain of page.domains) {
|
||||
currentSet.add(domain);
|
||||
}
|
||||
pageState = page.pageState;
|
||||
} while (pageState !== null);
|
||||
return currentSet;
|
||||
return new Set(await adminRepository.listDisposableEmailDomains());
|
||||
}
|
||||
|
||||
async function throwIfCancelled(helpers: WorkerTaskHelpers): Promise<void> {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"dev": "TOKIO_WORKER_THREADS=4 RAYON_NUM_THREADS=4 cargo run --manifest-path ../tools/ci/Cargo.toml -- app-dev-server",
|
||||
"test": "vitest run",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts src/features/member/state/MemberListStateMachines.bench.ts src/features/member/state/MemberSidebar.bench.ts",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts src/features/member/state/MemberSidebar.bench.ts",
|
||||
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts",
|
||||
"bench:messages": "vitest bench --run src/features/messaging/utils/MessageGroupingUtils.bench.ts src/features/channel/components/ChannelMessageStreamUtils.bench.ts src/features/messaging/components/markdown/MarkdownRendering.bench.ts src/features/channel/components/MessageAttachmentUtils.bench.ts src/features/messaging/utils/MessagePaginationUtils.bench.ts src/features/platform/utils/ScrollPosition.bench.ts src/features/messaging/state/ReactionStateMachine.bench.ts",
|
||||
"test:watch": "vitest",
|
||||
@@ -184,6 +184,9 @@
|
||||
"@fluxer/snowflake": "workspace:*",
|
||||
"@fluxer/voice_engine_v2": "workspace:*",
|
||||
"@hcaptcha/react-hcaptcha": "catalog:",
|
||||
"@lexical/history": "catalog:",
|
||||
"@lexical/react": "catalog:",
|
||||
"@lexical/utils": "catalog:",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lingui/core": "catalog:",
|
||||
"@lingui/react": "catalog:",
|
||||
@@ -192,7 +195,6 @@
|
||||
"@phosphor-icons/react": "catalog:",
|
||||
"@pkgs/list_utils": "workspace:*",
|
||||
"@pkgs/number_utils": "workspace:*",
|
||||
"@protontech/tidy-url": "catalog:",
|
||||
"@radix-ui/react-checkbox": "catalog:",
|
||||
"@radix-ui/react-radio-group": "catalog:",
|
||||
"@radix-ui/react-switch": "catalog:",
|
||||
@@ -214,6 +216,7 @@
|
||||
"idna-uts46-hx": "catalog:",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"katex": "catalog:",
|
||||
"lexical": "catalog:",
|
||||
"libphonenumber-js": "catalog:",
|
||||
"livekit-client": "workspace:*",
|
||||
"lodash": "catalog:",
|
||||
@@ -238,6 +241,7 @@
|
||||
"react-hotkeys-hook": "catalog:",
|
||||
"rxjs": "catalog:",
|
||||
"thumbhash": "catalog:",
|
||||
"tiny-invariant": "catalog:",
|
||||
"uint8array-extras": "catalog:",
|
||||
"unique-names-generator": "catalog:",
|
||||
"upng-js": "^2.1.0",
|
||||
|
||||
@@ -461,7 +461,7 @@ export default () => {
|
||||
priority: 55,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
chunks: 'async',
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
livekit: {
|
||||
test: /[\\/]node_modules[\\/](livekit-client|@livekit)[\\/]/,
|
||||
|
||||
@@ -20,7 +20,7 @@ const LARGE_STATUS_CUTOUT_GUTTER_RATIO = 0.2;
|
||||
|
||||
const STATUS_CONFIG: Record<number, StatusConfig> = {
|
||||
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
|
||||
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
|
||||
20: {statusSize: 10, cutoutRadius: 6, cutoutCenter: 17},
|
||||
24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20},
|
||||
32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27},
|
||||
36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30},
|
||||
@@ -181,16 +181,19 @@ function generateAvatarMaskStatusTyping(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingHeight = status.size;
|
||||
const typingRx = status.outerRadius;
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
const leftCx = status.cx - typingExtension + typingBridgeShift;
|
||||
const rightCx = status.cx + typingBridgeShift;
|
||||
const y = status.cy - typingRx;
|
||||
const bridgeHeight = typingRx * 2;
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<rect fill="black" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
<circle fill="black" cx="${rightCx}" cy="${status.cy}" r="${typingRx}" />
|
||||
<rect fill="black" x="${leftCx}" y="${y}" width="${typingExtension}" height="${bridgeHeight}" />
|
||||
<circle fill="black" cx="${leftCx}" cy="${status.cy}" r="${typingRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
@@ -285,7 +288,7 @@ function generateStatusTyping(size: number): string {
|
||||
const rx = status.outerRadius;
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const x = status.cx - status.size / 2 - typingExtension + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
|
||||
}
|
||||
@@ -404,12 +407,17 @@ for (const size of SIZES) {
|
||||
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
|
||||
const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingExtensionPx = Math.max(0, typingWidthPx - status.size);
|
||||
const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingWidth = typingWidthPx / size;
|
||||
const typingHeight = status.size / size;
|
||||
const typingX = cx - typingWidth / 2 + typingBridgeShift;
|
||||
const typingX =
|
||||
(status.cx - status.size / 2 - typingExtensionPx + typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingY = cy - typingHeight / 2;
|
||||
const typingRx = status.outerRadius / size;
|
||||
const typingCutoutLeftCx =
|
||||
(status.cx - typingExtensionPx + typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingCutoutRightCx = (status.cx + typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingCutoutY = (status.cy - status.outerRadius) / size;
|
||||
const typingCutoutHeight = (status.outerRadius * 2) / size;
|
||||
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
|
||||
@@ -449,7 +457,9 @@ for (const size of SIZES) {
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
|
||||
<circle fill="black" cx="${formatNumber(typingCutoutRightCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
|
||||
<rect fill="black" x="${formatNumber(typingCutoutLeftCx)}" y="${formatNumber(typingCutoutY)}" width="${formatNumber(typingExtensionPx / size)}" height="${formatNumber(typingCutoutHeight)}" />
|
||||
<circle fill="black" cx="${formatNumber(typingCutoutLeftCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
|
||||
@@ -327,7 +327,7 @@ const CONFIG: Config = {
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
@@ -344,7 +344,7 @@ const CONFIG: Config = {
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
@@ -410,7 +410,7 @@ const CONFIG: Config = {
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
@@ -438,8 +438,8 @@ const CONFIG: Config = {
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(258, calc(10% * var(--saturation-factor)), 2%) 20%
|
||||
var(--background-secondary-alt) 90%,
|
||||
hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
@@ -744,8 +744,8 @@ hsl(258, calc(10% * var(--saturation-factor)), 0%) 10%
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
var(--background-secondary-alt) 90%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 10%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'legacyDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
|
||||
@@ -238,21 +238,63 @@ function readSourceVariables(appDir: string): {
|
||||
return {darkDefaults, lightDefaults, sources};
|
||||
}
|
||||
|
||||
function findMatchingParen(text: string, openIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openIndex; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (character === '(') depth += 1;
|
||||
if (character === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function splitVarArguments(inner: string): {dependency: string; fallback: string | null} {
|
||||
let depth = 0;
|
||||
for (let index = 0; index < inner.length; index += 1) {
|
||||
const character = inner[index];
|
||||
if (character === '(') depth += 1;
|
||||
if (character === ')') depth -= 1;
|
||||
if (character === ',' && depth === 0) {
|
||||
return {dependency: inner.slice(0, index).trim(), fallback: inner.slice(index + 1).trim()};
|
||||
}
|
||||
}
|
||||
return {dependency: inner.trim(), fallback: null};
|
||||
}
|
||||
|
||||
function resolveVariableValue(name: string, values: ReadonlyMap<string, string>, stack = new Set<string>()): string {
|
||||
const value = values.get(name);
|
||||
if (!value) return '';
|
||||
return value.replace(
|
||||
/var\(\s*(--[a-zA-Z0-9_-]+)(?:\s*,\s*([^)]+))?\)/g,
|
||||
(full, dependency: string, fallback?: string) => {
|
||||
if (dependency === '--saturation-factor') return full;
|
||||
if (stack.has(dependency)) return fallback?.trim() ?? full;
|
||||
const dependencyValue = values.get(dependency);
|
||||
if (!dependencyValue) return fallback?.trim() ?? full;
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf('var(', cursor);
|
||||
if (start === -1) {
|
||||
result += value.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
const close = findMatchingParen(value, start + 3);
|
||||
if (close === -1) {
|
||||
result += value.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
result += value.slice(cursor, start);
|
||||
const full = value.slice(start, close + 1);
|
||||
const {dependency, fallback} = splitVarArguments(value.slice(start + 4, close));
|
||||
if (dependency === '--saturation-factor') {
|
||||
result += full;
|
||||
} else if (stack.has(dependency) || !values.get(dependency)) {
|
||||
result += fallback ?? full;
|
||||
} else {
|
||||
const nextStack = new Set(stack);
|
||||
nextStack.add(name);
|
||||
return resolveVariableValue(dependency, values, nextStack);
|
||||
},
|
||||
);
|
||||
result += resolveVariableValue(dependency, values, nextStack);
|
||||
}
|
||||
cursor = close + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getGroupId(name: string): string {
|
||||
|
||||
+13
-10
@@ -9,7 +9,6 @@ import ScreenReader from '@app/features/accessibility/state/ScreenReader';
|
||||
import {DndContext} from '@app/features/app/components/layout/DndContext';
|
||||
import GlobalOverlays from '@app/features/app/components/layout/GlobalOverlays';
|
||||
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
|
||||
import {NativeTrafficLightsBackdrop} from '@app/features/app/components/layout/NativeTrafficLightsBackdrop';
|
||||
import {useDesktopAllowTransparency} from '@app/features/app/hooks/useDesktopAllowTransparency';
|
||||
import {useDesktopElectronBridges} from '@app/features/app/hooks/useDesktopElectronBridges';
|
||||
import {useDocumentClassToggle} from '@app/features/app/hooks/useDocumentClassToggle';
|
||||
@@ -27,6 +26,7 @@ import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ensureAutostartDefaultEnabled} from '@app/features/platform/utils/Autostart';
|
||||
@@ -62,7 +62,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
|
||||
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
|
||||
import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen';
|
||||
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
|
||||
@@ -72,6 +71,7 @@ import {msg} from '@lingui/core/macro';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {IconContext} from '@phosphor-icons/react';
|
||||
import {reaction} from 'mobx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
@@ -89,10 +89,9 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
|
||||
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
|
||||
const isVoiceCallFullscreenActive = VoiceCallFullscreen.isActive;
|
||||
const isRootDocumentFullscreen = useIsRootDocumentFullscreen();
|
||||
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('app');
|
||||
const layoutVariantContextValue = useMemo(
|
||||
@@ -138,6 +137,15 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
}, []);
|
||||
useEffect(
|
||||
() =>
|
||||
reaction(
|
||||
() => GatewayConnection.sessionId,
|
||||
(sessionId) => MemberSidebar.synchronizeGatewaySession(sessionId),
|
||||
{fireImmediately: true},
|
||||
),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const clearForeignPortalHost = (): void => {
|
||||
const activePortalHost = getActivePortalHost();
|
||||
@@ -202,12 +210,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
>
|
||||
{i18n._(SKIP_TO_CONTENT_DESCRIPTOR)}
|
||||
</a>
|
||||
<NativeTrafficLightsBackdrop
|
||||
variant={layoutVariant}
|
||||
hidden={isVoiceCallFullscreenActive}
|
||||
data-flx="app.app.app-wrapper.native-traffic-lights-backdrop"
|
||||
/>
|
||||
{isNative && !isMacOS && !useSystemTitleBar && !isRootDocumentFullscreen && (
|
||||
{isNative && !useSystemTitleBar && !isRootDocumentFullscreen && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.app.app-wrapper.native-titlebar" />
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -86,6 +86,7 @@ var {
|
||||
--z-index-toast: 50000;
|
||||
--z-index-titlebar: 100000;
|
||||
--native-titlebar-height: 2rem;
|
||||
--macos-traffic-light-inset: 78px;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
@@ -97,13 +98,37 @@ var {
|
||||
--media-border-radius: 0.25rem;
|
||||
|
||||
--input-container-padding: 0.625rem;
|
||||
--footer-row-height: 4.5rem;
|
||||
--input-container-min-height: var(--footer-row-height);
|
||||
--list-row-min-height: 4.5rem;
|
||||
--input-wrapper-padding-x: 0.5rem;
|
||||
--input-wrapper-padding-bottom: 0.5rem;
|
||||
--textarea-top-bar-height: 2.5rem;
|
||||
--textarea-line-height: 1.375rem;
|
||||
--textarea-content-offset: calc((var(--user-area-content-height) - var(--textarea-line-height)) / 2);
|
||||
|
||||
--footer-box-height: 3.625rem;
|
||||
--footer-box-inset: 0.375rem;
|
||||
--footer-box-inset-inline: var(--footer-box-inset);
|
||||
--footer-box-radius: var(--radius-lg);
|
||||
--footer-box-inner-inset: 0.5rem;
|
||||
--outline-frame-border-width: 0.0625rem;
|
||||
--composer-mobile-box-height: 3rem;
|
||||
--composer-action-gap: 0.25rem;
|
||||
--floating-surface-ring-color: color-mix(in srgb, var(--background-modifier-accent) 20%, transparent);
|
||||
--floating-surface-ring-color-strong: color-mix(in srgb, var(--background-modifier-accent) 45%, transparent);
|
||||
|
||||
--footer-box-padding-y: max(0rem, calc((var(--footer-box-height) - var(--textarea-button-height, 2rem)) / 2));
|
||||
--composer-mobile-padding-y: max(
|
||||
0rem,
|
||||
calc((var(--composer-mobile-box-height) - var(--textarea-button-height, 2rem)) / 2)
|
||||
);
|
||||
--footer-row-height: calc(var(--footer-box-height) + var(--footer-box-inset) * 2);
|
||||
--input-container-min-height: var(--footer-row-height);
|
||||
--textarea-min-height: var(--footer-box-height);
|
||||
--textarea-padding-y: var(--footer-box-padding-y);
|
||||
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
|
||||
--composer-box-inset-inline: min(var(--footer-box-inset), var(--chat-horizontal-padding, var(--spacing-4)));
|
||||
--composer-box-padding-inline: max(
|
||||
0rem,
|
||||
calc(var(--chat-horizontal-padding, var(--spacing-4)) - var(--composer-box-inset-inline))
|
||||
);
|
||||
|
||||
--typing-indicator-height: 1rem;
|
||||
--typing-pill-height: 1rem;
|
||||
@@ -112,12 +137,11 @@ var {
|
||||
--typing-avatar-size: 0.75rem;
|
||||
--typing-indicator-animation-size: 1rem;
|
||||
--typing-indicator-gap: 0px;
|
||||
--typing-upload-column-width: calc(
|
||||
var(--user-area-content-height) +
|
||||
(var(--textarea-side-button-padding, 0.34375rem) * 2)
|
||||
);
|
||||
|
||||
--spoiler-border-radius: 0.375rem;
|
||||
--markup-restricted-inline-icon-baseline-shift: -0.125em;
|
||||
--markup-restricted-inline-emoji-size: 1.25em;
|
||||
--markup-restricted-inline-emoji-baseline-shift: -0.18em;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--emoji-size-emoji: 1.5em;
|
||||
@@ -139,16 +163,21 @@ var {
|
||||
--spacing-24: 6rem;
|
||||
|
||||
--layout-guild-list-width: 4.5rem;
|
||||
--layout-sidebar-width: 16.875rem;
|
||||
--layout-sidebar-width: 20rem;
|
||||
--layout-header-height: 3.5rem;
|
||||
--layout-user-area-height: var(--input-container-min-height);
|
||||
--layout-user-area-height: var(--footer-box-height);
|
||||
--layout-user-area-reserved-height: 0px;
|
||||
--layout-mobile-bottom-nav-reserved-height: 0px;
|
||||
--user-area-content-height: 2.25rem;
|
||||
--user-area-padding-y: calc((var(--layout-user-area-height) - var(--user-area-content-height)) / 2);
|
||||
--user-area-padding-x: var(--spacing-4);
|
||||
--user-area-box-inset-block-end: calc(var(--footer-box-inset) + var(--outline-frame-border-width));
|
||||
--user-area-content-height: var(--textarea-button-height, 2rem);
|
||||
--user-area-padding-y: var(--footer-box-padding-y);
|
||||
--user-area-padding-x: var(--footer-box-inner-inset);
|
||||
--user-area-avatar-lead: max(
|
||||
var(--footer-box-inner-inset),
|
||||
calc(var(--layout-guild-list-width) / 2 - var(--footer-box-inset-inline) - var(--user-area-content-height) / 2)
|
||||
);
|
||||
--voice-connection-padding-y: var(--spacing-2);
|
||||
--footer-row-padding-y: var(--user-area-padding-y);
|
||||
--voice-connection-padding-x: var(--footer-box-inner-inset);
|
||||
--layout-header-popout-width: calc(var(--layout-sidebar-width) - (var(--spacing-4) * 2));
|
||||
|
||||
--layout-gap: var(--spacing-4);
|
||||
@@ -224,7 +253,7 @@ html.allow-transparency body {
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
html.platform-native:not(.platform-macos):not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
html.platform-native:not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -1319,8 +1319,7 @@ class Accessibility {
|
||||
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
|
||||
if (validated.scrollToBottomOnMessageSend !== undefined)
|
||||
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
|
||||
if (validated.sequentialFileSend !== undefined)
|
||||
this.sequentialFileSend = validated.sequentialFileSend;
|
||||
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
|
||||
if (validated.showNeko !== undefined && validated.showNeko !== this.showNeko) {
|
||||
this.showNeko = validated.showNeko;
|
||||
persistLocalShowNeko(validated.showNeko);
|
||||
@@ -1463,14 +1462,7 @@ class Accessibility {
|
||||
|
||||
async applyZoom(level: number): Promise<void> {
|
||||
const zoomLevel = clampZoomLevel(level);
|
||||
const electronApi = (
|
||||
window as {
|
||||
electron?: {
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
};
|
||||
}
|
||||
).electron;
|
||||
applyAppZoomToDocument(zoomLevel * 100, electronApi);
|
||||
applyAppZoomToDocument(zoomLevel * 100, window.electron);
|
||||
}
|
||||
|
||||
async applyStoredZoom(): Promise<void> {
|
||||
|
||||
@@ -52,7 +52,7 @@ async function cleanupRuntimeStateOnCrash(): Promise<void> {
|
||||
|
||||
export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
@@ -111,7 +111,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
}, [stackTraceText]);
|
||||
return (
|
||||
<div className={errorFallbackStyles.errorFallbackContainer} data-flx="app.error-fallback.div">
|
||||
{isNative && !isMacOS && !useSystemTitleBar && (
|
||||
{isNative && !useSystemTitleBar && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.error-fallback.native-titlebar" />
|
||||
)}
|
||||
<FluxerIcon className={errorFallbackStyles.errorFallbackIcon} data-flx="app.error-fallback.fluxer-icon" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createRangesForSection,
|
||||
setHighlightRanges,
|
||||
} from '@app/features/messaging/utils/CSSHighlightSearch';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {AccessibilityInlineContent} from '@app/features/user/components/modals/tabs/accessibility_tab/AccessibilityTabInline';
|
||||
import {AccountSecurityInlineTab} from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityTabInline';
|
||||
import {AppearanceInlineContent} from '@app/features/user/components/modals/tabs/appearance_tab/AppearanceTabInline';
|
||||
@@ -149,7 +150,7 @@ const SettingsSection: React.FC<SettingsSectionProps> = observer(
|
||||
/>
|
||||
</span>
|
||||
<CaretRightIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={clsx(styles.expandIcon, isExpanded && styles.expandIconExpanded)}
|
||||
data-flx="app.all-settings-renderer.settings-section.expand-icon"
|
||||
|
||||
+8
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {SettingsModalHeader} from '@app/features/app/components/dialogs/components/SettingsModalHeader';
|
||||
import {resolveSettingsTitle} from '@app/features/app/components/dialogs/shared/SettingsContentPresentation';
|
||||
import {
|
||||
SettingsModalDesktopContent,
|
||||
SettingsModalDesktopScroll,
|
||||
@@ -117,6 +118,11 @@ export const DesktopChannelSettingsView: React.FC<DesktopChannelSettingsViewProp
|
||||
const useOverride = SettingsSidebar.useOverride;
|
||||
const activeTabPanelId = selectedTab ? `channel-settings-tabpanel-${selectedTab}` : undefined;
|
||||
const activeTabId = selectedTab ? `channel-settings-tab-${selectedTab}` : undefined;
|
||||
const fallbackSettingsTitle = i18n._(
|
||||
isCategory ? CATEGORY_SETTINGS_LABEL_DESCRIPTOR : CHANNEL_SETTINGS_LABEL_DESCRIPTOR,
|
||||
);
|
||||
const currentTabLabel = currentTab ? currentTab.label : null;
|
||||
const settingsTitle = resolveSettingsTitle(currentTabLabel, fallbackSettingsTitle);
|
||||
const scrollKey = useMemo(
|
||||
() => `channel-settings-${channel.id}-${selectedTab ?? 'none'}`,
|
||||
[channel.id, selectedTab],
|
||||
@@ -263,10 +269,8 @@ export const DesktopChannelSettingsView: React.FC<DesktopChannelSettingsViewProp
|
||||
data-flx="app.desktop-channel-settings-view.settings-modal-desktop-content"
|
||||
>
|
||||
<SettingsModalHeader
|
||||
title={
|
||||
currentTab?.label ||
|
||||
i18n._(isCategory ? CATEGORY_SETTINGS_LABEL_DESCRIPTOR : CHANNEL_SETTINGS_LABEL_DESCRIPTOR)
|
||||
}
|
||||
title={settingsTitle}
|
||||
pageLinkHref={null}
|
||||
showUnsavedBanner={showUnsavedBanner}
|
||||
flashBanner={flashBanner}
|
||||
tabData={tabData}
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ import {isStockCommunityGuild} from '@app/features/guild/utils/GuildCommunityUti
|
||||
import {openMessageHistoryThresholdSettings} from '@app/features/guild/utils/guild_tabs/GuildOverviewTabUtils';
|
||||
import {BACK_TO_SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -246,7 +247,7 @@ export const DesktopGuildSettingsView: React.FC<DesktopGuildSettingsViewProps> =
|
||||
{i18n._(MEMBERS_PAGE_ACTION_DESCRIPTOR)}
|
||||
</span>
|
||||
<ArrowSquareOutIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={styles.externalTabIcon}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SIGN_OUT_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -269,7 +270,7 @@ const BreadcrumbTitle: React.FC<BreadcrumbTitleProps> = ({parentLabel, currentLa
|
||||
{parentLabel}
|
||||
</button>
|
||||
<CaretRightIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={styles.breadcrumbChevron}
|
||||
aria-hidden="true"
|
||||
|
||||
+10
-16
@@ -7,6 +7,8 @@ import {
|
||||
MobileSettingsDangerItem,
|
||||
MobileSettingsList,
|
||||
} from '@app/features/app/components/dialogs/shared/MobileSettingsComponents';
|
||||
import {resolveSettingsTitle} from '@app/features/app/components/dialogs/shared/SettingsContentPresentation';
|
||||
import {usePreservedScrollerPosition} from '@app/features/app/components/dialogs/shared/UsePreservedScrollerPosition';
|
||||
import {ChannelDeleteModal} from '@app/features/channel/components/modals/ChannelDeleteModal';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {DELETE_CATEGORY_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors';
|
||||
@@ -15,7 +17,7 @@ import channelStyles from '@app/features/guild/components/modals/GuildSettingsMo
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import styles from '@app/features/user/components/modals/UserSettingsModal.module.css';
|
||||
import type {
|
||||
ChannelSettingsTab,
|
||||
@@ -30,7 +32,7 @@ import {TrashIcon} from '@phosphor-icons/react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {type UIEvent, useCallback, useEffect, useRef} from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const DELETE_CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Delete channel',
|
||||
@@ -102,19 +104,10 @@ export const MobileChannelSettingsView: React.FC<MobileChannelSettingsViewProps>
|
||||
});
|
||||
const showMobileList = mobileNav.isRootView;
|
||||
const showMobileContent = !mobileNav.isRootView;
|
||||
const listScrollPositionRef = useRef(0);
|
||||
const listScrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const handleListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
listScrollPositionRef.current = event.currentTarget.scrollTop;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!showMobileList) return;
|
||||
const scroller = listScrollerRef.current;
|
||||
if (!scroller) return;
|
||||
const target = listScrollPositionRef.current;
|
||||
if (target === 0) return;
|
||||
scroller.scrollTo({to: target, animate: false});
|
||||
}, [showMobileList]);
|
||||
const {scrollerRef: listScrollerRef, handleScroll: handleListScroll} = usePreservedScrollerPosition(showMobileList);
|
||||
const currentTabLabel = currentTab ? currentTab.label : null;
|
||||
const currentViewTitle = mobileNav.currentView ? mobileNav.currentView.title : null;
|
||||
const mobileTitle = resolveSettingsTitle(currentTabLabel, currentViewTitle);
|
||||
const dangerAction = canManageChannel ? (
|
||||
<MobileSettingsDangerItem
|
||||
icon={TrashIcon}
|
||||
@@ -177,7 +170,8 @@ export const MobileChannelSettingsView: React.FC<MobileChannelSettingsViewProps>
|
||||
data-flx="app.mobile-channel-settings-view.mobile-header-content--2"
|
||||
>
|
||||
<MobileHeaderWithBanner
|
||||
title={currentTab.label || mobileNav.currentView?.title}
|
||||
pageLinkHref={null}
|
||||
title={mobileTitle}
|
||||
onBack={handleBack}
|
||||
showUnsavedBanner={showUnsavedBanner}
|
||||
flashBanner={flashBanner}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SEARCH_SETTINGS_FIELD_LABEL_DESCRIPTOR,
|
||||
SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -91,7 +92,7 @@ export const SettingsSearch: React.FC<SettingsSearchProps> = observer(
|
||||
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
|
||||
data-flx="app.settings-search.clear-button"
|
||||
>
|
||||
<XIcon size={14} weight="bold" data-flx="app.settings-search.x-icon" />
|
||||
<XIcon size={remFromPx(14)} weight="bold" data-flx="app.settings-search.x-icon" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
) : undefined;
|
||||
@@ -110,7 +111,11 @@ export const SettingsSearch: React.FC<SettingsSearchProps> = observer(
|
||||
placeholder={placeholder ?? i18n._(SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR)}
|
||||
aria-label={i18n._(SEARCH_SETTINGS_FIELD_LABEL_DESCRIPTOR)}
|
||||
leftIcon={
|
||||
<MagnifyingGlassIcon size={16} weight="bold" data-flx="app.settings-search.magnifying-glass-icon" />
|
||||
<MagnifyingGlassIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.settings-search.magnifying-glass-icon"
|
||||
/>
|
||||
}
|
||||
rightElement={rightElement}
|
||||
data-flx="app.settings-search.input.query-change.text"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {formatSlowmodeTime} from '@app/features/channel/components/SlowmodeIndicator';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {isForwardableChannelType} from './ForwardChannelEligibility';
|
||||
|
||||
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
|
||||
message: 'Sending messages is disabled in this community',
|
||||
comment: 'Short label in the settings dialog forward channel selection.',
|
||||
});
|
||||
const MEMBER_TIMED_OUT_DESCRIPTOR = msg({
|
||||
message: "You're on timeout in this community",
|
||||
comment: 'Short label in the settings dialog forward channel selection. Keep the tone plain and specific.',
|
||||
});
|
||||
const SEND_MESSAGES_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{sendMessagesPermissionLabel}" permission to send messages in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the user lacks the Send Messages permission in the target channel. Permission name is interpolated.',
|
||||
});
|
||||
const EMBED_LINKS_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{embedLinksPermissionLabel}" permission to embed links in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message contains embeds and the user lacks Embed Links in the target channel.',
|
||||
});
|
||||
const ATTACH_FILES_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{attachFilesPermissionLabel}" permission to attach files in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message has attachments and the user lacks Attach Files in the target channel.',
|
||||
});
|
||||
const SLOWMODE_WAIT_DESCRIPTOR = msg({
|
||||
message: 'Slowmode · wait {remaining}',
|
||||
comment:
|
||||
'Short label in the settings dialog forward channel selection. Preserve {remaining}; it is inserted by code.',
|
||||
});
|
||||
|
||||
const FORWARD_CHANNEL_RESULT_LIMIT = 100;
|
||||
|
||||
export interface ForwardMessageMediaSelection {
|
||||
readonly hasAttachments: boolean;
|
||||
readonly hasEmbeds: boolean;
|
||||
}
|
||||
|
||||
interface ResolveForwardMessageMediaSelectionArgs {
|
||||
readonly message: Message;
|
||||
readonly override: ForwardMessageMediaSelection | undefined;
|
||||
}
|
||||
|
||||
function hasSnapshotAttachments(message: Message): boolean {
|
||||
if (message.attachments.length > 0) return true;
|
||||
const snapshots = message.messageSnapshots;
|
||||
if (snapshots === undefined) return false;
|
||||
return snapshots.some((snapshot) => {
|
||||
const attachments = snapshot.attachments;
|
||||
return attachments != null && attachments.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hasSnapshotEmbeds(message: Message): boolean {
|
||||
if (message.embeds.length > 0) return true;
|
||||
const snapshots = message.messageSnapshots;
|
||||
if (snapshots === undefined) return false;
|
||||
return snapshots.some((snapshot) => {
|
||||
const embeds = snapshot.embeds;
|
||||
return embeds != null && embeds.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveForwardMessageMediaSelection({
|
||||
message,
|
||||
override,
|
||||
}: ResolveForwardMessageMediaSelectionArgs): ForwardMessageMediaSelection {
|
||||
if (override !== undefined) return override;
|
||||
return Object.freeze({
|
||||
hasAttachments: hasSnapshotAttachments(message),
|
||||
hasEmbeds: hasSnapshotEmbeds(message),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ForwardChannelObservation {
|
||||
readonly canAttachFiles: boolean;
|
||||
readonly canEmbedLinks: boolean;
|
||||
readonly canSendMessages: boolean;
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly displayName: string;
|
||||
readonly guildMessagesDisabled: boolean;
|
||||
readonly guildName: string | null;
|
||||
readonly memberTimedOut: boolean;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
readonly slowmodeRemainingMs: number;
|
||||
}
|
||||
|
||||
export interface ForwardChannelOption {
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly disableReason: string | null;
|
||||
readonly displayName: string;
|
||||
readonly guildName: string | null;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
readonly slowmodeRemainingMs: number;
|
||||
}
|
||||
|
||||
interface IndexedForwardChannelOption extends ForwardChannelOption {
|
||||
readonly channelNameSearchValue: string;
|
||||
readonly displayNameSearchValue: string;
|
||||
readonly guildNameSearchValue: string;
|
||||
readonly isPersonalNotes: boolean;
|
||||
readonly isSource: boolean;
|
||||
readonly recentRank: number | null;
|
||||
}
|
||||
|
||||
interface BuildForwardChannelIndexRequest {
|
||||
readonly excludedChannelId: string;
|
||||
readonly i18n: I18n;
|
||||
readonly mediaSelection: ForwardMessageMediaSelection;
|
||||
readonly observations: ReadonlyArray<ForwardChannelObservation>;
|
||||
readonly recentChannelIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
interface ForwardChannelSelectionDisabledRequest {
|
||||
readonly maxSelections: number;
|
||||
readonly option: ForwardChannelOption;
|
||||
readonly selectedChannelIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export class ForwardChannelIndex {
|
||||
private readonly i18n: I18n;
|
||||
private readonly options: ReadonlyArray<IndexedForwardChannelOption>;
|
||||
private readonly optionsByChannelId: ReadonlyMap<string, IndexedForwardChannelOption>;
|
||||
|
||||
constructor({
|
||||
excludedChannelId,
|
||||
i18n,
|
||||
mediaSelection,
|
||||
observations,
|
||||
recentChannelIds,
|
||||
}: BuildForwardChannelIndexRequest) {
|
||||
this.i18n = i18n;
|
||||
const recentRanks = ForwardChannelIndex.buildRecentRanks(recentChannelIds);
|
||||
const options = observations
|
||||
.filter((observation) => isForwardableChannelType(observation.channel.type))
|
||||
.map((observation) => this.buildOption({excludedChannelId, mediaSelection, observation, recentRanks}));
|
||||
options.sort((left, right) => this.compareOptions(left, right));
|
||||
this.options = Object.freeze(options);
|
||||
this.optionsByChannelId = new Map(options.map((option) => [option.channel.id, option]));
|
||||
}
|
||||
|
||||
filter(searchQuery: string): ReadonlyArray<ForwardChannelOption> {
|
||||
if (searchQuery.trim().length === 0) {
|
||||
return this.options.slice(0, FORWARD_CHANNEL_RESULT_LIMIT);
|
||||
}
|
||||
const normalizedQuery = searchQuery.toLowerCase();
|
||||
const personalNotesSearchValue = this.i18n._(PERSONAL_NOTES_DESCRIPTOR).toLowerCase();
|
||||
const matches: Array<IndexedForwardChannelOption> = [];
|
||||
for (const option of this.options) {
|
||||
if (!ForwardChannelIndex.matchesSearch({normalizedQuery, personalNotesSearchValue, option})) continue;
|
||||
matches.push(option);
|
||||
if (matches.length === FORWARD_CHANNEL_RESULT_LIMIT) break;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
isSelectionDisabled({maxSelections, option, selectedChannelIds}: ForwardChannelSelectionDisabledRequest): boolean {
|
||||
if (option.disableReason != null) return true;
|
||||
if (selectedChannelIds.has(option.channel.id)) return false;
|
||||
return selectedChannelIds.size >= maxSelections;
|
||||
}
|
||||
|
||||
select(selectedChannelIds: ReadonlySet<string>): ReadonlyArray<ForwardChannelOption> {
|
||||
const selected: Array<IndexedForwardChannelOption> = [];
|
||||
for (const channelId of selectedChannelIds) {
|
||||
const option = this.optionsByChannelId.get(channelId);
|
||||
if (option != null) selected.push(option);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static buildRecentRanks(recentChannelIds: ReadonlyArray<string>): ReadonlyMap<string, number> {
|
||||
return new Map(recentChannelIds.map((channelId, index) => [channelId, index]));
|
||||
}
|
||||
|
||||
private static matchesSearch({
|
||||
normalizedQuery,
|
||||
personalNotesSearchValue,
|
||||
option,
|
||||
}: {
|
||||
readonly normalizedQuery: string;
|
||||
readonly personalNotesSearchValue: string;
|
||||
readonly option: IndexedForwardChannelOption;
|
||||
}): boolean {
|
||||
if (option.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true;
|
||||
if (option.displayNameSearchValue.includes(normalizedQuery)) return true;
|
||||
if (option.channelNameSearchValue.includes(normalizedQuery)) return true;
|
||||
return option.guildNameSearchValue.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
private buildOption({
|
||||
excludedChannelId,
|
||||
mediaSelection,
|
||||
observation,
|
||||
recentRanks,
|
||||
}: {
|
||||
readonly excludedChannelId: string;
|
||||
readonly mediaSelection: ForwardMessageMediaSelection;
|
||||
readonly observation: ForwardChannelObservation;
|
||||
readonly recentRanks: ReadonlyMap<string, number>;
|
||||
}): IndexedForwardChannelOption {
|
||||
const permissionIssue = this.resolvePermissionIssue(observation, mediaSelection);
|
||||
const disableReason = this.resolveDisableReason(observation, permissionIssue);
|
||||
const recentRankValue = recentRanks.get(observation.channel.id);
|
||||
let recentRank: number | null = null;
|
||||
if (recentRankValue !== undefined) recentRank = recentRankValue;
|
||||
let channelNameSearchValue = '';
|
||||
if (observation.channel.name) channelNameSearchValue = observation.channel.name.toLowerCase();
|
||||
let guildNameSearchValue = '';
|
||||
if (observation.guildName != null) guildNameSearchValue = observation.guildName.toLowerCase();
|
||||
return Object.freeze({
|
||||
categoryName: observation.categoryName,
|
||||
channel: observation.channel,
|
||||
channelNameSearchValue,
|
||||
disableReason,
|
||||
displayName: observation.displayName,
|
||||
displayNameSearchValue: observation.displayName.toLowerCase(),
|
||||
guildName: observation.guildName,
|
||||
guildNameSearchValue,
|
||||
isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES,
|
||||
isSource: observation.channel.id === excludedChannelId,
|
||||
recentRank,
|
||||
slowmodeEnabled: observation.slowmodeEnabled,
|
||||
slowmodeRemainingMs: observation.slowmodeRemainingMs,
|
||||
});
|
||||
}
|
||||
|
||||
private compareOptions(left: IndexedForwardChannelOption, right: IndexedForwardChannelOption): number {
|
||||
if (left.isSource !== right.isSource) return left.isSource ? 1 : -1;
|
||||
const leftUnavailable = left.disableReason != null;
|
||||
const rightUnavailable = right.disableReason != null;
|
||||
if (leftUnavailable !== rightUnavailable) return leftUnavailable ? 1 : -1;
|
||||
if (left.recentRank != null && right.recentRank != null) return left.recentRank - right.recentRank;
|
||||
if (left.recentRank != null) return -1;
|
||||
if (right.recentRank != null) return 1;
|
||||
return left.displayNameSearchValue.localeCompare(right.displayNameSearchValue);
|
||||
}
|
||||
|
||||
private resolveDisableReason(observation: ForwardChannelObservation, permissionIssue: string | null): string | null {
|
||||
if (permissionIssue != null) return permissionIssue;
|
||||
if (observation.slowmodeRemainingMs <= 0) return null;
|
||||
const remaining = formatSlowmodeTime(observation.slowmodeRemainingMs, this.i18n.locale);
|
||||
return this.i18n._(SLOWMODE_WAIT_DESCRIPTOR, {remaining});
|
||||
}
|
||||
|
||||
private resolvePermissionIssue(
|
||||
observation: ForwardChannelObservation,
|
||||
mediaSelection: ForwardMessageMediaSelection,
|
||||
): string | null {
|
||||
if (observation.guildMessagesDisabled) return this.i18n._(GUILD_MESSAGES_DISABLED_DESCRIPTOR);
|
||||
if (observation.memberTimedOut) return this.i18n._(MEMBER_TIMED_OUT_DESCRIPTOR);
|
||||
if (!observation.canSendMessages) {
|
||||
const sendMessagesPermissionLabel = formatPermissionLabel(this.i18n, Permissions.SEND_MESSAGES);
|
||||
return this.i18n._(SEND_MESSAGES_PERMISSION_REQUIRED_DESCRIPTOR, {sendMessagesPermissionLabel});
|
||||
}
|
||||
if (mediaSelection.hasEmbeds && !observation.canEmbedLinks) {
|
||||
const embedLinksPermissionLabel = formatPermissionLabel(this.i18n, Permissions.EMBED_LINKS);
|
||||
return this.i18n._(EMBED_LINKS_PERMISSION_REQUIRED_DESCRIPTOR, {embedLinksPermissionLabel});
|
||||
}
|
||||
if (mediaSelection.hasAttachments && !observation.canAttachFiles) {
|
||||
const attachFilesPermissionLabel = formatPermissionLabel(this.i18n, Permissions.ATTACH_FILES);
|
||||
return this.i18n._(ATTACH_FILES_PERMISSION_REQUIRED_DESCRIPTOR, {attachFilesPermissionLabel});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,288 +1,119 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {formatSlowmodeTime} from '@app/features/channel/components/SlowmodeIndicator';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {
|
||||
ForwardChannelIndex,
|
||||
type ForwardChannelOption,
|
||||
type ForwardMessageMediaSelection,
|
||||
resolveForwardMessageMediaSelection,
|
||||
} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex';
|
||||
import {useForwardChannelObservations} from '@app/features/app/components/dialogs/shared/UseForwardChannelObservations';
|
||||
import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import Slowmode from '@app/features/slowmode/state/Slowmode';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {isForwardableChannelType} from './ForwardChannelEligibility';
|
||||
|
||||
const CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Channel {id}',
|
||||
comment: 'Short label in the settings dialog forward channel selection. Preserve {id}; it is inserted by code.',
|
||||
});
|
||||
const SENDING_MESSAGES_IS_DISABLED_IN_THIS_COMMUNITY_DESCRIPTOR = msg({
|
||||
message: 'Sending messages is disabled in this community',
|
||||
comment: 'Short label in the settings dialog forward channel selection.',
|
||||
});
|
||||
const YOU_RE_ON_TIMEOUT_IN_THIS_COMMUNITY_DESCRIPTOR = msg({
|
||||
message: "You're on timeout in this community",
|
||||
comment: 'Short label in the settings dialog forward channel selection. Keep the tone plain and specific.',
|
||||
});
|
||||
const YOU_NEED_THE_PERMISSION_TO_SEND_MESSAGES_IN_DESCRIPTOR = msg({
|
||||
message: 'You need the "{sendMessagesPermissionLabel}" permission to send messages in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the user lacks the Send Messages permission in the target channel. Permission name is interpolated.',
|
||||
});
|
||||
const YOU_NEED_THE_PERMISSION_TO_EMBED_LINKS_IN_DESCRIPTOR = msg({
|
||||
message: 'You need the "{embedLinksPermissionLabel}" permission to embed links in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message contains embeds and the user lacks Embed Links in the target channel.',
|
||||
});
|
||||
const YOU_NEED_THE_PERMISSION_TO_ATTACH_FILES_IN_DESCRIPTOR = msg({
|
||||
message: 'You need the "{attachFilesPermissionLabel}" permission to attach files in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message has attachments and the user lacks Attach Files in the target channel.',
|
||||
});
|
||||
const SLOWMODE_WAIT_DESCRIPTOR = msg({
|
||||
message: 'Slowmode · wait {remaining}',
|
||||
comment:
|
||||
'Short label in the settings dialog forward channel selection. Preserve {remaining}; it is inserted by code.',
|
||||
});
|
||||
|
||||
export interface ChannelSlowmodeState {
|
||||
isEnabled: boolean;
|
||||
isCoolingDown: boolean;
|
||||
remainingMs: number;
|
||||
}
|
||||
|
||||
const EMPTY_SLOWMODE_STATE: ChannelSlowmodeState = {isEnabled: false, isCoolingDown: false, remainingMs: 0};
|
||||
|
||||
export function getChannelSlowmodeState(channel: Channel): ChannelSlowmodeState {
|
||||
if (!channel.guildId || channel.rateLimitPerUser <= 0) return EMPTY_SLOWMODE_STATE;
|
||||
if (Permission.can(Permissions.BYPASS_SLOWMODE, channel)) return EMPTY_SLOWMODE_STATE;
|
||||
if (DeveloperOptions.mockSlowmodeActive) {
|
||||
return {isEnabled: true, isCoolingDown: true, remainingMs: DeveloperOptions.mockSlowmodeRemaining};
|
||||
}
|
||||
const remainingMs = Slowmode.getSlowmodeRemaining(channel.id, channel.rateLimitPerUser);
|
||||
return {isEnabled: true, isCoolingDown: remainingMs > 0, remainingMs};
|
||||
}
|
||||
|
||||
export const getForwardChannelDisplayName = (channel: Channel, i18n?: I18n): string => {
|
||||
if (
|
||||
channel.type === ChannelTypes.DM_PERSONAL_NOTES ||
|
||||
channel.type === ChannelTypes.DM ||
|
||||
channel.type === ChannelTypes.GROUP_DM
|
||||
) {
|
||||
return ChannelUtils.getDMDisplayName(channel);
|
||||
}
|
||||
if (channel.name) return channel.name;
|
||||
if (!i18n) return `Channel ${channel.id}`;
|
||||
const id = channel.id;
|
||||
return i18n._(CHANNEL_DESCRIPTOR, {id});
|
||||
};
|
||||
export const getForwardChannelGuildName = (channel: Channel): string | null => {
|
||||
if (!channel.guildId) return null;
|
||||
const guild = Guilds.getGuild(channel.guildId);
|
||||
return guild?.name ?? null;
|
||||
};
|
||||
export const getForwardChannelCategoryName = (channel: Channel): string | null => {
|
||||
if (!channel.parentId) return null;
|
||||
const category = Channels.getChannel(channel.parentId);
|
||||
if (!category) return null;
|
||||
return category.name || null;
|
||||
};
|
||||
|
||||
function messageHasMedia(message: Message): {
|
||||
hasEmbeds: boolean;
|
||||
hasAttachments: boolean;
|
||||
} {
|
||||
const hasEmbeds =
|
||||
message.embeds.length > 0 ||
|
||||
(message.messageSnapshots?.some((s) => s.embeds != null && s.embeds.length > 0) ?? false);
|
||||
const hasAttachments =
|
||||
message.attachments.length > 0 ||
|
||||
(message.messageSnapshots?.some((s) => s.attachments != null && s.attachments.length > 0) ?? false);
|
||||
return {hasEmbeds, hasAttachments};
|
||||
}
|
||||
import {type Dispatch, type SetStateAction, useCallback, useMemo, useState} from 'react';
|
||||
|
||||
interface UseForwardChannelSelectionOptions {
|
||||
excludedChannelId: string;
|
||||
message: Message;
|
||||
maxSelections?: number;
|
||||
mediaSelection?: {
|
||||
hasEmbeds: boolean;
|
||||
hasAttachments: boolean;
|
||||
};
|
||||
readonly excludedChannelId: string;
|
||||
readonly message: Message;
|
||||
readonly maxSelections?: number;
|
||||
readonly mediaSelection?: ForwardMessageMediaSelection;
|
||||
}
|
||||
|
||||
export const useForwardChannelSelection = ({
|
||||
export interface ForwardChannelSelectionState {
|
||||
readonly filteredChannels: ReadonlyArray<ForwardChannelOption>;
|
||||
readonly handleToggleChannel: (channelId: string) => void;
|
||||
readonly isChannelSelectionDisabled: (option: ForwardChannelOption) => boolean;
|
||||
readonly maxSelections: number;
|
||||
readonly searchQuery: string;
|
||||
readonly selectedChannelIds: ReadonlySet<string>;
|
||||
readonly setSearchQuery: Dispatch<SetStateAction<string>>;
|
||||
readonly slowmodeActiveSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>;
|
||||
readonly slowmodeEnabledSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>;
|
||||
}
|
||||
|
||||
function toggleForwardChannelSelection(
|
||||
channelId: string,
|
||||
maxSelections: number,
|
||||
previousChannelIds: Set<string>,
|
||||
): Set<string> {
|
||||
const nextChannelIds = new Set(previousChannelIds);
|
||||
if (nextChannelIds.has(channelId)) {
|
||||
nextChannelIds.delete(channelId);
|
||||
return nextChannelIds;
|
||||
}
|
||||
if (nextChannelIds.size >= maxSelections) return previousChannelIds;
|
||||
nextChannelIds.add(channelId);
|
||||
return nextChannelIds;
|
||||
}
|
||||
|
||||
export function useForwardChannelSelection({
|
||||
excludedChannelId,
|
||||
message,
|
||||
maxSelections = 5,
|
||||
mediaSelection,
|
||||
}: UseForwardChannelSelectionOptions) => {
|
||||
}: UseForwardChannelSelectionOptions): ForwardChannelSelectionState {
|
||||
const {i18n} = useLingui();
|
||||
const locale = i18n.locale;
|
||||
const allKnownChannels = Channels.allChannels;
|
||||
const recentChannelIds = SelectedChannel.recentChannels;
|
||||
const {hasEmbeds, hasAttachments} = useMemo(
|
||||
() => mediaSelection ?? messageHasMedia(message),
|
||||
const allKnownChannels = useShallowStableArray(Channels.allChannels);
|
||||
const recentChannelIds = useShallowStableArray(SelectedChannel.recentChannels);
|
||||
const currentUser = Users.currentUser;
|
||||
const currentUserId = currentUser ? currentUser.id : null;
|
||||
const resolvedMediaSelection = useMemo(
|
||||
() => resolveForwardMessageMediaSelection({message, override: mediaSelection}),
|
||||
[message, mediaSelection],
|
||||
);
|
||||
const observations = useForwardChannelObservations({channels: allKnownChannels, currentUserId, i18n});
|
||||
const channelIndex = useMemo(
|
||||
() =>
|
||||
new ForwardChannelIndex({
|
||||
excludedChannelId,
|
||||
i18n,
|
||||
mediaSelection: resolvedMediaSelection,
|
||||
observations,
|
||||
recentChannelIds,
|
||||
}),
|
||||
[excludedChannelId, i18n, locale, observations, recentChannelIds, resolvedMediaSelection],
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedChannelIds, setSelectedChannelIds] = useState<Set<string>>(new Set());
|
||||
const [slowmodeTick, setSlowmodeTick] = useState(0);
|
||||
const getChannelPermissionIssue = useCallback(
|
||||
(channel: Channel): string | null => {
|
||||
if (!channel.guildId) return null;
|
||||
const guild = Guilds.getGuild(channel.guildId);
|
||||
if (guild && (guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0) {
|
||||
return i18n._(SENDING_MESSAGES_IS_DISABLED_IN_THIS_COMMUNITY_DESCRIPTOR);
|
||||
}
|
||||
const currentUserId = Users.currentUser?.id;
|
||||
const member = currentUserId ? GuildMembers.getMember(channel.guildId, currentUserId) : null;
|
||||
if (member?.isTimedOut()) {
|
||||
return i18n._(YOU_RE_ON_TIMEOUT_IN_THIS_COMMUNITY_DESCRIPTOR);
|
||||
}
|
||||
if (!Permission.can(Permissions.SEND_MESSAGES, channel)) {
|
||||
const sendMessagesPermissionLabel = formatPermissionLabel(i18n, Permissions.SEND_MESSAGES);
|
||||
return i18n._(YOU_NEED_THE_PERMISSION_TO_SEND_MESSAGES_IN_DESCRIPTOR, {sendMessagesPermissionLabel});
|
||||
}
|
||||
if (!hasEmbeds && !hasAttachments) return null;
|
||||
if (hasEmbeds && !Permission.can(Permissions.EMBED_LINKS, {channelId: channel.id, guildId: channel.guildId})) {
|
||||
const embedLinksPermissionLabel = formatPermissionLabel(i18n, Permissions.EMBED_LINKS);
|
||||
return i18n._(YOU_NEED_THE_PERMISSION_TO_EMBED_LINKS_IN_DESCRIPTOR, {embedLinksPermissionLabel});
|
||||
}
|
||||
if (
|
||||
hasAttachments &&
|
||||
!Permission.can(Permissions.ATTACH_FILES, {channelId: channel.id, guildId: channel.guildId})
|
||||
) {
|
||||
const attachFilesPermissionLabel = formatPermissionLabel(i18n, Permissions.ATTACH_FILES);
|
||||
return i18n._(YOU_NEED_THE_PERMISSION_TO_ATTACH_FILES_IN_DESCRIPTOR, {attachFilesPermissionLabel});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[hasEmbeds, hasAttachments, i18n],
|
||||
);
|
||||
const allChannels = useMemo(() => {
|
||||
const channels = allKnownChannels.filter((channel) => isForwardableChannelType(channel.type));
|
||||
return channels.sort((a, b) => {
|
||||
const aIsSource = a.id === excludedChannelId;
|
||||
const bIsSource = b.id === excludedChannelId;
|
||||
if (aIsSource && bIsSource) return 0;
|
||||
if (aIsSource) return 1;
|
||||
if (bIsSource) return -1;
|
||||
const aIsUnavailable = getChannelPermissionIssue(a) != null || getChannelSlowmodeState(a).isCoolingDown;
|
||||
const bIsUnavailable = getChannelPermissionIssue(b) != null || getChannelSlowmodeState(b).isCoolingDown;
|
||||
if (aIsUnavailable !== bIsUnavailable) return aIsUnavailable ? 1 : -1;
|
||||
const aIndex = recentChannelIds.indexOf(a.id);
|
||||
const bIndex = recentChannelIds.indexOf(b.id);
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
const aName = getForwardChannelDisplayName(a, i18n).toLowerCase();
|
||||
const bName = getForwardChannelDisplayName(b, i18n).toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [allKnownChannels, recentChannelIds, excludedChannelId, getChannelPermissionIssue, i18n.locale, slowmodeTick]);
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allChannels;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allChannels.filter((channel) => {
|
||||
const channelName = (channel.name || '').toLowerCase();
|
||||
const displayName = getForwardChannelDisplayName(channel, i18n).toLowerCase();
|
||||
const guildName = channel.guildId ? (Guilds.getGuild(channel.guildId)?.name ?? '').toLowerCase() : '';
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
const notes = i18n._(PERSONAL_NOTES_DESCRIPTOR).toLowerCase();
|
||||
if (notes.includes(query)) return true;
|
||||
}
|
||||
return displayName.includes(query) || channelName.includes(query) || guildName.includes(query);
|
||||
});
|
||||
}, [allChannels, searchQuery, i18n.locale]);
|
||||
const filteredChannels = useMemo(() => channelIndex.filter(searchQuery), [channelIndex, searchQuery]);
|
||||
const handleToggleChannel = useCallback(
|
||||
(channelId: string) => {
|
||||
setSelectedChannelIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(channelId)) {
|
||||
next.delete(channelId);
|
||||
return next;
|
||||
}
|
||||
if (next.size >= maxSelections) return prev;
|
||||
next.add(channelId);
|
||||
return next;
|
||||
});
|
||||
setSelectedChannelIds((previousChannelIds) =>
|
||||
toggleForwardChannelSelection(channelId, maxSelections, previousChannelIds),
|
||||
);
|
||||
},
|
||||
[maxSelections],
|
||||
);
|
||||
const hasAnySlowmodeChannel = useMemo(
|
||||
() =>
|
||||
DeveloperOptions.mockSlowmodeActive ||
|
||||
allChannels.some(
|
||||
(channel) =>
|
||||
channel.guildId != null &&
|
||||
channel.rateLimitPerUser > 0 &&
|
||||
!Permission.can(Permissions.BYPASS_SLOWMODE, channel),
|
||||
),
|
||||
[allChannels],
|
||||
const isChannelSelectionDisabled = useCallback(
|
||||
(option: ForwardChannelOption) => channelIndex.isSelectionDisabled({maxSelections, option, selectedChannelIds}),
|
||||
[maxSelections, channelIndex, selectedChannelIds],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!hasAnySlowmodeChannel) return;
|
||||
const intervalId = window.setInterval(() => {
|
||||
setSlowmodeTick((current) => (current + 1) % 1000000);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hasAnySlowmodeChannel]);
|
||||
const isChannelDisabled = useCallback(
|
||||
(channelId: string) => {
|
||||
if (!selectedChannelIds.has(channelId) && selectedChannelIds.size >= maxSelections) return true;
|
||||
const channel = Channels.getChannel(channelId);
|
||||
if (!channel) return false;
|
||||
if (getChannelPermissionIssue(channel) != null) return true;
|
||||
if (getChannelSlowmodeState(channel).isCoolingDown) return true;
|
||||
return false;
|
||||
},
|
||||
[selectedChannelIds, maxSelections, getChannelPermissionIssue],
|
||||
const selectedChannelOptions = useMemo(
|
||||
() => channelIndex.select(selectedChannelIds),
|
||||
[channelIndex, selectedChannelIds],
|
||||
);
|
||||
const getChannelDisableReason = useCallback(
|
||||
(channel: Channel): string | null => {
|
||||
const permissionIssue = getChannelPermissionIssue(channel);
|
||||
if (permissionIssue != null) return permissionIssue;
|
||||
const slowmode = getChannelSlowmodeState(channel);
|
||||
if (slowmode.isCoolingDown) {
|
||||
const remaining = formatSlowmodeTime(slowmode.remainingMs, locale);
|
||||
return i18n._(SLOWMODE_WAIT_DESCRIPTOR, {remaining});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[getChannelPermissionIssue, i18n, locale],
|
||||
const slowmodeEnabledSelectedChannelOptions = useMemo(
|
||||
() => selectedChannelOptions.filter((option) => option.slowmodeEnabled),
|
||||
[selectedChannelOptions],
|
||||
);
|
||||
const selectedChannels = useMemo(
|
||||
() =>
|
||||
Array.from(selectedChannelIds)
|
||||
.map((channelId) => Channels.getChannel(channelId))
|
||||
.filter((channel): channel is Channel => channel != null),
|
||||
[selectedChannelIds],
|
||||
);
|
||||
const slowmodeEnabledSelectedChannels = useMemo(
|
||||
() => selectedChannels.filter((channel) => getChannelSlowmodeState(channel).isEnabled),
|
||||
[selectedChannels],
|
||||
const slowmodeActiveSelectedChannelOptions = useMemo(
|
||||
() => selectedChannelOptions.filter((option) => option.slowmodeRemainingMs > 0),
|
||||
[selectedChannelOptions],
|
||||
);
|
||||
|
||||
return {
|
||||
filteredChannels,
|
||||
handleToggleChannel,
|
||||
isChannelDisabled,
|
||||
getChannelDisableReason,
|
||||
isChannelSelectionDisabled,
|
||||
maxSelections,
|
||||
searchQuery,
|
||||
selectedChannelIds,
|
||||
setSearchQuery,
|
||||
maxSelections,
|
||||
selectedChannels,
|
||||
slowmodeEnabledSelectedChannels,
|
||||
slowmodeActiveSelectedChannelOptions,
|
||||
slowmodeEnabledSelectedChannelOptions,
|
||||
};
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -230,7 +230,7 @@
|
||||
background-color: var(--settings-border-color, var(--background-modifier-accent));
|
||||
}
|
||||
|
||||
.additionalContent {
|
||||
.extraContent {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ export const MobileSettingsList = observer(function MobileSettingsList<T extends
|
||||
)}
|
||||
{additionalContent && (
|
||||
<div
|
||||
className={styles.additionalContent}
|
||||
className={styles.extraContent}
|
||||
data-flx="app.mobile-settings-components.mobile-settings-list.additional-content"
|
||||
>
|
||||
{additionalContent}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import styles from '@app/features/app/components/dialogs/shared/PermissionComponents.module.css';
|
||||
import PermissionLayout from '@app/features/permissions/state/PermissionLayout';
|
||||
import type * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Switch as UISwitch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -69,7 +70,11 @@ export const PermissionStateButtons: React.FC<{
|
||||
aria-label={i18n._(DENY_DESCRIPTOR)}
|
||||
data-flx="app.permission-components.permission-state-buttons.deny.button"
|
||||
>
|
||||
<XIcon weight="bold" size={16} data-flx="app.permission-components.permission-state-buttons.x-icon" />
|
||||
<XIcon
|
||||
weight="bold"
|
||||
size={remFromPx(16)}
|
||||
data-flx="app.permission-components.permission-state-buttons.x-icon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={styles.stateDivider}
|
||||
@@ -83,7 +88,11 @@ export const PermissionStateButtons: React.FC<{
|
||||
aria-label={i18n._(NEUTRAL_INHERIT_DESCRIPTOR)}
|
||||
data-flx="app.permission-components.permission-state-buttons.neutral.button"
|
||||
>
|
||||
<MinusIcon weight="bold" size={16} data-flx="app.permission-components.permission-state-buttons.minus-icon" />
|
||||
<MinusIcon
|
||||
weight="bold"
|
||||
size={remFromPx(16)}
|
||||
data-flx="app.permission-components.permission-state-buttons.minus-icon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={styles.stateDivider}
|
||||
@@ -97,7 +106,11 @@ export const PermissionStateButtons: React.FC<{
|
||||
aria-label={i18n._(ALLOW_DESCRIPTOR)}
|
||||
data-flx="app.permission-components.permission-state-buttons.allow.button"
|
||||
>
|
||||
<CheckIcon weight="bold" size={16} data-flx="app.permission-components.permission-state-buttons.check-icon" />
|
||||
<CheckIcon
|
||||
weight="bold"
|
||||
size={remFromPx(16)}
|
||||
data-flx="app.permission-components.permission-state-buttons.check-icon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+35
-12
@@ -52,8 +52,14 @@
|
||||
.itemList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: var(--spacing-2) 0;
|
||||
gap: 0.0625rem;
|
||||
padding: 0 0 1rem;
|
||||
}
|
||||
|
||||
.itemGroupList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
@@ -65,9 +71,10 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
border: 0.0625rem solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
padding: var(--spacing-2) 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
@@ -75,17 +82,26 @@
|
||||
}
|
||||
|
||||
.itemButton::before {
|
||||
content: none;
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 var(--spacing-2);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.itemButton:hover:not(:disabled):not(.itemButtonSelected) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-color: transparent;
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.itemButton:hover:not(:disabled)::before {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.itemButtonSelected {
|
||||
background-color: var(--background-modifier-selected);
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.itemButtonSelected::before {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.itemButtonDisabled {
|
||||
@@ -95,10 +111,15 @@
|
||||
|
||||
.itemButtonDisabled:hover {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.itemButtonDisabled:hover::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -132,6 +153,8 @@
|
||||
}
|
||||
|
||||
.itemAction {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export function resolveSettingsTitle(primaryTitle: string | null, fallbackTitle: string | null): string | null {
|
||||
if (primaryTitle != null && primaryTitle.length > 0) {
|
||||
return primaryTitle;
|
||||
}
|
||||
return fallbackTitle;
|
||||
}
|
||||
+6
-10
@@ -290,10 +290,6 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebarNavWrapper > div {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.sidebarNav {
|
||||
flex: 1;
|
||||
margin-inline-end: -0.5rem;
|
||||
@@ -376,7 +372,7 @@
|
||||
}
|
||||
|
||||
.sidebarItemSelected,
|
||||
.sidebarItem:focus:not(.sidebarItemDanger) {
|
||||
.sidebarItem.sidebarItemFocused:not(.sidebarItemDanger) {
|
||||
background: var(--settings-sidebar-selected-background);
|
||||
color: var(--settings-selected-color);
|
||||
}
|
||||
@@ -422,7 +418,7 @@
|
||||
.sidebarItemSelected .sidebarItemChevron,
|
||||
.sidebarItem[aria-expanded='true'] .sidebarItemChevron,
|
||||
.sidebarItem:hover:not(.sidebarItemSelected) .sidebarItemChevron,
|
||||
.sidebarItem:focus-visible .sidebarItemChevron {
|
||||
.sidebarItem.sidebarItemFocused .sidebarItemChevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -431,7 +427,7 @@
|
||||
}
|
||||
|
||||
.sidebarItemSelected .sidebarItemChevron,
|
||||
.sidebarItem:focus:not(.sidebarItemDanger) .sidebarItemChevron {
|
||||
.sidebarItem.sidebarItemFocused:not(.sidebarItemDanger) .sidebarItemChevron {
|
||||
color: var(--settings-selected-color);
|
||||
}
|
||||
|
||||
@@ -440,7 +436,7 @@
|
||||
}
|
||||
|
||||
.sidebarItemSelected .sidebarItemIcon,
|
||||
.sidebarItem:focus:not(.sidebarItemDanger) .sidebarItemIcon {
|
||||
.sidebarItem.sidebarItemFocused:not(.sidebarItemDanger) .sidebarItemIcon {
|
||||
color: var(--settings-sidebar-highlight);
|
||||
}
|
||||
|
||||
@@ -612,8 +608,8 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: var(--active-top, 0px);
|
||||
height: var(--active-height, 0px);
|
||||
top: var(--active-top, 0);
|
||||
height: var(--active-height, 0);
|
||||
width: var(--settings-sidebar-subtree-line-width);
|
||||
background: var(--settings-sidebar-highlight);
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import styles from '@app/features/app/components/dialogs/shared/SettingsModalLayout.module.css';
|
||||
import {SETTINGS_SECTIONS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {
|
||||
@@ -343,7 +344,7 @@ export const SettingsModalSidebarItem: React.FC<SettingsModalSidebarItemProps> =
|
||||
<>
|
||||
<IconComponent
|
||||
className={styles.sidebarItemIcon}
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
weight={iconWeight}
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.sidebar-item-icon"
|
||||
/>
|
||||
@@ -356,7 +357,7 @@ export const SettingsModalSidebarItem: React.FC<SettingsModalSidebarItemProps> =
|
||||
{expandable && (
|
||||
<CaretRightIcon
|
||||
className={styles.sidebarItemChevron}
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
aria-hidden="true"
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.sidebar-item-chevron"
|
||||
@@ -365,7 +366,11 @@ export const SettingsModalSidebarItem: React.FC<SettingsModalSidebarItemProps> =
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<FocusRing offset={-2} data-flx="app.settings-modal-layout.settings-modal-sidebar-item.focus-ring">
|
||||
<FocusRing
|
||||
offset={-2}
|
||||
focusClassName={styles.sidebarItemFocused}
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.focus-ring"
|
||||
>
|
||||
{isTabRole ? (
|
||||
<button
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.button"
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ForwardChannelObservation} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex';
|
||||
import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import Slowmode from '@app/features/slowmode/state/Slowmode';
|
||||
import {useNow} from '@app/features/ui/state/Tick';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {computed, type IComputedValue} from 'mobx';
|
||||
import {useMemo, useRef} from 'react';
|
||||
|
||||
const CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Channel {id}',
|
||||
comment: 'Short label in the settings dialog forward channel selection. Preserve {id}; it is inserted by code.',
|
||||
});
|
||||
|
||||
interface UseForwardChannelObservationsOptions {
|
||||
readonly channels: ReadonlyArray<Channel>;
|
||||
readonly currentUserId: string | null;
|
||||
readonly i18n: I18n;
|
||||
}
|
||||
|
||||
interface ForwardChannelObservationBase {
|
||||
readonly canAttachFiles: boolean;
|
||||
readonly canEmbedLinks: boolean;
|
||||
readonly canSendMessages: boolean;
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly displayName: string;
|
||||
readonly guildMessagesDisabled: boolean;
|
||||
readonly guildName: string | null;
|
||||
readonly memberTimedOut: boolean;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
}
|
||||
|
||||
interface ForwardSlowmodeChannel {
|
||||
readonly channelId: string;
|
||||
readonly index: number;
|
||||
readonly rateLimitPerUser: number;
|
||||
}
|
||||
|
||||
interface ForwardChannelObservationSelection {
|
||||
readonly bases: ReadonlyArray<ForwardChannelObservationBase>;
|
||||
readonly slowmodeChannels: ReadonlyArray<ForwardSlowmodeChannel>;
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channel: Channel, i18n: I18n): string {
|
||||
if (
|
||||
channel.type === ChannelTypes.DM_PERSONAL_NOTES ||
|
||||
channel.type === ChannelTypes.DM ||
|
||||
channel.type === ChannelTypes.GROUP_DM
|
||||
) {
|
||||
return ChannelUtils.getDMDisplayName(channel);
|
||||
}
|
||||
if (channel.name) return channel.name;
|
||||
return i18n._(CHANNEL_DESCRIPTOR, {id: channel.id});
|
||||
}
|
||||
|
||||
function resolveChannelCategoryName(channel: Channel): string | null {
|
||||
if (!channel.parentId) return null;
|
||||
const category = Channels.getChannel(channel.parentId);
|
||||
if (!category || !category.name) return null;
|
||||
return category.name;
|
||||
}
|
||||
|
||||
function isCurrentMemberTimedOut(channel: Channel, currentUserId: string | null): boolean {
|
||||
if (!channel.guildId || currentUserId === null) return false;
|
||||
const member = GuildMembers.getMember(channel.guildId, currentUserId);
|
||||
if (!member) return false;
|
||||
return member.isTimedOut();
|
||||
}
|
||||
|
||||
function resolveSlowmodeRemainingMs(
|
||||
entry: ForwardSlowmodeChannel,
|
||||
mockSlowmodeActive: boolean,
|
||||
mockSlowmodeRemaining: number,
|
||||
): number {
|
||||
if (mockSlowmodeActive) return mockSlowmodeRemaining;
|
||||
return Slowmode.getSlowmodeRemaining(entry.channelId, entry.rateLimitPerUser);
|
||||
}
|
||||
|
||||
function areForwardChannelObservationBasesEqual(
|
||||
left: ForwardChannelObservationBase,
|
||||
right: ForwardChannelObservationBase,
|
||||
): boolean {
|
||||
if (left.channel !== right.channel || left.displayName !== right.displayName) return false;
|
||||
if (left.guildName !== right.guildName || left.categoryName !== right.categoryName) return false;
|
||||
if (left.guildMessagesDisabled !== right.guildMessagesDisabled || left.memberTimedOut !== right.memberTimedOut) {
|
||||
return false;
|
||||
}
|
||||
if (left.canSendMessages !== right.canSendMessages || left.canEmbedLinks !== right.canEmbedLinks) return false;
|
||||
return left.canAttachFiles === right.canAttachFiles && left.slowmodeEnabled === right.slowmodeEnabled;
|
||||
}
|
||||
|
||||
function areForwardSlowmodeChannelsEqual(left: ForwardSlowmodeChannel, right: ForwardSlowmodeChannel): boolean {
|
||||
return (
|
||||
left.channelId === right.channelId && left.index === right.index && left.rateLimitPerUser === right.rateLimitPerUser
|
||||
);
|
||||
}
|
||||
|
||||
function areForwardChannelObservationSelectionsEqual(
|
||||
left: ForwardChannelObservationSelection,
|
||||
right: ForwardChannelObservationSelection,
|
||||
): boolean {
|
||||
if (left.bases.length !== right.bases.length || left.slowmodeChannels.length !== right.slowmodeChannels.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.bases.length; index += 1) {
|
||||
const leftBase = left.bases[index];
|
||||
const rightBase = right.bases[index];
|
||||
if (leftBase === undefined || rightBase === undefined) {
|
||||
throw new Error(`Forward channel observation ${index.toString()} is missing`);
|
||||
}
|
||||
if (!areForwardChannelObservationBasesEqual(leftBase, rightBase)) return false;
|
||||
}
|
||||
for (let index = 0; index < left.slowmodeChannels.length; index += 1) {
|
||||
const leftEntry = left.slowmodeChannels[index];
|
||||
const rightEntry = right.slowmodeChannels[index];
|
||||
if (leftEntry === undefined || rightEntry === undefined) {
|
||||
throw new Error(`Forward slowmode channel ${index.toString()} is missing`);
|
||||
}
|
||||
if (!areForwardSlowmodeChannelsEqual(leftEntry, rightEntry)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class ForwardChannelObservationsOwner {
|
||||
private readonly selection: IComputedValue<ForwardChannelObservationSelection>;
|
||||
private readonly locale: string;
|
||||
|
||||
constructor(private readonly request: UseForwardChannelObservationsOptions) {
|
||||
this.locale = request.i18n.locale;
|
||||
this.selection = computed(() => this.buildSelection(), {equals: areForwardChannelObservationSelectionsEqual});
|
||||
}
|
||||
|
||||
matches(request: UseForwardChannelObservationsOptions): boolean {
|
||||
return (
|
||||
this.request.channels === request.channels &&
|
||||
this.request.currentUserId === request.currentUserId &&
|
||||
this.request.i18n === request.i18n &&
|
||||
this.locale === request.i18n.locale
|
||||
);
|
||||
}
|
||||
|
||||
read(): ForwardChannelObservationSelection {
|
||||
return this.selection.get();
|
||||
}
|
||||
|
||||
private buildSelection(): ForwardChannelObservationSelection {
|
||||
const bases: Array<ForwardChannelObservationBase> = [];
|
||||
const slowmodeChannels: Array<ForwardSlowmodeChannel> = [];
|
||||
for (const channel of this.request.channels) {
|
||||
const index = bases.length;
|
||||
const base = this.buildBase(channel);
|
||||
bases.push(base);
|
||||
if (base.slowmodeEnabled) {
|
||||
slowmodeChannels.push(
|
||||
Object.freeze({channelId: channel.id, index, rateLimitPerUser: channel.rateLimitPerUser}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({bases: Object.freeze(bases), slowmodeChannels: Object.freeze(slowmodeChannels)});
|
||||
}
|
||||
|
||||
private buildBase(channel: Channel): ForwardChannelObservationBase {
|
||||
const guildId = channel.guildId;
|
||||
if (!guildId) {
|
||||
return Object.freeze({
|
||||
canAttachFiles: true,
|
||||
canEmbedLinks: true,
|
||||
canSendMessages: true,
|
||||
categoryName: resolveChannelCategoryName(channel),
|
||||
channel,
|
||||
displayName: resolveChannelDisplayName(channel, this.request.i18n),
|
||||
guildMessagesDisabled: false,
|
||||
guildName: null,
|
||||
memberTimedOut: false,
|
||||
slowmodeEnabled: false,
|
||||
});
|
||||
}
|
||||
const guild = Guilds.getGuild(guildId);
|
||||
let guildName: string | null = null;
|
||||
let guildMessagesDisabled = false;
|
||||
if (guild) {
|
||||
guildName = guild.name;
|
||||
guildMessagesDisabled = (guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0;
|
||||
}
|
||||
let slowmodeEnabled = false;
|
||||
if (
|
||||
Number.isSafeInteger(channel.rateLimitPerUser) &&
|
||||
channel.rateLimitPerUser > 0 &&
|
||||
channel.rateLimitPerUser <= CHANNEL_RATE_LIMIT_PER_USER_MAX
|
||||
) {
|
||||
slowmodeEnabled = !Permission.can(Permissions.BYPASS_SLOWMODE, channel);
|
||||
}
|
||||
return Object.freeze({
|
||||
canAttachFiles: Permission.can(Permissions.ATTACH_FILES, channel),
|
||||
canEmbedLinks: Permission.can(Permissions.EMBED_LINKS, channel),
|
||||
canSendMessages: Permission.can(Permissions.SEND_MESSAGES, channel),
|
||||
categoryName: resolveChannelCategoryName(channel),
|
||||
channel,
|
||||
displayName: resolveChannelDisplayName(channel, this.request.i18n),
|
||||
guildMessagesDisabled,
|
||||
guildName,
|
||||
memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId),
|
||||
slowmodeEnabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardChannelObservations(
|
||||
selection: ForwardChannelObservationSelection,
|
||||
slowmodeRemainingValues: ReadonlyArray<number>,
|
||||
): ReadonlyArray<ForwardChannelObservation> {
|
||||
const remainingByIndex = new Map<number, number>();
|
||||
for (let entryIndex = 0; entryIndex < selection.slowmodeChannels.length; entryIndex += 1) {
|
||||
const entry = selection.slowmodeChannels[entryIndex];
|
||||
const remainingMs = slowmodeRemainingValues[entryIndex];
|
||||
if (entry === undefined || remainingMs === undefined) {
|
||||
throw new Error(`Forward slowmode remaining value ${entryIndex.toString()} is missing`);
|
||||
}
|
||||
remainingByIndex.set(entry.index, remainingMs);
|
||||
}
|
||||
return Object.freeze(
|
||||
selection.bases.map((base, index) => {
|
||||
const remainingMs = remainingByIndex.get(index);
|
||||
return Object.freeze({
|
||||
canAttachFiles: base.canAttachFiles,
|
||||
canEmbedLinks: base.canEmbedLinks,
|
||||
canSendMessages: base.canSendMessages,
|
||||
categoryName: base.categoryName,
|
||||
channel: base.channel,
|
||||
displayName: base.displayName,
|
||||
guildMessagesDisabled: base.guildMessagesDisabled,
|
||||
guildName: base.guildName,
|
||||
memberTimedOut: base.memberTimedOut,
|
||||
slowmodeEnabled: base.slowmodeEnabled,
|
||||
slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useForwardChannelObservations({
|
||||
channels,
|
||||
currentUserId,
|
||||
i18n,
|
||||
}: UseForwardChannelObservationsOptions): ReadonlyArray<ForwardChannelObservation> {
|
||||
const request: UseForwardChannelObservationsOptions = {channels, currentUserId, i18n};
|
||||
const ownerRef = useRef<ForwardChannelObservationsOwner | null>(null);
|
||||
let owner = ownerRef.current;
|
||||
if (owner === null || !owner.matches(request)) {
|
||||
owner = new ForwardChannelObservationsOwner(request);
|
||||
ownerRef.current = owner;
|
||||
}
|
||||
const selection = owner.read();
|
||||
const mockSlowmodeActive = DeveloperOptions.mockSlowmodeActive;
|
||||
const mockSlowmodeRemaining = DeveloperOptions.mockSlowmodeRemaining;
|
||||
const slowmodeRemainingValues = useShallowStableArray(
|
||||
selection.slowmodeChannels.map((entry) =>
|
||||
resolveSlowmodeRemainingMs(entry, mockSlowmodeActive, mockSlowmodeRemaining),
|
||||
),
|
||||
);
|
||||
let hasActiveSlowmodeChannel = false;
|
||||
for (const remainingMs of slowmodeRemainingValues) {
|
||||
if (remainingMs > 0) {
|
||||
hasActiveSlowmodeChannel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
useNow(hasActiveSlowmodeChannel);
|
||||
return useMemo(
|
||||
() => buildForwardChannelObservations(selection, slowmodeRemainingValues),
|
||||
[selection, slowmodeRemainingValues],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import type {RefObject, UIEvent} from 'react';
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
|
||||
export interface PreservedScrollerPosition {
|
||||
scrollerRef: RefObject<ScrollerHandle | null>;
|
||||
handleScroll: (event: UIEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function usePreservedScrollerPosition(active: boolean): PreservedScrollerPosition {
|
||||
const scrollPositionRef = useRef(0);
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
scrollPositionRef.current = event.currentTarget.scrollTop;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller == null) return;
|
||||
const target = scrollPositionRef.current;
|
||||
if (target === 0) return;
|
||||
scroller.scrollTo({to: target, animate: false});
|
||||
}, [active]);
|
||||
return {scrollerRef, handleScroll};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {COPY_USERNAME_DESCRIPTOR, UNKNOWN_DESCRIPTOR} from '@app/features/i18n/u
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {formatClientBuildInfo, getClientInfo, getClientInfoSync} from '@app/features/platform/utils/ClientInfo';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {getUserAccentColor} from '@app/features/theme/utils/AccentColorUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -564,7 +565,7 @@ export const UserAreaPopout = observer(() => {
|
||||
aria-label={i18n._(COPY_USERNAME_DESCRIPTOR)}
|
||||
data-flx="app.floating.user-area-popout.copy-username-button.copy-user-tag"
|
||||
>
|
||||
<CopyIcon size={14} weight="fill" data-flx="app.floating.user-area-popout.copy-icon" />
|
||||
<CopyIcon size={remFromPx(14)} weight="fill" data-flx="app.floating.user-area-popout.copy-icon" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
@@ -592,7 +593,7 @@ export const UserAreaPopout = observer(() => {
|
||||
data-flx="app.floating.user-area-popout.custom-status-placeholder.open-custom-status.button"
|
||||
>
|
||||
<SmileyIcon
|
||||
size={14}
|
||||
size={remFromPx(14)}
|
||||
weight="regular"
|
||||
className={styles.customStatusPlaceholderIcon}
|
||||
data-flx="app.floating.user-area-popout.custom-status-placeholder-icon"
|
||||
@@ -672,7 +673,7 @@ export const UserAreaPopout = observer(() => {
|
||||
<ActionButton
|
||||
icon={
|
||||
<UsersThreeIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.users-three-icon"
|
||||
/>
|
||||
@@ -687,7 +688,7 @@ export const UserAreaPopout = observer(() => {
|
||||
<ActionButton
|
||||
icon={
|
||||
<IdentificationBadgeIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.identification-badge-icon"
|
||||
/>
|
||||
@@ -700,7 +701,13 @@ export const UserAreaPopout = observer(() => {
|
||||
<>
|
||||
<div className={styles.actionDivider} data-flx="app.floating.user-area-popout.action-divider--3" />
|
||||
<ActionButton
|
||||
icon={<InfoIcon size={16} weight="bold" data-flx="app.floating.user-area-popout.info-icon" />}
|
||||
icon={
|
||||
<InfoIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.info-icon"
|
||||
/>
|
||||
}
|
||||
label={<Trans>Copy build info</Trans>}
|
||||
onClick={handleCopyBuildInfo}
|
||||
data-flx="app.floating.user-area-popout.action-button.copy-build-info"
|
||||
@@ -709,7 +716,7 @@ export const UserAreaPopout = observer(() => {
|
||||
<ActionButton
|
||||
icon={
|
||||
<ChartLineIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.chart-line-icon"
|
||||
/>
|
||||
@@ -726,7 +733,13 @@ export const UserAreaPopout = observer(() => {
|
||||
<Button
|
||||
variant="primary"
|
||||
fitContainer
|
||||
leftIcon={<PencilIcon size={16} weight="bold" data-flx="app.floating.user-area-popout.pencil-icon" />}
|
||||
leftIcon={
|
||||
<PencilIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.pencil-icon"
|
||||
/>
|
||||
}
|
||||
onClick={openEditProfile}
|
||||
className={styles.editProfileButton}
|
||||
data-flx="app.floating.user-area-popout.edit-profile-button.open-edit-profile"
|
||||
|
||||
@@ -35,7 +35,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
const [splashAlignment, setSplashAlignment] = useState<GuildSplashCardAlignmentValue>(
|
||||
GuildSplashCardAlignment.CENTER,
|
||||
);
|
||||
const {isNative, isMacOS, platform} = useNativePlatform();
|
||||
const {isNative, platform} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const splashUrlRef = useRef<string | null>(null);
|
||||
const registerFormDraftsRef = useRef<Map<string, AuthRegisterFormDraft>>(new Map());
|
||||
@@ -187,7 +187,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
key="auth-layout-scroller"
|
||||
data-flx="app.auth-layout.auth-layout-content.container"
|
||||
>
|
||||
{isNative && !isMacOS && !useSystemTitleBar && (
|
||||
{isNative && !useSystemTitleBar && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.auth-layout.auth-layout-content.native-titlebar" />
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
.channelListScroller {
|
||||
background-color: var(--background-secondary);
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.channelListScrollerWrapper {
|
||||
--floating-unread-indicator-bottom-inset: calc(
|
||||
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
|
||||
0.5rem
|
||||
);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navigationContainer {
|
||||
|
||||
@@ -34,12 +34,14 @@ import Permission from '@app/features/permissions/state/Permission';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {ChannelListContextMenu} from '@app/features/ui/action_menu/ChannelListContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import * as DimensionCommands from '@app/features/ui/commands/DimensionCommands';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll';
|
||||
import Dimension from '@app/features/ui/state/Dimension';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -168,6 +170,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
[channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
|
||||
);
|
||||
const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getScrollerNode() ?? null, [scrollerRef]);
|
||||
useDragAutoScroll({active: isDraggingAnything, getScrollElement: getChannelScrollContainer});
|
||||
const handleChannelDrop = useCallback(
|
||||
(item: DragItem, result: DropResult) => {
|
||||
if (!result) return;
|
||||
@@ -331,7 +334,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
<ChannelItemContent
|
||||
icon={
|
||||
<UsersIcon
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
className={clsx(
|
||||
channelItemStyles.channelItemIcon,
|
||||
isMembersSelected
|
||||
@@ -572,6 +575,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
</Scroller>
|
||||
<ScrollIndicatorOverlay
|
||||
getScrollContainer={getChannelScrollContainer}
|
||||
scrollContainerIdentity={guild.id}
|
||||
dependencies={channelIndicatorDependencies}
|
||||
label={i18n._(NEW_MESSAGES_DESCRIPTOR)}
|
||||
data-flx="app.channel-list-content.scroll-indicator-overlay"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import styles from '@app/features/app/components/layout/DiscoveryGuildHeader.module.css';
|
||||
import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.module.css';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CompassIcon} from '@phosphor-icons/react';
|
||||
@@ -17,7 +18,7 @@ export function DiscoveryGuildHeader() {
|
||||
return (
|
||||
<div
|
||||
className={clsx(guildHeaderStyles.headerContainer, guildHeaderStyles.headerContainerNoBanner)}
|
||||
style={{height: 56}}
|
||||
style={{height: remFromPx(56)}}
|
||||
data-flx="app.discovery-guild-header.div"
|
||||
>
|
||||
<div
|
||||
|
||||
+3
-3
@@ -198,7 +198,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
.categoryAddButton {
|
||||
display: flex;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
@@ -216,11 +216,11 @@
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.addButton:hover {
|
||||
.categoryAddButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addButtonIcon {
|
||||
.categoryAddButtonIcon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import channelItemSurfaceStyles from '@app/features/app/components/layout/Channe
|
||||
import styles from '@app/features/app/components/layout/ChannelListContent.module.css';
|
||||
import {ChannelListSkeleton} from '@app/features/app/components/layout/ChannelListSkeleton';
|
||||
import {computeVerticalDropPosition} from '@app/features/app/components/layout/dnd/DndDropPosition';
|
||||
import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect';
|
||||
import favoritesChannelListStyles from '@app/features/app/components/layout/FavoritesChannelListContent.module.css';
|
||||
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
@@ -39,9 +40,10 @@ import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuComma
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {MentionBadge} from '@app/features/ui/components/MentionBadge';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
@@ -56,7 +58,7 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import type {ConnectableElement} from 'react-dnd';
|
||||
import {useDrag, useDrop} from 'react-dnd';
|
||||
import {useDrag, useDragLayer, useDrop} from 'react-dnd';
|
||||
import {getEmptyImage} from 'react-dnd-html5-backend';
|
||||
|
||||
const EMPTY_FAVORITES_DESCRIPTOR = msg({
|
||||
@@ -97,7 +99,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
({favoriteChannel, channel, guild}: {favoriteChannel: FavoriteChannel; channel: Channel; guild: Guild | null}) => {
|
||||
const {i18n} = useLingui();
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropTargetRectRef = useRef<DOMRect | null>(null);
|
||||
const getDropTargetRect = useDragTargetRect(elementRef);
|
||||
const [dropIndicator, setDropIndicator] = useState<FavoriteDropIndicator | null>(null);
|
||||
const setFavoriteDropIndicator = useCallback((indicator: FavoriteDropIndicator | null) => {
|
||||
setDropIndicator((current) => {
|
||||
@@ -112,7 +114,6 @@ const FavoriteChannelResolvedItem = observer(
|
||||
});
|
||||
}, []);
|
||||
const resetFavoriteDropIndicator = useCallback(() => {
|
||||
dropTargetRectRef.current = null;
|
||||
setFavoriteDropIndicator(null);
|
||||
}, [setFavoriteDropIndicator]);
|
||||
const location = useLocation();
|
||||
@@ -141,12 +142,10 @@ const FavoriteChannelResolvedItem = observer(
|
||||
const [{isOver}, dropRef] = useDrop<ChannelDragItem, unknown, {isOver: boolean}>({
|
||||
accept: DND_TYPES.FAVORITES_CHANNEL,
|
||||
hover: (_item, monitor) => {
|
||||
const node = elementRef.current;
|
||||
if (!node) return;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
const hoverBoundingRect = dropTargetRectRef.current ?? node.getBoundingClientRect();
|
||||
dropTargetRectRef.current = hoverBoundingRect;
|
||||
const hoverBoundingRect = getDropTargetRect();
|
||||
if (!hoverBoundingRect) return;
|
||||
const dropPos = computeVerticalDropPosition(clientOffset, hoverBoundingRect);
|
||||
setFavoriteDropIndicator({
|
||||
position: dropPos === 'before' ? 'top' : 'bottom',
|
||||
@@ -407,7 +406,7 @@ const FavoriteCategoryItem = observer(
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropTargetRectRef = useRef<DOMRect | null>(null);
|
||||
const getDropTargetRect = useDragTargetRect(elementRef);
|
||||
const [dropIndicator, setDropIndicator] = useState<FavoriteDropIndicator | null>(null);
|
||||
const setCategoryDropIndicator = useCallback((indicator: FavoriteDropIndicator | null) => {
|
||||
setDropIndicator((current) => {
|
||||
@@ -422,7 +421,6 @@ const FavoriteCategoryItem = observer(
|
||||
});
|
||||
}, []);
|
||||
const resetCategoryDropIndicator = useCallback(() => {
|
||||
dropTargetRectRef.current = null;
|
||||
setCategoryDropIndicator(null);
|
||||
}, [setCategoryDropIndicator]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -454,12 +452,10 @@ const FavoriteCategoryItem = observer(
|
||||
resetCategoryDropIndicator();
|
||||
return;
|
||||
}
|
||||
const node = elementRef.current;
|
||||
if (!node) return;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
const hoverBoundingRect = dropTargetRectRef.current ?? node.getBoundingClientRect();
|
||||
dropTargetRectRef.current = hoverBoundingRect;
|
||||
const hoverBoundingRect = getDropTargetRect();
|
||||
if (!hoverBoundingRect) return;
|
||||
const dropPos = computeVerticalDropPosition(clientOffset, hoverBoundingRect);
|
||||
setCategoryDropIndicator({
|
||||
position: dropPos === 'before' ? 'top' : 'bottom',
|
||||
@@ -585,7 +581,7 @@ const FavoriteCategoryItem = observer(
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={favoritesChannelListStyles.addButton}
|
||||
className={favoritesChannelListStyles.categoryAddButton}
|
||||
aria-label={i18n._(ADD_CHANNEL_DESCRIPTOR)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -595,7 +591,7 @@ const FavoriteCategoryItem = observer(
|
||||
>
|
||||
<PlusIcon
|
||||
weight="bold"
|
||||
className={favoritesChannelListStyles.addButtonIcon}
|
||||
className={favoritesChannelListStyles.categoryAddButtonIcon}
|
||||
data-flx="app.favorites-channel-list-content.favorite-category-item.plus-icon"
|
||||
/>
|
||||
</button>
|
||||
@@ -641,6 +637,19 @@ const UncategorizedGroup = ({children}: {children: React.ReactNode}) => {
|
||||
};
|
||||
export const FavoritesChannelListContent = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const isDraggingFavorite = useDragLayer((monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
return (
|
||||
monitor.isDragging() && (itemType === DND_TYPES.FAVORITES_CHANNEL || itemType === DND_TYPES.FAVORITES_CATEGORY)
|
||||
);
|
||||
});
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const getScrollElement = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller == null) return null;
|
||||
return scroller.getScrollerNode();
|
||||
}, []);
|
||||
useDragAutoScroll({active: isDraggingFavorite, getScrollElement});
|
||||
const favorites = Favorites.sortedChannels;
|
||||
const categories = Favorites.sortedCategories;
|
||||
const hideMutedChannels = Favorites.hideMutedChannels;
|
||||
@@ -684,6 +693,7 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
if (!hasVisibleChannels && categories.length === 0) {
|
||||
return (
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.channelListScroller}
|
||||
key="favorites-channel-list-empty-scroller"
|
||||
data-flx="app.favorites-channel-list-content.channel-list-scroller"
|
||||
@@ -702,6 +712,7 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
}
|
||||
return (
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.channelListScroller}
|
||||
key="favorites-channel-list-scroller"
|
||||
data-flx="app.favorites-channel-list-content.channel-list-scroller--2"
|
||||
|
||||
@@ -6,6 +6,7 @@ import styles from '@app/features/app/components/layout/FavoritesGuildHeader.mod
|
||||
import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.module.css';
|
||||
import {GuildHeaderShell} from '@app/features/app/components/layout/GuildHeaderShell';
|
||||
import {FAVORITES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {FavoritesGuildContextMenu} from '@app/features/ui/action_menu/FavoritesGuildContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -43,7 +44,7 @@ export const FavoritesGuildHeader = observer(() => {
|
||||
guildHeaderStyles.headerContainerNoBanner,
|
||||
isOpen && guildHeaderStyles.headerContainerActive,
|
||||
)}
|
||||
style={{height: 56}}
|
||||
style={{height: remFromPx(56)}}
|
||||
data-flx="app.favorites-guild-header.div"
|
||||
>
|
||||
<GuildHeaderShell
|
||||
|
||||
@@ -10,10 +10,12 @@ import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheet
|
||||
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
|
||||
import {GuildHeaderPopout} from '@app/features/guild/components/popouts/GuildHeaderPopout';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Popout from '@app/features/ui/state/Popout';
|
||||
import {SIDEBAR_WIDTH_DEFAULT} from '@app/features/ui/state/SidebarWidth';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -30,7 +32,6 @@ const OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR = msg({
|
||||
comment: 'Short label in the app layout guild header. Preserve {guildName}; it is inserted by code.',
|
||||
});
|
||||
const HEADER_MIN_HEIGHT = 56;
|
||||
const DEFAULT_SIDEBAR_WIDTH = 270;
|
||||
const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9;
|
||||
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
|
||||
export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
@@ -56,7 +57,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
const headerContainerRef = useRef<HTMLElement | null>(null);
|
||||
const mergedHeaderContainerRef = useMergeRefs([headerContainerRef, bannerHoverRef]);
|
||||
const [containerWidth, setContainerWidth] = useState<number>(() =>
|
||||
isMobile && typeof window !== 'undefined' ? window.innerWidth : DEFAULT_SIDEBAR_WIDTH,
|
||||
isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT,
|
||||
);
|
||||
const [viewportHeight, setViewportHeight] = useState<number>(() =>
|
||||
typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
@@ -122,7 +123,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
!showIntegratedBanner && styles.headerContainerNoBanner,
|
||||
!showIntegratedBanner && isOpen && styles.headerContainerActive,
|
||||
)}
|
||||
style={{height: showIntegratedBanner ? bannerMaxHeight : HEADER_MIN_HEIGHT}}
|
||||
style={{height: showIntegratedBanner ? bannerMaxHeight : remFromPx(HEADER_MIN_HEIGHT)}}
|
||||
data-flx="app.guild-header.header-container.context-menu"
|
||||
>
|
||||
{showIntegratedBanner && (
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
--layout-mobile-bottom-nav-reserved-height: var(--mobile-bottom-nav-height);
|
||||
}
|
||||
|
||||
:global(html.platform-native):not(.platform-macos) .guildNavbarContainer {
|
||||
:global(html.platform-native) .guildNavbarContainer {
|
||||
border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(html.platform-native):not(.platform-macos) .skeletonContainer {
|
||||
:global(html.platform-native) .skeletonContainer {
|
||||
border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
}
|
||||
|
||||
.guildsLayoutReserveSpace {
|
||||
--layout-user-area-overlay-height: calc(var(--layout-user-area-height) + var(--layout-voice-connection-height, 0px));
|
||||
--layout-user-area-overlay-height: calc(
|
||||
var(--layout-user-area-height) +
|
||||
var(--user-area-box-inset-block-end) +
|
||||
var(--layout-voice-connection-height, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
.guildsLayoutReserveMobileBottomNav {
|
||||
@@ -42,6 +46,10 @@
|
||||
--layout-user-area-overlay-height,
|
||||
var(--layout-user-area-reserved-height, 0px)
|
||||
);
|
||||
--floating-unread-indicator-bottom-inset: calc(
|
||||
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
|
||||
0.5rem
|
||||
);
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
@@ -62,9 +70,14 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background-color: var(--background-secondary);
|
||||
overflow-anchor: none;
|
||||
--guild-badge-surface: var(--background-secondary);
|
||||
padding-top: var(--spacing-1);
|
||||
padding-bottom: var(--spacing-2);
|
||||
scroll-padding-bottom: calc(
|
||||
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
|
||||
var(--spacing-2)
|
||||
);
|
||||
}
|
||||
|
||||
.guildListContent {
|
||||
@@ -172,7 +185,8 @@
|
||||
width: calc(var(--layout-guild-list-width) + var(--layout-sidebar-width) + 0.0625rem);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
padding: 0 var(--footer-box-inset-inline) var(--user-area-box-inset-block-end);
|
||||
background-color: var(--background-secondary);
|
||||
pointer-events: none;
|
||||
z-index: var(--z-index-elevated-1);
|
||||
}
|
||||
@@ -323,10 +337,11 @@
|
||||
}
|
||||
|
||||
.dmListSection {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--guild-list-item-gap);
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -377,7 +392,28 @@
|
||||
composes: guildListItem;
|
||||
}
|
||||
|
||||
.dmListItemWrapper {
|
||||
.dmListRow {
|
||||
position: relative;
|
||||
height: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dmListRow[data-pending-removal='true'] {
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dmListRow[data-last-row='true'] {
|
||||
height: var(--guild-list-item-box-size);
|
||||
}
|
||||
|
||||
.dmListItemBox {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
height: var(--guild-list-item-box-size);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -419,11 +455,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.addGuildButton {
|
||||
.createGuildButton {
|
||||
composes: guildListItem;
|
||||
}
|
||||
|
||||
.addGuildButtonIcon {
|
||||
.createGuildButtonIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -442,11 +478,11 @@
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.addGuildButton:hover
|
||||
.addGuildButtonIcon,
|
||||
.createGuildButton:hover
|
||||
.createGuildButtonIcon,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.addGuildButtonIcon:hover,
|
||||
.addGuildButton.contextMenuHover .addGuildButtonIcon {
|
||||
.createGuildButtonIcon:hover,
|
||||
.createGuildButton.contextMenuHover .createGuildButtonIcon {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -582,31 +618,24 @@
|
||||
background: #000;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .guildsLayoutContainer {
|
||||
:global(html.platform-native) .guildsLayoutContainer {
|
||||
padding-top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .guildsLayoutFullscreen {
|
||||
:global(html.platform-native) .guildsLayoutFullscreen {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .guildListScrollContainer {
|
||||
padding-top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
:global(html:not(.platform-native)) .outlineFrame,
|
||||
:global(html.platform-native.platform-macos) .outlineFrame {
|
||||
:global(html:not(.platform-native)) .outlineFrame {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .outlineFrame {
|
||||
:global(html.platform-native) .outlineFrame {
|
||||
border-top: 0.0625rem solid var(--user-area-divider-color);
|
||||
--outline-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
}
|
||||
|
||||
:global(html:not(.platform-native)) .outlineFrameFullscreen,
|
||||
:global(html.platform-native.platform-macos) .outlineFrameFullscreen,
|
||||
:global(html.platform-native:not(.platform-macos)) .outlineFrameFullscreen {
|
||||
:global(html.platform-native) .outlineFrame.outlineFrameFullscreen {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {useActiveNagbars, useNagbarConditions} from '@app/features/app/components/layout/app_layout/AppLayoutHooks';
|
||||
import {NagbarContainer} from '@app/features/app/components/layout/app_layout/NagbarContainer';
|
||||
import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/TopNagbarContext';
|
||||
import styles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import {
|
||||
DM_LIST_REMOVAL_DELAY_MS,
|
||||
DMListAnimatedRow,
|
||||
type DMListRow,
|
||||
useFrameBatchedDMListRows,
|
||||
} from '@app/features/app/components/layout/guilds_layout/GuildListDMAnimation';
|
||||
import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame';
|
||||
import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {AddGuildButton} from '@app/features/app/components/layout/sidebar_nav/AddGuildButton';
|
||||
@@ -13,7 +20,6 @@ import {DownloadButton} from '@app/features/app/components/layout/sidebar_nav/Do
|
||||
import {FavoritesButton} from '@app/features/app/components/layout/sidebar_nav/FavoritesButton';
|
||||
import {FluxerButton} from '@app/features/app/components/layout/sidebar_nav/FluxerButton';
|
||||
import {GuildFolderItem} from '@app/features/app/components/layout/sidebar_nav/GuildFolderItem';
|
||||
import {DMListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListDMItem';
|
||||
import {GuildListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListItem';
|
||||
import {HelpButton} from '@app/features/app/components/layout/sidebar_nav/HelpButton';
|
||||
import {DND_TYPES, type GuildDragItem, type GuildDropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
@@ -38,11 +44,15 @@ import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import * as DimensionCommands from '@app/features/ui/commands/DimensionCommands';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {useHoverDeferredOrderedItems} from '@app/features/ui/hooks/UseHoverDeferredOrderedItems';
|
||||
import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll';
|
||||
import {useListScrollAnchor} from '@app/features/ui/hooks/useScrollAnchor';
|
||||
import Dimension from '@app/features/ui/state/Dimension';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Nagbar from '@app/features/ui/state/Nagbar';
|
||||
import SidebarPreferences from '@app/features/ui/state/SidebarPreferences';
|
||||
import SidebarWidth from '@app/features/ui/state/SidebarWidth';
|
||||
import WhatsNew from '@app/features/ui/state/WhatsNew';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands';
|
||||
@@ -60,7 +70,7 @@ import {ExclamationMarkIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useDrop} from 'react-dnd';
|
||||
|
||||
const NEW_DESCRIPTOR = msg({
|
||||
@@ -71,12 +81,26 @@ const NEW_DESCRIPTOR = msg({
|
||||
const isSelectedPath = (pathname: string, path: string) => {
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
const DM_LIST_REMOVAL_DELAY_MS = 750;
|
||||
const UNAVAILABLE_INDICATOR_DEBOUNCE_MS = 1500;
|
||||
const GUILD_LIST_FOCUSABLE_SELECTOR = '[data-guild-list-focus-item="true"]';
|
||||
const WHEEL_LINE_HEIGHT_PX = 16;
|
||||
const DOM_DELTA_LINE = 1;
|
||||
const DOM_DELTA_PAGE = 2;
|
||||
const getChannelId = (channel: Channel): string => channel.id;
|
||||
const EMPTY_CHANNELS: ReadonlyArray<Channel> = Object.freeze([]);
|
||||
|
||||
interface GuildsLayoutSidebarStyle extends React.CSSProperties {
|
||||
'--layout-sidebar-width': string;
|
||||
}
|
||||
|
||||
function resolveGuildsLayoutSidebarStyle(
|
||||
mobileEnabled: boolean,
|
||||
sidebarWidth: string | null,
|
||||
): React.CSSProperties | undefined {
|
||||
if (mobileEnabled || sidebarWidth == null || sidebarWidth === '') return undefined;
|
||||
const style: GuildsLayoutSidebarStyle = {'--layout-sidebar-width': sidebarWidth};
|
||||
return style;
|
||||
}
|
||||
|
||||
function getResizeObserverEntryBlockSize(entry: ResizeObserverEntry): number {
|
||||
const borderBoxSize = entry.borderBoxSize;
|
||||
@@ -84,8 +108,7 @@ function getResizeObserverEntryBlockSize(entry: ResizeObserverEntry): number {
|
||||
return firstBorderBoxSize?.blockSize ?? entry.contentRect.height;
|
||||
}
|
||||
|
||||
const getUnreadDMChannels = (): Array<Channel> => {
|
||||
const dmChannels = Channels.dmChannels;
|
||||
const getUnreadDMChannels = (dmChannels: ReadonlyArray<Channel>): Array<Channel> => {
|
||||
const out: Array<Channel> = [];
|
||||
for (let i = 0; i < dmChannels.length; i++) {
|
||||
if (ReadStates.hasUnread(dmChannels[i].id)) out.push(dmChannels[i]);
|
||||
@@ -93,6 +116,96 @@ const getUnreadDMChannels = (): Array<Channel> => {
|
||||
return out;
|
||||
};
|
||||
|
||||
interface DMChannelVisibilityControllerState {
|
||||
readonly removalDelayMs: number;
|
||||
readonly removalTimers: React.MutableRefObject<Map<string, number>>;
|
||||
readonly orderedUnreadChannels: ReadonlyArray<Channel>;
|
||||
readonly setVisibleChannels: React.Dispatch<React.SetStateAction<ReadonlyArray<Channel>>>;
|
||||
readonly unreadChannels: ReadonlyArray<Channel>;
|
||||
readonly unreadIdsRef: React.MutableRefObject<ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
interface ProjectVisibleDMChannelsRequest {
|
||||
readonly currentChannels: ReadonlyArray<Channel>;
|
||||
readonly unreadIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
class DMChannelVisibilityController {
|
||||
private readonly removalDelayMs: number;
|
||||
private readonly removalTimers: React.MutableRefObject<Map<string, number>>;
|
||||
private readonly orderedUnreadChannels: ReadonlyArray<Channel>;
|
||||
private readonly setVisibleChannels: React.Dispatch<React.SetStateAction<ReadonlyArray<Channel>>>;
|
||||
private readonly unreadChannels: ReadonlyArray<Channel>;
|
||||
private readonly unreadIdsRef: React.MutableRefObject<ReadonlySet<string>>;
|
||||
|
||||
public constructor({
|
||||
removalDelayMs,
|
||||
removalTimers,
|
||||
orderedUnreadChannels,
|
||||
setVisibleChannels,
|
||||
unreadChannels,
|
||||
unreadIdsRef,
|
||||
}: DMChannelVisibilityControllerState) {
|
||||
this.removalDelayMs = removalDelayMs;
|
||||
this.removalTimers = removalTimers;
|
||||
this.orderedUnreadChannels = orderedUnreadChannels;
|
||||
this.setVisibleChannels = setVisibleChannels;
|
||||
this.unreadChannels = unreadChannels;
|
||||
this.unreadIdsRef = unreadIdsRef;
|
||||
}
|
||||
|
||||
public synchronize(): void {
|
||||
this.setVisibleChannels((currentChannels) =>
|
||||
this.projectVisibleChannels({currentChannels, unreadIds: this.unreadIdsRef.current}),
|
||||
);
|
||||
for (const channel of this.unreadChannels) this.cancelRemoval(channel.id);
|
||||
}
|
||||
|
||||
private projectVisibleChannels({currentChannels, unreadIds}: ProjectVisibleDMChannelsRequest): Array<Channel> {
|
||||
const visibleChannelIds = new Set(this.orderedUnreadChannels.map(getChannelId));
|
||||
const leavingChannels = currentChannels.filter((channel) => {
|
||||
if (unreadIds.has(channel.id) || visibleChannelIds.has(channel.id)) return false;
|
||||
visibleChannelIds.add(channel.id);
|
||||
return true;
|
||||
});
|
||||
for (const channel of leavingChannels) this.scheduleRemoval(channel.id);
|
||||
return [...this.orderedUnreadChannels, ...leavingChannels];
|
||||
}
|
||||
|
||||
private scheduleRemoval(channelId: string): void {
|
||||
if (this.removalTimers.current.has(channelId)) return;
|
||||
const timer = window.setTimeout(() => this.removeChannel(channelId), this.removalDelayMs);
|
||||
this.removalTimers.current.set(channelId, timer);
|
||||
}
|
||||
|
||||
private removeChannel(channelId: string): void {
|
||||
this.cancelRemoval(channelId);
|
||||
if (this.unreadIdsRef.current.has(channelId)) return;
|
||||
this.setVisibleChannels((channels) => channels.filter((channel) => channel.id !== channelId));
|
||||
}
|
||||
|
||||
public completeRemoval(channelId: string): void {
|
||||
this.removeChannel(channelId);
|
||||
}
|
||||
|
||||
private cancelRemoval(channelId: string): void {
|
||||
const timer = this.removalTimers.current.get(channelId);
|
||||
if (timer == null) return;
|
||||
window.clearTimeout(timer);
|
||||
this.removalTimers.current.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
interface ClearHoveredChannelRequest {
|
||||
readonly currentChannelId: string | null;
|
||||
readonly endedChannelId: string;
|
||||
}
|
||||
|
||||
function clearHoveredChannelId({currentChannelId, endedChannelId}: ClearHoveredChannelRequest): string | null {
|
||||
if (currentChannelId === endedChannelId) return null;
|
||||
return currentChannelId;
|
||||
}
|
||||
|
||||
function getOrganizedItemKey(item: OrganizedItem): string {
|
||||
if (item.type === 'folder') {
|
||||
return `folder-${item.folder.id}`;
|
||||
@@ -100,6 +213,13 @@ function getOrganizedItemKey(item: OrganizedItem): string {
|
||||
return item.guild.id;
|
||||
}
|
||||
|
||||
function getGuildListAnchorKeyForItem(item: OrganizedItem): string {
|
||||
if (item.type === 'folder') {
|
||||
return `folder-${item.folder.id}`;
|
||||
}
|
||||
return `guild-${item.guild.id}`;
|
||||
}
|
||||
|
||||
function buildGuildNavigationIndexMap(organizedItems: ReadonlyArray<OrganizedItem>): Map<string, number> {
|
||||
const guildNavigationIndexes = new Map<string, number>();
|
||||
let guildIndex = 0;
|
||||
@@ -138,10 +258,12 @@ function getWheelScrollDeltaY(event: WheelEvent | React.WheelEvent<HTMLDivElemen
|
||||
}
|
||||
|
||||
function isWheelEventOverElement(event: WheelEvent, element: HTMLElement): boolean {
|
||||
if (event.target instanceof Node && element.contains(event.target)) {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow != null && event.target instanceof ownerWindow.Node && element.contains(event.target)) {
|
||||
return true;
|
||||
}
|
||||
const pointElement = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const pointElement = ownerDocument.elementFromPoint(event.clientX, event.clientY);
|
||||
return pointElement !== null && element.contains(pointElement);
|
||||
}
|
||||
|
||||
@@ -318,11 +440,26 @@ const GuildList = observer(() => {
|
||||
const guildNavigationIndexes = useMemo(() => buildGuildNavigationIndexMap(organizedItems), [organizedItems]);
|
||||
const unavailableGuilds = GuildAvailability.unavailableGuilds;
|
||||
const unavailableCount = unavailableGuilds.size;
|
||||
const unreadDMChannels = getUnreadDMChannels();
|
||||
let unreadDMChannelIds = '';
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) {
|
||||
unreadDMChannelIds += i === 0 ? unreadDMChannels[i].id : `,${unreadDMChannels[i].id}`;
|
||||
}
|
||||
const readVersion = ReadStates.version;
|
||||
const dmChannels = Channels.dmChannels;
|
||||
const unreadDMChannels = useMemo(() => getUnreadDMChannels(dmChannels), [dmChannels, readVersion]);
|
||||
const unreadDMChannelIdSet = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (let index = 0; index < unreadDMChannels.length; index++) {
|
||||
ids.add(unreadDMChannels[index].id);
|
||||
}
|
||||
return ids;
|
||||
}, [unreadDMChannels]);
|
||||
const unreadDMChannelIdSetRef = useRef<ReadonlySet<string>>(unreadDMChannelIdSet);
|
||||
unreadDMChannelIdSetRef.current = unreadDMChannelIdSet;
|
||||
const unreadDMChannelIds = useMemo(() => unreadDMChannels.map(getChannelId).join(','), [unreadDMChannels]);
|
||||
const [hoveredInlineDMChannelId, setHoveredInlineDMChannelId] = useState<string | null>(null);
|
||||
const orderedUnreadDMChannels = useHoverDeferredOrderedItems({
|
||||
items: unreadDMChannels,
|
||||
getKey: getChannelId,
|
||||
isHoveringDynamicItem: hoveredInlineDMChannelId != null,
|
||||
releaseToken: readVersion,
|
||||
});
|
||||
const scrollRef = useRef<ScrollerHandle>(null);
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
const scrollPersistRafRef = useRef<number | null>(null);
|
||||
@@ -332,12 +469,12 @@ const GuildList = observer(() => {
|
||||
const unavailableIndicatorHideTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasUnavailableGuilds = visibleUnavailableCount > 0;
|
||||
const guildReadVersion = GuildReadState.version;
|
||||
const readVersion = ReadStates.version;
|
||||
const guildIndicatorDependencies = useMemo(
|
||||
() => [guilds.length, guildReadVersion, readVersion, unreadDMChannelIds],
|
||||
[guilds.length, guildReadVersion, readVersion, unreadDMChannelIds],
|
||||
);
|
||||
const getGuildScrollContainer = useCallback(() => scrollRef.current?.getScrollerNode() ?? null, []);
|
||||
useDragAutoScroll({active: isDragging, getScrollElement: getGuildScrollContainer});
|
||||
const scrollGuildListByWheel = useCallback((event: WheelEvent | React.WheelEvent<HTMLDivElement>) => {
|
||||
const scrollNode = scrollRef.current?.getScrollerNode();
|
||||
if (!scrollNode) return false;
|
||||
@@ -351,7 +488,7 @@ const GuildList = observer(() => {
|
||||
scrollNode.scrollTop = nextScrollTop;
|
||||
return true;
|
||||
}, []);
|
||||
const [visibleDMChannels, setVisibleDMChannels] = useState(unreadDMChannels);
|
||||
const [visibleDMChannels, setVisibleDMChannels] = useState<ReadonlyArray<Channel>>(orderedUnreadDMChannels);
|
||||
const directMessagesDisabled = RuntimeConfig.directMessagesDisabled;
|
||||
let pinnedCallChannel: Channel | null = null;
|
||||
if (!directMessagesDisabled && MediaEngine.connected) {
|
||||
@@ -368,19 +505,76 @@ const GuildList = observer(() => {
|
||||
}
|
||||
}
|
||||
const inlineDmsCollapsed = SidebarPreferences.inlineDmsCollapsed;
|
||||
const baseDMChannels = inlineDmsCollapsed || directMessagesDisabled ? [] : visibleDMChannels;
|
||||
const filteredDMChannels = pinnedCallChannel
|
||||
? baseDMChannels.filter((channel) => channel.id !== pinnedCallChannel.id)
|
||||
: baseDMChannels;
|
||||
const hasVisibleDMChannels = filteredDMChannels.length > 0 || Boolean(pinnedCallChannel);
|
||||
let baseDMChannels: ReadonlyArray<Channel>;
|
||||
if (inlineDmsCollapsed || directMessagesDisabled) {
|
||||
baseDMChannels = EMPTY_CHANNELS;
|
||||
} else {
|
||||
baseDMChannels = visibleDMChannels;
|
||||
}
|
||||
const filteredDMChannels = useMemo(() => {
|
||||
if (pinnedCallChannel != null) {
|
||||
return baseDMChannels.filter((channel) => channel.id !== pinnedCallChannel.id);
|
||||
}
|
||||
return baseDMChannels;
|
||||
}, [baseDMChannels, pinnedCallChannel]);
|
||||
const targetDMListRows = useMemo<Array<DMListRow>>(() => {
|
||||
const rows: Array<DMListRow> = [];
|
||||
if (pinnedCallChannel != null) {
|
||||
rows.push({type: 'channel', channel: pinnedCallChannel, voiceCallActive: true, pendingRemoval: false});
|
||||
}
|
||||
for (let index = 0; index < filteredDMChannels.length; index++) {
|
||||
const channel = filteredDMChannels[index];
|
||||
rows.push({
|
||||
type: 'channel',
|
||||
channel,
|
||||
voiceCallActive: false,
|
||||
pendingRemoval: !unreadDMChannelIdSet.has(channel.id),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [filteredDMChannels, pinnedCallChannel, unreadDMChannelIdSet]);
|
||||
const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows);
|
||||
const {
|
||||
getAnchorRef: getGuildListAnchorRef,
|
||||
handleResize: handleGuildListAnchorResize,
|
||||
handleScroll: handleGuildListAnchorScroll,
|
||||
stabilize: stabilizeGuildListAnchor,
|
||||
} = useListScrollAnchor({scrollerRef: scrollRef});
|
||||
const hasVisibleDMChannels = targetDMListRows.length > 0 || visibleDMListRows.length > 0;
|
||||
const shouldShowTopDivider = (guilds.length > 0 || hasUnavailableGuilds) && !hasVisibleDMChannels;
|
||||
const shouldShowEmptyStateDivider = !hasVisibleDMChannels && !hasUnavailableGuilds && guilds.length === 0;
|
||||
const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0;
|
||||
useLayoutEffect(() => {
|
||||
stabilizeGuildListAnchor();
|
||||
}, [
|
||||
hasUnavailableGuilds,
|
||||
hasVisibleDMChannels,
|
||||
organizedItems,
|
||||
shouldRenderGuildListItems,
|
||||
stabilizeGuildListAnchor,
|
||||
visibleDMListRows,
|
||||
visibleUnavailableCount,
|
||||
]);
|
||||
const selectedGuildIndex = useMemo(
|
||||
() => getSelectedGuildNavigationIndex(location.pathname, guildNavigationIndexes),
|
||||
[location.pathname, guildNavigationIndexes],
|
||||
);
|
||||
const removalTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const removalTimers = useRef<Map<string, number>>(new Map());
|
||||
const dmVisibilityControllerRef = useRef<DMChannelVisibilityController | null>(null);
|
||||
let dmRemovalDelayMs = DM_LIST_REMOVAL_DELAY_MS;
|
||||
if (Accessibility.useReducedMotion) dmRemovalDelayMs = 0;
|
||||
const handleInlineDMHoverStart = useCallback((channelId: string) => {
|
||||
setHoveredInlineDMChannelId(channelId);
|
||||
}, []);
|
||||
const handleInlineDMHoverEnd = useCallback((channelId: string) => {
|
||||
setHoveredInlineDMChannelId((currentChannelId) =>
|
||||
clearHoveredChannelId({currentChannelId, endedChannelId: channelId}),
|
||||
);
|
||||
}, []);
|
||||
const handleInlineDMRemovalAnimationComplete = useCallback((channelId: string) => {
|
||||
const controller = dmVisibilityControllerRef.current;
|
||||
if (controller != null) controller.completeRemoval(channelId);
|
||||
}, []);
|
||||
const guildListNavigationRef = useRovingFocusList<HTMLDivElement>({
|
||||
focusableSelector: GUILD_LIST_FOCUSABLE_SELECTOR,
|
||||
orientation: 'vertical',
|
||||
@@ -390,38 +584,22 @@ const GuildList = observer(() => {
|
||||
manageTabIndex: true,
|
||||
});
|
||||
useEffect(() => {
|
||||
const unreadIds = new Set<string>();
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) unreadIds.add(unreadDMChannels[i].id);
|
||||
setVisibleDMChannels((current) => {
|
||||
const leftover: Array<Channel> = [];
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
const channel = current[i];
|
||||
if (!unreadIds.has(channel.id)) leftover.push(channel);
|
||||
}
|
||||
for (let i = 0; i < leftover.length; i++) {
|
||||
const channel = leftover[i];
|
||||
if (!removalTimers.current.has(channel.id)) {
|
||||
const timer = setTimeout(() => {
|
||||
removalTimers.current.delete(channel.id);
|
||||
setVisibleDMChannels((latest) => latest.filter((latestChannel) => latestChannel.id !== channel.id));
|
||||
}, DM_LIST_REMOVAL_DELAY_MS);
|
||||
removalTimers.current.set(channel.id, timer);
|
||||
}
|
||||
}
|
||||
const next: Array<Channel> = new Array(unreadDMChannels.length + leftover.length);
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) next[i] = unreadDMChannels[i];
|
||||
for (let i = 0; i < leftover.length; i++) next[unreadDMChannels.length + i] = leftover[i];
|
||||
return next;
|
||||
const controller = new DMChannelVisibilityController({
|
||||
removalDelayMs: dmRemovalDelayMs,
|
||||
removalTimers,
|
||||
orderedUnreadChannels: orderedUnreadDMChannels,
|
||||
setVisibleChannels: setVisibleDMChannels,
|
||||
unreadChannels: unreadDMChannels,
|
||||
unreadIdsRef: unreadDMChannelIdSetRef,
|
||||
});
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) {
|
||||
const channel = unreadDMChannels[i];
|
||||
const timer = removalTimers.current.get(channel.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
removalTimers.current.delete(channel.id);
|
||||
}
|
||||
}
|
||||
}, [unreadDMChannelIds]);
|
||||
dmVisibilityControllerRef.current = controller;
|
||||
controller.synchronize();
|
||||
}, [dmRemovalDelayMs, orderedUnreadDMChannels, unreadDMChannels]);
|
||||
useEffect(() => {
|
||||
if (hoveredInlineDMChannelId == null) return;
|
||||
const hoveredRow = visibleDMListRows.find((row) => row.channel.id === hoveredInlineDMChannelId);
|
||||
if (hoveredRow == null || hoveredRow.pendingRemoval) setHoveredInlineDMChannelId(null);
|
||||
}, [hoveredInlineDMChannelId, visibleDMListRows]);
|
||||
useEffect(() => {
|
||||
if (unavailableCount > 0) {
|
||||
if (unavailableIndicatorHideTimer.current) {
|
||||
@@ -439,31 +617,15 @@ const GuildList = observer(() => {
|
||||
}, [unavailableCount]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dmVisibilityControllerRef.current = null;
|
||||
if (unavailableIndicatorHideTimer.current) {
|
||||
clearTimeout(unavailableIndicatorHideTimer.current);
|
||||
unavailableIndicatorHideTimer.current = null;
|
||||
}
|
||||
removalTimers.current.forEach((timer) => clearTimeout(timer));
|
||||
removalTimers.current.forEach((timer) => window.clearTimeout(timer));
|
||||
removalTimers.current.clear();
|
||||
};
|
||||
}, []);
|
||||
const renderDMListItems = (channels: Array<Channel>) =>
|
||||
channels.map((channel) => {
|
||||
const isSelected = isSelectedPath(location.pathname, Routes.dmChannel(channel.id));
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={styles.dmListItemWrapper}
|
||||
data-flx="app.guilds-layout.render-dm-list-items.dm-list-item-wrapper"
|
||||
>
|
||||
<DMListItem
|
||||
channel={channel}
|
||||
isSelected={isSelected}
|
||||
data-flx="app.guilds-layout.render-dm-list-items.dm-list-item"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const handleGuildDrop = useCallback(
|
||||
(item: GuildDragItem, result: GuildDropResult) => {
|
||||
const sourceKey = item.id;
|
||||
@@ -611,17 +773,21 @@ const GuildList = observer(() => {
|
||||
const handleDragStateChange = useCallback((item: GuildDragItem | null) => {
|
||||
setIsDragging(item !== null);
|
||||
}, []);
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const scrollTop = event.currentTarget.scrollTop;
|
||||
pendingScrollTopRef.current = scrollTop;
|
||||
if (scrollPersistRafRef.current != null) return;
|
||||
scrollPersistRafRef.current = requestAnimationFrame(() => {
|
||||
scrollPersistRafRef.current = null;
|
||||
const pendingScrollTop = pendingScrollTopRef.current;
|
||||
if (pendingScrollTop == null) return;
|
||||
DimensionCommands.updateGuildListScroll(pendingScrollTop);
|
||||
});
|
||||
}, []);
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
handleGuildListAnchorScroll(event.currentTarget);
|
||||
const scrollTop = event.currentTarget.scrollTop;
|
||||
pendingScrollTopRef.current = scrollTop;
|
||||
if (scrollPersistRafRef.current != null) return;
|
||||
scrollPersistRafRef.current = requestAnimationFrame(() => {
|
||||
scrollPersistRafRef.current = null;
|
||||
const pendingScrollTop = pendingScrollTopRef.current;
|
||||
if (pendingScrollTop == null) return;
|
||||
DimensionCommands.updateGuildListScroll(pendingScrollTop);
|
||||
});
|
||||
},
|
||||
[handleGuildListAnchorScroll],
|
||||
);
|
||||
const handleWheel = useCallback(
|
||||
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || event.defaultPrevented) return;
|
||||
@@ -634,19 +800,23 @@ const GuildList = observer(() => {
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const scrollNode = getGuildScrollContainer();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = scrollNode.ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return;
|
||||
const handleWindowWheel = (event: WheelEvent) => {
|
||||
const scrollNode = scrollRef.current?.getScrollerNode();
|
||||
if (!scrollNode || !isWheelEventOverElement(event, scrollNode)) return;
|
||||
const currentScrollNode = getGuildScrollContainer();
|
||||
if (currentScrollNode == null || !isWheelEventOverElement(event, currentScrollNode)) return;
|
||||
if (!scrollGuildListByWheel(event)) return;
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener('wheel', handleWindowWheel, {capture: true, passive: false});
|
||||
ownerWindow.addEventListener('wheel', handleWindowWheel, {capture: true, passive: false});
|
||||
return () => {
|
||||
window.removeEventListener('wheel', handleWindowWheel, true);
|
||||
ownerWindow.removeEventListener('wheel', handleWindowWheel, true);
|
||||
};
|
||||
}, [isDragging, scrollGuildListByWheel]);
|
||||
}, [getGuildScrollContainer, isDragging, scrollGuildListByWheel]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollPersistRafRef.current != null) {
|
||||
@@ -673,6 +843,7 @@ const GuildList = observer(() => {
|
||||
className={styles.guildListScrollContainer}
|
||||
showTrack={false}
|
||||
onScroll={handleScroll}
|
||||
onResize={handleGuildListAnchorResize}
|
||||
onWheel={handleWheel}
|
||||
key="guild-list-scroller"
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-scroll-container"
|
||||
@@ -686,37 +857,43 @@ const GuildList = observer(() => {
|
||||
<FluxerButton data-flx="app.guilds-layout.guild-list.fluxer-button" />
|
||||
<FavoritesButton data-flx="app.guilds-layout.guild-list.favorites-button" />
|
||||
<div className={styles.dmListSection} data-flx="app.guilds-layout.guild-list.dm-list-section">
|
||||
{pinnedCallChannel && (
|
||||
<div
|
||||
className={styles.dmListItemWrapper}
|
||||
key={`pinned-call-${pinnedCallChannel.id}`}
|
||||
data-flx="app.guilds-layout.guild-list.dm-list-item-wrapper"
|
||||
>
|
||||
<DMListItem
|
||||
channel={pinnedCallChannel}
|
||||
isSelected={isSelectedPath(location.pathname, Routes.dmChannel(pinnedCallChannel.id))}
|
||||
voiceCallActive
|
||||
data-flx="app.guilds-layout.guild-list.dm-list-item"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{renderDMListItems(filteredDMChannels)}
|
||||
{visibleDMListRows.map((row, index) => (
|
||||
<DMListAnimatedRow
|
||||
key={row.channel.id}
|
||||
channel={row.channel}
|
||||
isLast={index === visibleDMListRows.length - 1}
|
||||
isSelected={isSelectedPath(location.pathname, Routes.dmChannel(row.channel.id))}
|
||||
pendingRemoval={row.pendingRemoval}
|
||||
reducedMotion={Accessibility.useReducedMotion}
|
||||
onHoverStart={handleInlineDMHoverStart}
|
||||
onHoverEnd={handleInlineDMHoverEnd}
|
||||
onRemovalAnimationComplete={handleInlineDMRemovalAnimationComplete}
|
||||
scrollAnchorRef={getGuildListAnchorRef(`dm-${row.channel.id}`)}
|
||||
voiceCallActive={row.voiceCallActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasVisibleDMChannels && (
|
||||
<div className={styles.guildDivider} data-flx="app.guilds-layout.guild-list.guild-divider" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={getGuildListAnchorRef('guild-section')}
|
||||
className={styles.guildListGuildsSection}
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-guilds-section"
|
||||
>
|
||||
{shouldShowTopDivider && (
|
||||
<div className={styles.guildDivider} data-flx="app.guilds-layout.guild-list.guild-divider--2" />
|
||||
<div
|
||||
ref={getGuildListAnchorRef('guild-divider')}
|
||||
className={styles.guildDivider}
|
||||
data-flx="app.guilds-layout.guild-list.guild-divider--2"
|
||||
/>
|
||||
)}
|
||||
{shouldRenderGuildListItems && (
|
||||
<div className={styles.guildListItems} data-flx="app.guilds-layout.guild-list.guild-list-items">
|
||||
{hasUnavailableGuilds && (
|
||||
<div
|
||||
ref={getGuildListAnchorRef('guild-outage-indicator')}
|
||||
className={styles.guildListItemSlot}
|
||||
key="guild-outage-indicator"
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-item-slot"
|
||||
@@ -764,6 +941,7 @@ const GuildList = observer(() => {
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={getGuildListAnchorRef(getGuildListAnchorKeyForItem(item))}
|
||||
className={styles.guildListItemSlot}
|
||||
key={getOrganizedItemKey(item)}
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-item-slot--2"
|
||||
@@ -784,6 +962,7 @@ const GuildList = observer(() => {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={getGuildListAnchorRef(getGuildListAnchorKeyForItem(item))}
|
||||
className={styles.guildListItemSlot}
|
||||
key={item.guild.id}
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-item-slot--3"
|
||||
@@ -827,6 +1006,7 @@ const GuildList = observer(() => {
|
||||
</Scroller>
|
||||
<ScrollIndicatorOverlay
|
||||
getScrollContainer={getGuildScrollContainer}
|
||||
scrollContainerIdentity="guild-list"
|
||||
dependencies={guildIndicatorDependencies}
|
||||
label={i18n._(NEW_DESCRIPTOR)}
|
||||
data-flx="app.guilds-layout.guild-list.scroll-indicator-overlay"
|
||||
@@ -945,6 +1125,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
||||
shouldReserveUserAreaSpace && styles.guildsLayoutReserveSpace,
|
||||
showBottomNav && styles.guildsLayoutReserveMobileBottomNav,
|
||||
)}
|
||||
style={resolveGuildsLayoutSidebarStyle(mobileLayout.enabled, SidebarWidth.cssValue)}
|
||||
data-flx="app.guilds-layout.guilds-layout"
|
||||
>
|
||||
{!isVoiceCallFullscreenActive && (!mobileLayout.enabled || showGuildListOnMobile) && (
|
||||
@@ -963,6 +1144,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
||||
<OutlineFrame
|
||||
className={clsx(styles.outlineFrame, isVoiceCallFullscreenActive && styles.outlineFrameFullscreen)}
|
||||
sidebarDivider={!isVoiceCallFullscreenActive && shouldShowSidebarDivider}
|
||||
sidebarResizeHandle={!isVoiceCallFullscreenActive && shouldShowSidebarDivider}
|
||||
nagbar={
|
||||
!isVoiceCallFullscreenActive && activeNagbars.length > 0 ? (
|
||||
<div className={styles.nagbarStack} data-flx="app.guilds-layout.nagbar-stack">
|
||||
|
||||
@@ -15,15 +15,16 @@ type NativeDragRegionProps = Omit<React.HTMLAttributes<HTMLElement>, 'style'> &
|
||||
export const NativeDragRegion = React.forwardRef<HTMLElement, NativeDragRegionProps>(
|
||||
function NativeDragRegionInner(props, ref) {
|
||||
const {as, disabled = false, className, ...rest} = props;
|
||||
const Component = (as ?? 'div') as ElementType;
|
||||
return (
|
||||
<Component
|
||||
ref={ref as React.Ref<HTMLElement>}
|
||||
className={clsx(className, !disabled && styles.nativeDragRegion)}
|
||||
data-flx="app.native-drag-region.native-drag-region-inner.native-drag-region"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
let Component: ElementType = 'div';
|
||||
if (as != null) {
|
||||
Component = as;
|
||||
}
|
||||
return React.createElement(Component, {
|
||||
...rest,
|
||||
ref: ref as React.Ref<HTMLElement>,
|
||||
className: clsx(className, !disabled && styles.nativeDragRegion),
|
||||
'data-flx': 'app.native-drag-region.native-drag-region-inner.native-drag-region',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
.titlebar[data-platform='macos'] {
|
||||
padding-left: calc(var(--macos-traffic-light-inset) + env(safe-area-inset-left));
|
||||
padding-right: calc(var(--spacing-3) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.window-focused)) .titlebar,
|
||||
:global(html.platform-native.window-focus-activation-guard) .titlebar {
|
||||
pointer-events: auto;
|
||||
@@ -29,9 +34,11 @@
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
height: 0.875rem;
|
||||
display: block;
|
||||
height: 0.8125rem;
|
||||
width: auto;
|
||||
color: var(--text-primary-muted);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
||||
@@ -4,32 +4,54 @@ import styles from '@app/features/app/components/layout/NativeTitlebar.module.cs
|
||||
import {FluxerWordmark} from '@app/features/ui/components/icons/FluxerWordmark';
|
||||
import {getElectronAPI, type NativePlatform} from '@app/features/ui/utils/NativeUtils';
|
||||
import type React from 'react';
|
||||
import {useLayoutEffect} from 'react';
|
||||
import {NativeWindowControls} from './NativeWindowControls';
|
||||
|
||||
const STARTUP_NATIVE_TITLEBAR_ID = 'fluxer-startup-native-titlebar';
|
||||
|
||||
interface NativeTitlebarProps {
|
||||
platform: NativePlatform;
|
||||
}
|
||||
|
||||
export const NativeTitlebar: React.FC<NativeTitlebarProps> = ({platform}) => {
|
||||
const isMacOS = platform === 'macos';
|
||||
useLayoutEffect(() => {
|
||||
const startupTitlebar = document.getElementById(STARTUP_NATIVE_TITLEBAR_ID);
|
||||
if (startupTitlebar != null) {
|
||||
startupTitlebar.remove();
|
||||
}
|
||||
}, []);
|
||||
const handleDoubleClick = () => {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.windowMaximize) return;
|
||||
electronApi.windowMaximize();
|
||||
};
|
||||
const brand = (
|
||||
<div className={styles.left} data-flx="app.native-titlebar.left">
|
||||
<FluxerWordmark className={styles.wordmark} data-flx="app.native-titlebar.wordmark" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
className={styles.titlebar}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onDoubleClick={isMacOS ? undefined : handleDoubleClick}
|
||||
data-platform={platform}
|
||||
data-native-titlebar=""
|
||||
data-flx="app.native-titlebar.titlebar"
|
||||
>
|
||||
<div className={styles.left} data-flx="app.native-titlebar.left">
|
||||
<FluxerWordmark className={styles.wordmark} data-flx="app.native-titlebar.wordmark" />
|
||||
</div>
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
<NativeWindowControls data-flx="app.native-titlebar.controls" />
|
||||
{isMacOS ? (
|
||||
<>
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
{brand}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{brand}
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
<NativeWindowControls data-flx="app.native-titlebar.controls" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loaded 100 of 875 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user