build: extract marketing and simplify releases (#1594)

This commit is contained in:
Hampus
2026-08-14 21:14:13 +02:00
committed by GitHub
parent beb906753f
commit 08566fc244
258 changed files with 940 additions and 26031 deletions
-6
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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/
+7
View File
@@ -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
+18
View File
@@ -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
```
+3 -1
View File
@@ -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/**/*
+46 -65
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+31 -77
View File
@@ -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}"
+32 -35
View File
@@ -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) }}
@@ -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[@]}"
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
-60
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
-473
View File
@@ -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,218 @@
# 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/i18n/marketing/**
- .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
-51
View File
@@ -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 }}"
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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"
-282
View File
@@ -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 }}"
+1 -1
View File
@@ -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
-2
View File
@@ -41,8 +41,6 @@
/app-dist-output/
/artifacts/
/release-input/
/release-out/
/s3_payload/
/upload_staging/
+4
View File
@@ -0,0 +1,4 @@
[submodule "fluxer_marketing"]
path = fluxer_marketing
url = https://github.com/fluxerapp/marketing.git
update = none
Generated
-443
View File
@@ -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
View File
@@ -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",
-1
View File
@@ -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
+1 -1
View File
@@ -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
+1
Submodule fluxer_marketing added at 5297a205aa
-44
View File
@@ -1,44 +0,0 @@
[package]
name = "fluxer_marketing"
version = "0.1.0"
edition.workspace = true
license.workspace = true
build = "build.rs"
[dependencies]
accept-language = "3.1.0"
ammonia = "4.1.2"
anyhow = "1.0.102"
axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1"
fluxer_common = { path = "../fluxer_common" }
comrak = { version = "0.52.0", default-features = false }
cookie = "0.18.1"
email_address = "0.2.9"
gettext = "0.4.0"
hmac = "0.13.0"
icu_datetime = "2.2.0"
icu_locale = "2.2.0"
maud = { version = "0.27.0", features = ["axum"] }
mime_guess = "2.0.5"
moka = { version = "0.12.15", features = ["future", "sync"] }
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.11.0"
time = { version = "0.3.47", features = ["formatting", "macros", "parsing"] }
tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["compression-gzip", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
urlencoding = "2.1.3"
[build-dependencies]
polib = "0.3.0"
serde_json = "1.0.150"
sha2 = "0.11.0"
syn = { version = "2.0.117", features = ["full", "visit"] }
[dev-dependencies]
http-body-util = "0.1.3"
-76
View File
@@ -1,76 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM rust:1-bookworm AS builder
ARG BUILD_VERSION=""
ARG TARGETARCH
WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
&& npm install -g pnpm@10.29.3 \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches patches
COPY fluxer_marketing/package.json fluxer_marketing/package.json
RUN pnpm install --frozen-lockfile --filter fluxer_marketing --config.block-exotic-subdeps=false
COPY Cargo.lock Cargo.lock
COPY fluxer_common fluxer_common
COPY fluxer_marketing fluxer_marketing
COPY packages/fonts/manifest.json packages/fonts/manifest.json
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
COPY packages/fonts/css/locale-fallbacks.css packages/fonts/css/locale-fallbacks.css
COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
RUN printf '%s\n' \
'[workspace]' \
'members = ["fluxer_common", "fluxer_marketing"]' \
'resolver = "2"' \
'' \
'[workspace.package]' \
'edition = "2024"' \
'license = "AGPL-3.0-or-later"' \
> Cargo.toml
RUN pnpm install --frozen-lockfile --filter fluxer_marketing --config.block-exotic-subdeps=false
RUN OXIDE_VERSION="$(node -p "require('./fluxer_marketing/package.json').optionalDependencies['@tailwindcss/oxide-linux-x64-gnu']")" \
&& case "${TARGETARCH}" in \
amd64) OXIDE_PKG="@tailwindcss/oxide-linux-x64-gnu" ;; \
arm64) OXIDE_PKG="@tailwindcss/oxide-linux-arm64-gnu" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& npm install --prefix /tmp/tailwind-oxide --no-audit --no-fund "${OXIDE_PKG}@${OXIDE_VERSION}" \
&& mkdir -p node_modules/@tailwindcss \
&& cp -R "/tmp/tailwind-oxide/node_modules/${OXIDE_PKG}" "node_modules/${OXIDE_PKG}" \
&& rm -rf /tmp/tailwind-oxide
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
RUN cargo test -p fluxer_marketing \
&& cargo build --release -p fluxer_marketing \
&& cp target/release/fluxer_marketing /usr/local/bin/fluxer-marketing
FROM debian:bookworm-slim AS runtime
ARG BUILD_VERSION=""
WORKDIR /usr/local/bin
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/bin/fluxer-marketing /usr/local/bin/fluxer-marketing
ENV BUILD_VERSION="${BUILD_VERSION}"
ENV FLUXER_MARKETING_HOST="0.0.0.0"
ENV FLUXER_MARKETING_PORT="8080"
USER 65532:65532
EXPOSE 8080
CMD ["/usr/local/bin/fluxer-marketing"]
-985
View File
@@ -1,985 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use syn::{
Expr, ExprLit, ExprMacro, Ident, Lit, Macro, Token, braced,
parse::{Parse, ParseStream},
visit::{self, Visit},
};
#[derive(Clone)]
struct LocaleInfo {
code: &'static str,
variant: &'static str,
rtl: bool,
}
#[derive(Clone, Debug)]
struct Descriptor {
const_name: String,
key: String,
message: String,
comment: String,
placeholders: BTreeSet<String>,
}
const LOCALES: &[LocaleInfo] = &[
LocaleInfo {
code: "ar",
variant: "Ar",
rtl: true,
},
LocaleInfo {
code: "bg",
variant: "Bg",
rtl: false,
},
LocaleInfo {
code: "cs",
variant: "Cs",
rtl: false,
},
LocaleInfo {
code: "da",
variant: "Da",
rtl: false,
},
LocaleInfo {
code: "de",
variant: "De",
rtl: false,
},
LocaleInfo {
code: "el",
variant: "El",
rtl: false,
},
LocaleInfo {
code: "en-GB",
variant: "EnGb",
rtl: false,
},
LocaleInfo {
code: "en-US",
variant: "EnUs",
rtl: false,
},
LocaleInfo {
code: "es-419",
variant: "Es419",
rtl: false,
},
LocaleInfo {
code: "es-ES",
variant: "EsEs",
rtl: false,
},
LocaleInfo {
code: "fi",
variant: "Fi",
rtl: false,
},
LocaleInfo {
code: "fr",
variant: "Fr",
rtl: false,
},
LocaleInfo {
code: "he",
variant: "He",
rtl: true,
},
LocaleInfo {
code: "hi",
variant: "Hi",
rtl: false,
},
LocaleInfo {
code: "hr",
variant: "Hr",
rtl: false,
},
LocaleInfo {
code: "hu",
variant: "Hu",
rtl: false,
},
LocaleInfo {
code: "id",
variant: "Id",
rtl: false,
},
LocaleInfo {
code: "it",
variant: "It",
rtl: false,
},
LocaleInfo {
code: "ja",
variant: "Ja",
rtl: false,
},
LocaleInfo {
code: "ko",
variant: "Ko",
rtl: false,
},
LocaleInfo {
code: "lt",
variant: "Lt",
rtl: false,
},
LocaleInfo {
code: "nl",
variant: "Nl",
rtl: false,
},
LocaleInfo {
code: "no",
variant: "No",
rtl: false,
},
LocaleInfo {
code: "pl",
variant: "Pl",
rtl: false,
},
LocaleInfo {
code: "pt-BR",
variant: "PtBr",
rtl: false,
},
LocaleInfo {
code: "ro",
variant: "Ro",
rtl: false,
},
LocaleInfo {
code: "ru",
variant: "Ru",
rtl: false,
},
LocaleInfo {
code: "sv-SE",
variant: "SvSe",
rtl: false,
},
LocaleInfo {
code: "th",
variant: "Th",
rtl: false,
},
LocaleInfo {
code: "tr",
variant: "Tr",
rtl: false,
},
LocaleInfo {
code: "uk",
variant: "Uk",
rtl: false,
},
LocaleInfo {
code: "vi",
variant: "Vi",
rtl: false,
},
LocaleInfo {
code: "zh-CN",
variant: "ZhCn",
rtl: false,
},
LocaleInfo {
code: "zh-TW",
variant: "ZhTw",
rtl: false,
},
];
fn main() {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR missing"));
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR missing"));
let locale_dir = manifest_dir.join("locales");
let descriptors_path = manifest_dir.join("src/i18n/descriptors.rs");
let generated_dir = out_dir.join("i18n");
fs::create_dir_all(&generated_dir).expect("failed to create generated i18n dir");
println!("cargo:rerun-if-changed=src/i18n/descriptors.rs");
println!("cargo:rerun-if-changed=locales");
println!("cargo:rerun-if-changed=package.json");
println!("cargo:rerun-if-changed=src/styles/app.css");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=content");
let descriptors = parse_descriptors(&descriptors_path);
validate_gettext_catalogs(&locale_dir, &generated_dir, &descriptors);
write_generated_i18n(&generated_dir.join("generated.rs"));
build_fonts(&manifest_dir, &out_dir);
build_tailwind(&manifest_dir, &out_dir);
}
fn parse_descriptors(path: &Path) -> Vec<Descriptor> {
let mut descriptors = Vec::new();
let mut seen_names = BTreeSet::new();
let mut seen_keys = BTreeSet::new();
let mut macro_count = 0;
for descriptor_file in descriptor_source_files(path) {
let source = fs::read_to_string(&descriptor_file)
.unwrap_or_else(|err| panic!("failed to read {}: {}", descriptor_file.display(), err));
let syntax = syn::parse_file(&source)
.unwrap_or_else(|err| panic!("failed to parse {}: {}", descriptor_file.display(), err));
let mut visitor = MarketingMessageVisitor::new(&descriptor_file);
visitor.visit_file(&syntax);
macro_count += visitor.macro_count;
for descriptor in visitor.descriptors {
let const_name = descriptor.const_name.clone();
let key = descriptor.key.clone();
let message = descriptor.message.clone();
let comment = descriptor.comment.clone();
if !seen_names.insert(const_name.clone()) {
panic!("duplicate marketing descriptor const: {}", const_name);
}
if !seen_keys.insert(key.clone()) {
panic!("duplicate marketing descriptor key: {}", key);
}
if message.trim().is_empty() {
panic!(
"descriptor {} has an empty American English source string",
const_name
);
}
if comment.trim().len() < 24 {
panic!(
"descriptor {} needs a contextual translator comment",
const_name
);
}
let placeholders = extract_placeholders(&message);
validate_descriptor_comment(&const_name, &comment, &placeholders);
descriptors.push(Descriptor {
const_name: descriptor.const_name,
key: descriptor.key,
message: descriptor.message,
comment: descriptor.comment,
placeholders,
});
}
}
if macro_count != descriptors.len() {
panic!(
"parsed {} marketing_message! descriptors from {}, but found {} macro invocations",
descriptors.len(),
path.display(),
macro_count,
);
}
if descriptors.is_empty() {
panic!(
"no marketing_message! descriptors found in {}",
path.display()
);
}
descriptors
}
struct MarketingMessageVisitor<'a> {
descriptor_file: &'a Path,
descriptors: Vec<Descriptor>,
macro_count: usize,
}
impl<'a> MarketingMessageVisitor<'a> {
fn new(descriptor_file: &'a Path) -> Self {
Self {
descriptor_file,
descriptors: Vec::new(),
macro_count: 0,
}
}
}
impl<'ast> Visit<'ast> for MarketingMessageVisitor<'_> {
fn visit_macro(&mut self, node: &'ast Macro) {
if is_marketing_message_macro(node) {
self.macro_count += 1;
let input = node
.parse_body::<MarketingMessageInput>()
.unwrap_or_else(|err| {
panic!(
"failed to parse marketing_message! descriptor in {}: {}",
self.descriptor_file.display(),
err
)
});
self.descriptors
.push(input.into_descriptor(self.descriptor_file));
}
visit::visit_macro(self, node);
}
}
struct MarketingMessageInput {
const_name: Ident,
key: Expr,
message: Expr,
comment: Expr,
}
impl MarketingMessageInput {
fn into_descriptor(self, descriptor_file: &Path) -> Descriptor {
let const_name = self.const_name.to_string();
if !const_name
.chars()
.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
{
panic!(
"marketing descriptor const must be uppercase snake case in {}: {}",
descriptor_file.display(),
const_name
);
}
let key = expect_string_literal(&self.key, &const_name, "key", descriptor_file);
let message = parse_descriptor_message(descriptor_file, &const_name, &self.message);
let comment = expect_string_literal(&self.comment, &const_name, "comment", descriptor_file);
Descriptor {
const_name,
key,
message,
comment,
placeholders: BTreeSet::new(),
}
}
}
impl Parse for MarketingMessageInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
input.parse::<Token![pub]>()?;
input.parse::<Token![const]>()?;
let const_name = input.parse()?;
input.parse::<Token![=]>()?;
let body;
braced!(body in input);
let key = parse_expected_field(&body, "key")?;
body.parse::<Token![,]>()?;
let message = parse_expected_field(&body, "message")?;
body.parse::<Token![,]>()?;
let comment = parse_expected_field(&body, "comment")?;
if body.peek(Token![,]) {
body.parse::<Token![,]>()?;
}
if !body.is_empty() {
return Err(body.error("unexpected descriptor field"));
}
input.parse::<Token![;]>()?;
if !input.is_empty() {
return Err(input.error("unexpected tokens after descriptor"));
}
Ok(Self {
const_name,
key,
message,
comment,
})
}
}
fn parse_expected_field(input: ParseStream, expected_name: &str) -> syn::Result<Expr> {
let name: Ident = input.parse()?;
if name != expected_name {
return Err(syn::Error::new(
name.span(),
format!("expected `{expected_name}` field"),
));
}
input.parse::<Token![:]>()?;
input.parse()
}
fn is_marketing_message_macro(node: &Macro) -> bool {
if node.path.leading_colon.is_some() {
return false;
}
let segments = node
.path
.segments
.iter()
.map(|segment| segment.ident.to_string())
.collect::<Vec<_>>();
matches!(
segments.as_slice(),
[name] if name == "marketing_message"
) || matches!(
segments.as_slice(),
[root, name] if root == "crate" && name == "marketing_message"
)
}
fn expect_string_literal(
expr: &Expr,
const_name: &str,
field_name: &str,
descriptor_file: &Path,
) -> String {
if let Expr::Lit(ExprLit {
lit: Lit::Str(value),
..
}) = expr
{
return value.value();
}
panic!(
"descriptor {} field {} must be a string literal in {}",
const_name,
field_name,
descriptor_file.display()
);
}
fn parse_descriptor_message(descriptor_file: &Path, const_name: &str, message: &Expr) -> String {
if let Expr::Lit(ExprLit {
lit: Lit::Str(value),
..
}) = message
{
return value.value();
}
let Expr::Macro(ExprMacro { mac, .. }) = message else {
panic!(
"descriptor {} message must be a string literal or include_str!(...) in {}",
const_name,
descriptor_file.display()
);
};
if !is_include_str_macro(mac) {
panic!(
"descriptor {} message must be a string literal or include_str!(...) in {}",
const_name,
descriptor_file.display()
);
}
let relative_path = mac.parse_body::<syn::LitStr>().unwrap_or_else(|err| {
panic!(
"invalid include_str! descriptor message in {} for {}: {}",
descriptor_file.display(),
const_name,
err
)
});
let source_path = descriptor_file
.parent()
.expect("descriptor file should have a parent directory")
.join(relative_path.value());
fs::read_to_string(&source_path).unwrap_or_else(|err| {
panic!(
"failed to read descriptor source {}: {}",
source_path.display(),
err
)
})
}
fn is_include_str_macro(node: &Macro) -> bool {
node.path.leading_colon.is_none()
&& node.path.segments.len() == 1
&& node.path.segments[0].ident == "include_str"
}
fn validate_descriptor_comment(const_name: &str, comment: &str, placeholders: &BTreeSet<String>) {
if placeholders.is_empty() {
return;
}
let lowercase_comment = comment.to_ascii_lowercase();
let mentions_placeholder_handling = lowercase_comment.contains("placeholder")
|| placeholders.iter().all(|placeholder| {
comment.contains(&format!("{{{placeholder}}}")) || comment.contains(placeholder)
});
if !mentions_placeholder_handling {
panic!(
"descriptor {} uses placeholders {:?} but its translator comment does not explain placeholder handling",
const_name, placeholders,
);
}
}
fn descriptor_source_files(path: &Path) -> Vec<PathBuf> {
let mut files = vec![path.to_path_buf()];
let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
return files;
};
let dir = path.with_file_name(stem);
if dir.is_dir() {
let mut children = fs::read_dir(&dir)
.unwrap_or_else(|err| {
panic!("failed to read descriptor dir {}: {}", dir.display(), err)
})
.map(|entry| entry.expect("failed to read descriptor dir entry").path())
.filter(|child| child.extension().and_then(|ext| ext.to_str()) == Some("rs"))
.collect::<Vec<_>>();
children.sort();
files.extend(children);
}
files
}
fn validate_gettext_catalogs(locale_dir: &Path, generated_dir: &Path, descriptors: &[Descriptor]) {
let mut locale_codes = BTreeSet::new();
for locale in LOCALES {
locale_codes.insert(locale.code);
let po_path = locale_dir.join(format!("{}.po", locale.code));
if !po_path.exists() {
panic!("missing gettext catalog: {}", po_path.display());
}
}
for entry in fs::read_dir(locale_dir).expect("failed to read locale dir") {
let entry = entry.expect("failed to read locale entry");
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("po") {
let code = path
.file_stem()
.and_then(|stem| stem.to_str())
.expect("invalid locale file name");
if !locale_codes.contains(code) {
panic!(
"unexpected gettext catalog without Locale enum entry: {}",
path.display()
);
}
}
}
let source_messages = descriptors
.iter()
.map(|descriptor| (descriptor.key.clone(), descriptor))
.collect::<BTreeMap<_, _>>();
for locale in LOCALES {
let po_path = locale_dir.join(format!("{}.po", locale.code));
let catalog = polib::po_file::parse(&po_path)
.unwrap_or_else(|err| panic!("failed to parse {}: {}", po_path.display(), err));
let mut seen = BTreeSet::new();
for message in catalog.messages() {
if message.is_fuzzy() {
panic!(
"{} has fuzzy translation for {}",
locale.code,
message.msgid()
);
}
let key = message
.msgctxt()
.unwrap_or_else(|| panic!("message without msgctxt in {}", po_path.display()));
if key.is_empty() {
continue;
}
let expected = source_messages
.get(key)
.unwrap_or_else(|| panic!("{} has extra gettext key {}", locale.code, key));
if message.msgid() != expected.message {
panic!(
"{} has msgid drift for {} ({}): expected {:?}, got {:?}",
locale.code,
key,
expected.const_name,
expected.message,
message.msgid(),
);
}
if message.extracted_comments().trim() != expected.comment {
panic!(
"{} translator comment drift for {} ({}). Run cargo run --manifest-path tools/marketing/update-gettext-catalogs/Cargo.toml from the repository root.",
locale.code, key, expected.const_name,
);
}
let msgstr = message
.msgstr()
.expect("marketing messages should be singular");
if msgstr.trim().is_empty() {
panic!("{} has empty translation for {}", locale.code, key);
}
if locale.code == "en-US" && msgstr != expected.message {
panic!("en-US msgstr must match descriptor source for {}", key);
}
let actual_placeholders = extract_placeholders(msgstr);
if actual_placeholders != expected.placeholders {
panic!(
"{} placeholder mismatch for {}: expected {:?}, got {:?}",
locale.code, key, expected.placeholders, actual_placeholders,
);
}
if !seen.insert(key.to_owned()) {
panic!("{} has duplicate gettext key {}", locale.code, key);
}
}
for key in source_messages.keys() {
if !seen.contains(key) {
panic!("{} is missing gettext key {}", locale.code, key);
}
}
let mo_path = generated_dir.join(format!("{}.mo", locale_file_stem(locale.code)));
polib::mo_file::compile_from_po(&po_path, &mo_path)
.unwrap_or_else(|err| panic!("failed to compile {}: {}", po_path.display(), err));
}
}
fn extract_placeholders(input: &str) -> BTreeSet<String> {
let mut result = BTreeSet::new();
let bytes = input.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'{' {
index += 1;
continue;
}
let start = index + 1;
let Some(end_offset) = input[start..].find('}') else {
index += 1;
continue;
};
let end = start + end_offset;
let candidate = &input[start..end];
if is_placeholder_name(candidate) {
result.insert(candidate.to_owned());
}
index = end + 1;
}
result
}
fn is_placeholder_name(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return false;
}
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn write_generated_i18n(path: &Path) {
let mut output = String::new();
output.push_str("// SPDX-License-Identifier: AGPL-3.0-or-later\n");
output.push_str("// @generated by fluxer_marketing/build.rs\n\n");
output.push_str("#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]\n");
output.push_str("pub enum Locale {\n");
for locale in LOCALES {
output.push_str(&format!("\t{},\n", locale.variant));
}
output.push_str("}\n\n");
output.push_str("impl Locale {\n");
output.push_str("\tpub const DEFAULT: Self = Self::EnUs;\n");
output.push_str("\tpub const ALL: &'static [Self] = &[\n");
for locale in LOCALES {
output.push_str(&format!("\t\tSelf::{},\n", locale.variant));
}
output.push_str("\t];\n");
output.push_str("\tpub const fn code(self) -> &'static str {\n\t\tmatch self {\n");
for locale in LOCALES {
output.push_str(&format!(
"\t\t\tSelf::{} => {:?},\n",
locale.variant, locale.code
));
}
output.push_str("\t\t}\n\t}\n");
output.push_str("\tpub const fn is_rtl(self) -> bool {\n\t\tmatch self {\n");
for locale in LOCALES {
output.push_str(&format!(
"\t\t\tSelf::{} => {},\n",
locale.variant, locale.rtl
));
}
output.push_str("\t\t}\n\t}\n");
output.push_str("\tpub const fn catalog_bytes(self) -> &'static [u8] {\n\t\tmatch self {\n");
for locale in LOCALES {
output.push_str(&format!(
"\t\t\tSelf::{} => include_bytes!(concat!(env!(\"OUT_DIR\"), \"/i18n/{}.mo\")),\n",
locale.variant,
locale_file_stem(locale.code),
));
}
output.push_str("\t\t}\n\t}\n");
output.push_str("}\n");
fs::write(path, output).expect("failed to write generated i18n Rust");
}
const BUNDLED_FAMILIES: &[&str] = &["FluxerSans", "FluxerMono"];
const BUNDLED_WEIGHTS: &[u64] = &[400, 500, 600, 700];
const EXPECTED_FACE_COUNT: usize = 16;
struct Face {
css_family: String,
weight: u64,
style: String,
source: String,
}
struct Asset {
name: String,
content_type: &'static str,
}
fn build_fonts(manifest_dir: &Path, out_dir: &Path) {
println!("cargo:rerun-if-changed=../packages/fonts/manifest.json");
println!("cargo:rerun-if-changed=../packages/fonts/css/locale-fallbacks.css");
println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerSans");
println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerMono");
println!("cargo:rerun-if-changed=../packages/fonts/NOTICE.md");
println!("cargo:rerun-if-changed=../packages/fonts/LICENSE-IBM-PLEX.txt");
let package_dir = manifest_dir.join("../packages/fonts");
let fonts_dir = out_dir.join("static").join("fonts");
let _ = fs::remove_dir_all(&fonts_dir);
fs::create_dir_all(&fonts_dir).expect("failed to create generated font dir");
let mut assets = Vec::new();
let notice = emit_asset(
&fonts_dir,
&package_dir.join("NOTICE.md"),
"text/plain; charset=utf-8",
&mut assets,
);
let plex_license = emit_asset(
&fonts_dir,
&package_dir.join("LICENSE-IBM-PLEX.txt"),
"text/plain; charset=utf-8",
&mut assets,
);
let faces = select_faces(&package_dir);
assert_eq!(
faces.len(),
EXPECTED_FACE_COUNT,
"packages/fonts no longer offers the {} Latin-core faces fluxer_marketing renders; \
reconcile BUNDLED_FAMILIES/BUNDLED_WEIGHTS with the manifest",
EXPECTED_FACE_COUNT
);
let mut stylesheet = String::from("/* SPDX-License-Identifier: AGPL-3.0-or-later */\n");
stylesheet.push_str(
"/* @generated by fluxer_marketing/build.rs from packages/fonts. Do not edit by hand. */\n\n",
);
stylesheet.push_str(&format!(
"/*\n\
\x20* IBM Plex is licensed under the SIL Open Font License 1.1.\n\
\x20* The IBM Plex faces below are Modified Versions renamed to \"Fluxer Sans\", so OFL\n\
\x20* clause 3 requires the disclosure to travel with them. It is served beside them:\n\
\x20* ./{notice}\n\
\x20* ./{plex_license}\n\
\x20*\n\
\x20* Every url() below is relative, so it resolves against this stylesheet's own\n\
\x20* directory. That keeps the sheet correct under any FLUXER_MARKETING_BASE_PATH\n\
\x20* without the build having to know the runtime base path.\n\
\x20*/\n"
));
for face in &faces {
let source = package_dir.join("files").join(&face.source);
let file = emit_asset(&fonts_dir, &source, "font/woff2", &mut assets);
stylesheet.push_str(&format!(
"@font-face {{\n\
\tfont-family: '{}';\n\
\tsrc: url('{file}') format('woff2');\n\
\tfont-weight: {};\n\
\tfont-style: {};\n\
\tfont-display: swap;\n\
}}\n",
face.css_family, face.weight, face.style
));
}
let fallbacks_path = package_dir.join("css").join("locale-fallbacks.css");
let fallbacks = fs::read_to_string(&fallbacks_path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", fallbacks_path.display()));
stylesheet.push('\n');
stylesheet.push_str(strip_generated_banner(&fallbacks));
let stylesheet_name = write_hashed(
&fonts_dir,
"fonts.css",
stylesheet.as_bytes(),
"text/css; charset=utf-8",
&mut assets,
);
write_font_asset_table(out_dir, &stylesheet_name, &assets);
}
fn strip_generated_banner(css: &str) -> &str {
let mut rest = css;
while let Some(line_end) = rest.find('\n') {
let line = rest[..line_end].trim();
if line.is_empty() || (line.starts_with("/*") && line.ends_with("*/")) {
rest = &rest[line_end + 1..];
} else {
break;
}
}
rest
}
fn select_faces(package_dir: &Path) -> Vec<Face> {
let manifest_path = package_dir.join("manifest.json");
let raw = fs::read_to_string(&manifest_path).unwrap_or_else(|err| {
panic!(
"failed to read {}: {err}. packages/fonts is generated by \
`python3 tools/fonts/build_fonts.py`.",
manifest_path.display()
)
});
let manifest: serde_json::Value =
serde_json::from_str(&raw).expect("failed to parse packages/fonts/manifest.json");
let families = manifest["families"]
.as_array()
.expect("packages/fonts/manifest.json has no families array");
let mut faces = Vec::new();
for wanted in BUNDLED_FAMILIES {
let family = families
.iter()
.find(|family| family["id"].as_str() == Some(wanted))
.unwrap_or_else(|| panic!("packages/fonts/manifest.json has no family {wanted}"));
assert_eq!(
family["latinCore"].as_bool(),
Some(true),
"{wanted} is no longer a Latin-core family; it would need unicode-range gating"
);
let css_family = family["cssFamily"]
.as_str()
.unwrap_or_else(|| panic!("{wanted} has no cssFamily"))
.to_owned();
for face in family["faces"]
.as_array()
.unwrap_or_else(|| panic!("{wanted} has no faces array"))
{
let weight = face["weight"].as_u64().expect("face has no weight");
if !BUNDLED_WEIGHTS.contains(&weight) {
continue;
}
assert!(
face["unicodeRange"].is_null(),
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
face["file"]
);
faces.push(Face {
css_family: css_family.clone(),
weight,
style: face["style"]
.as_str()
.expect("face has no style")
.to_owned(),
source: face["file"].as_str().expect("face has no file").to_owned(),
});
}
}
faces
}
fn emit_asset(
fonts_dir: &Path,
source: &Path,
content_type: &'static str,
assets: &mut Vec<Asset>,
) -> String {
let bytes =
fs::read(source).unwrap_or_else(|err| panic!("failed to read {}: {err}", source.display()));
let file_name = source
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_else(|| panic!("{} has no file name", source.display()));
write_hashed(fonts_dir, file_name, &bytes, content_type, assets)
}
fn write_hashed(
fonts_dir: &Path,
file_name: &str,
bytes: &[u8],
content_type: &'static str,
assets: &mut Vec<Asset>,
) -> String {
let (stem, extension) = file_name
.rsplit_once('.')
.unwrap_or_else(|| panic!("{file_name} has no extension to hash around"));
let digest = Sha256::digest(bytes);
let hash: String = digest
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect();
let name = format!("{stem}.{hash}.{extension}");
fs::write(fonts_dir.join(&name), bytes)
.unwrap_or_else(|err| panic!("failed to write {name}: {err}"));
assets.push(Asset {
name: name.clone(),
content_type,
});
name
}
fn write_font_asset_table(out_dir: &Path, stylesheet_name: &str, assets: &[Asset]) {
let mut generated = String::from(
"// @generated by fluxer_marketing/build.rs from packages/fonts. Do not edit by hand.\n\n",
);
generated.push_str(&format!(
"/// File name of the content-hashed `@font-face` stylesheet, relative to `/static/fonts/`.\npub const STYLESHEET_FILE_NAME: &str = {stylesheet_name:?};\n\n"
));
generated.push_str("/// `(file name, content type, bytes)` for everything served under `/static/fonts/`.\npub static ASSETS: &[(&str, &str, &[u8])] = &[\n");
for asset in assets {
generated.push_str(&format!(
" ({:?}, {:?}, include_bytes!(concat!(env!(\"OUT_DIR\"), \"/static/fonts/{}\"))),\n",
asset.name, asset.content_type, asset.name
));
}
generated.push_str("];\n");
fs::write(out_dir.join("static").join("fonts.rs"), generated)
.expect("failed to write generated font asset table");
}
fn build_tailwind(manifest_dir: &Path, out_dir: &Path) {
let output_dir = out_dir.join("static");
fs::create_dir_all(&output_dir).expect("failed to create generated static dir");
let input = manifest_dir.join("src/styles/app.css");
let output = output_dir.join("app.css");
let candidates = [
manifest_dir.join("node_modules/.bin/tailwindcss"),
manifest_dir.join("../node_modules/.bin/tailwindcss"),
];
let cli = candidates
.iter()
.find(|candidate| candidate.exists())
.unwrap_or_else(|| {
panic!(
"tailwindcss CLI not found. Expected one of: {}",
candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", "),
)
});
let status = Command::new(cli)
.arg("-i")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--minify")
.arg("--cwd")
.arg(manifest_dir)
.status()
.expect("failed to run tailwindcss");
if !status.success() {
panic!("tailwindcss failed with status {}", status);
}
}
fn locale_file_stem(code: &str) -> String {
code.replace('-', "_")
}
@@ -1,580 +0,0 @@
---
title: "How I built Fluxer, a Discord-like chat app"
slug: "how-i-built-fluxer-a-discord-like-chat-app"
description: "Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities."
author: "Hampus Kraft"
published_at: "2026-01-24T14:00:00Z"
updated_at: "2026-05-24T12:00:00Z"
feature_image: "/blog/assets/how-i-built-fluxer-cover-1280.jpg"
feature_image_alt: "How I built Fluxer, a Discord-like chat app"
source_url: "https://fluxer.app/blog/how-i-built-fluxer-a-discord-like-chat-app"
tags:
- "News"
---
> [Discord will require a face scan or ID for full access next month](https://www.theverge.com/tech/875309/discord-age-verification-global-roll-out)
>
> Age verification for all.
>
> Source: The Verge / Stevie Bonifield
I'm Hampus Kraft, a 23-year-old software developer from Sweden, nearing completion of my BSc in Computer Engineering at KTH Royal Institute of Technology. You can find my LinkedIn page [here](https://www.linkedin.com/in/hampuskraft/). In Sweden, our resident and company registration databases are [public records](https://www.allabolag.se/foretag/fluxer-platform-ab/brandbergen/datacenters/2KJCA7DI5YDLG).
Ever since the pandemic hit in 2020, while I was still in senior high school, I've been fascinated by Discord, the instant messaging and VoIP chat app that became the default home for so many online communities.
Fluxer is the community chat app I kept coming back to for five years: familiar, free and open source (AGPLv3), and built so people can run their own instance.
## Fluxer in 60 seconds
<aside class="blog-definition-card">
<div class="blog-definition-card__term">
<span>flux</span>
<span>/flŭks/</span>
</div>
<div class="blog-definition-card__part">noun</div>
<ol class="blog-definition-card__definitions">
<li>A continuing movement, especially in large numbers of things.</li>
<li>Constant or frequent change; fluctuation.</li>
</ol>
<p class="blog-definition-card__example">"Flux capacitor ... fluxing!" (Back to the Future)</p>
</aside>
Why use Fluxer when Discord is free and works well?
If Discord does what you need, and you're fine relying on a closed, investor-driven app that has reportedly [filed confidential IPO paperwork](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/), then you may not need Fluxer. Fluxer is for people who want an open, self-hostable alternative.
> [Discord's IPO could happen in March | TechCrunch](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/)
>
> Discord reportedly filed confidential IPO paperwork and has pinned its hopes on a debut in March.
>
> Source: TechCrunch / Julie Bort
Fluxer is for people who want a different model: free, open source, self-hostable software, with an optional hosted instance run by an independent European company that helps pay for the open source work. You shouldn't have to give up the basics you like just to leave Discord: Fluxer keeps the familiar shape of modern community chat while making the software open and self-hostable.
Fluxer can succeed without Discord getting worse. Discord's network effect is hard to beat head-on, so I'm starting where switching already makes sense: technical users and communities that value control and openness, and want software they can run on their own terms. You don't need to be technical for Fluxer to be yours: many people prefer a European-owned instance that has clearer reasons to treat users well.
Fluxer is free, open source, and self-hostable. The public code should be useful to people who run their own instance, not only to Fluxer.app, and no community should have to depend on one company surviving. The hosted instance should fund hosting and development work that also helps the open source project:
- Fluxer.app is the hosted instance. It's free to use, and Plutonium gives hosted users higher limits while helping pay for hosting, support, and open source development. There was also a limited-time lifetime Visionary plan for early supporters; it sold out at around 1,000 copies before I stopped it.
- [Donations](/donate) are open for people and organisations who self-host Fluxer, or who want the open source project to keep improving.
- For people who self-host and want a closer community around it, I plan to offer a one-time Operator Pass at $199 or €199. It helps fund the open source work and gives self-hosters a smaller place to get help from other self-hosters and the Fluxer team, share ideas, and make feedback heard before it gets buried on GitHub.
- Managed hosting comes later. Right now, the priority is making self-hosting reliable first.
If the hosted free tier limits are too tight, you can always run your own instance. Fluxer will keep the software free of feature paywalls, licence key checks, upgrade-for-quota gates, and [SSO tax](https://sso.tax/).
## Now, my backstory
### 2017 to 2020
I started using Discord in 2017, and my interest only grew. By January 2019, I'd joined Discord Testers, their crowd-sourced bug reporting programme, and by April 2019 I'd earned enough XP to unlock the Bug Hunter badge. Around the same time, I was accepted into Discord's HypeSquad Events programme, though I never got the chance to represent Discord at any events.
### 2020 to 2022
When the pandemic hit, studying from home gave me more time to learn. Until then, I'd treated programming casually, but I kept returning to the project.
During my senior high school years, I worked on early prototypes of what is now Fluxer. My final graduation project became that prototype, plus a technical report on what I'd learned from studying Discord's technical blog posts and architecture and how I applied those ideas in practice. When I graduated in summer 2022, I received a small $200 scholarship from the school, and the title "Web Guru of the Year."
### 2022 to 2023
I've been a student at [KTH Royal Institute of Technology](https://www.kth.se/en) since autumn 2022. By summer 2023, I decided to focus more actively on Fluxer, but my studies still took up much of my time, so progress came in bursts.
In October 2022, I co-authored a medium-severity bug bounty report for a permission bypass vulnerability and submitted it to Discord's security team. The report earned my co-author and me a shared award of $1,500.
While studying, I also led web development for a popular Minecraft: Bedrock Edition server with nearly 6 million registered players. I built and ran the web systems, working on API design, security, billing, and reliability. It taught me how to keep large services running, and I still help maintain those codebases when needed.
### 2023 to 2024
In July 2023, I released the first private alpha of Fluxer to a few dozen testers. Since then, I've changed the tech stack many times before settling on what I run today.
The current stack is heavily inspired by Discord and, to some extent, WhatsApp. It uses boring, proven pieces for realtime delivery, internal messaging, fast shared state, and durable persistence. I did try simpler alternatives because I wanted less complexity, but I kept reinventing the wheel for problems that proven technologies already solve well.
Those changes, along with continued research into Discord's architecture, shaped the stack. I dug through Reddit and Hacker News posts from Discord employees, public conversations with their engineers, and many blog posts and postmortems, then tested the ideas in code to see where they held up. At the same time, my KTH studies in computer science, network security, software testing, and distributed systems gave me the background to judge the trade-offs.
My degree programme also puts a lot of weight on applying knowledge in practice. In spring 2024, I joined a project course where we delivered an internal tool for Giesecke+Devrient, an international security technology company.
### 2025
In January 2025, I grew wary of how Discord handled my personal data and where the app was headed. I built [Discorch](https://discorch.org/), a tool that guides you through Discord's undocumented privacy request process to bulk delete messages. It produces a CSV plus instructions for contacting Discord's privacy team to request deletion.
As more people used it, Discord kept changing the process and eventually restricted it, including blocking deletion of your own DM messages through this route because you can still delete those manually. The route now only works for servers and groups you've left.
In spring 2025, I reported a security vulnerability to Discord and was awarded $2,000. Later that summer, I registered [Fluxer Platform AB](https://www.allabolag.se/foretag/fluxer-platform-ab/brandbergen/datacenters/2KJCA7DI5YDLG), a Swedish limited liability company, which required a minimum deposit of $2,000.
<figure class="blog-media blog-media--gif">
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-freebie-poster.jpg">
<source src="/blog/assets/tenor-freebie.webm" type="video/webm" />
<source src="/blog/assets/tenor-freebie.mp4" type="video/mp4" />
</video>
<figcaption>Maeby from Arrested Development: "Well, that was a freebie."</figcaption>
</figure>
By summer 2025, I'd finished most of my university courses and spent the summer writing my bachelor's thesis with a fellow student for the supportive and welcoming Intelligent Heart Technology Lab at KTH. The thesis was [published in September 2025](https://urn.kb.se/resolve?urn=urn%3Anbn%3Ase%3Akth%3Adiva-371447).
Between September and December 2025, I worked day and night to make Fluxer ready for public release. Getting the architecture, feature set, and web client ready for real users by myself was the hardest work I'd done.
On 25 October 2025, I opened the first private beta because I needed funding to keep going. It was a small group of around 30 testers who could invite people they knew, and a handful bought Visionary at $299 each while I worked towards feature completion.
### 2026
On 2 January 2026, I opened Fluxer up to everyone. I tried a [Show HN](https://news.ycombinator.com/item?id=46468725), which didn't go far, and launched on [Product Hunt](https://www.producthunt.com/products/fluxer/launches/fluxer), which gained a little more traction. A few more people bought Visionary, enough to keep me going, and I started polishing things towards a self-hosting release.
There was effectively no marketing. Then a Bluesky post after Discord's IPO plans leaked travelled further than expected, and Discord's age-verification announcement on 9 February 2026 changed the scale of the project overnight. Fluxer grew to about 195,000 users, with a peak of around 11,000 concurrent connections, while the hosted setup was still built for a much smaller load and I was the only full-time engineer.
> After Discord's age-verification announcement, Visionary sold out quickly: around 1,000 copies at $299 each before sales were paused. Visionaries are recognised in-app with a numbered badge that shows how early they supported Fluxer.
Visionary was never about FOMO; I didn't know what was coming. I capped it at 1,000 slots with an expiration date in October 2026 because that seemed far above likely demand. It sold out in a matter of days.
Fluxer uses familiar community-chat patterns and includes the features people expect from this kind of app. In several areas it already does more than the other open source options, and it stays close to classic Discord where that model works.
The team is still tiny, but the day-to-day load is shared now. The goal is still simple: free and open source software, self-hosting without licence-key checks, and a hosted instance whose job is to serve users and fund the open source work.
You can support Fluxer through [Plutonium](/plutonium), a custom [donation](/donate), or issues and PRs in [the GitHub repo](http://github.com/fluxerapp/fluxer). That support goes into hosting, documentation, code review, and the work needed to keep both Fluxer.app and the self-hosted release moving.
> [GitHub - fluxerapp/fluxer: A free and open source instant messaging and VoIP chat app built for friends, groups, and communities.](http://github.com/fluxerapp/fluxer)
>
> A free and open source instant messaging and VoIP chat app built for friends, groups, and communities. - fluxerapp/fluxer
>
> Source: GitHub / fluxerapp
## Fluxer's backend
Discord's backend scaling work is unusually well documented, and their engineering posts have been a big reference point for me. Start here:
- [How Discord Scaled Elixir to 5,000,000 Concurrent Users](https://discord.com/blog/how-discord-scaled-elixir-to-5-000-000-concurrent-users)
- [Using Rust to Scale Elixir for 11 Million Concurrent Users](https://discord.com/blog/using-rust-to-scale-elixir-for-11-million-concurrent-users)
- [How Discord Stores Trillions of Messages](https://discord.com/blog/how-discord-stores-trillions-of-messages)
- [How Discord Reduced WebSocket Traffic by 40%](https://discord.com/blog/how-discord-reduced-websocket-traffic-by-40-percent)
I respect their engineers, even if I'm not a fan of some of Discord's business decisions. Meanwhile, a usable self-hosted alternative still doesn't really exist, and building something good enough to replace what people already use takes an enormous amount of time and energy, since people usually only pay once an app feels close to what they expect.
What kept me going was years of work to understand Discord's architecture in depth: Hacker News and Reddit threads, postmortems and blog posts, relevant courses at KTH, and professional experience along the way. I've also been active in Discord's meta communities for years, alongside fellow enthusiasts, security researchers, and bug hunters, and I've spoken with Discord engineers directly a few times.
That knowledge of Discord's development, and of people's frustrations with it, is what I'm putting to work on an alternative to closed community chat apps. Fluxer should be more than a Discord clone.
### What the backend looks like now
At launch, I described Fluxer as mostly TypeScript with a bit of Erlang. That was accurate at the time. Today Fluxer.app is a set of focused services, because different parts of the app have different needs.
Most of what people touch every day goes through the API. It's a TypeScript service running on Node with [Hono](https://hono.dev/), and it owns the parts of Fluxer that change the most: accounts, authentication, communities, channels, messages, attachments, billing, reports, downloads, unfurls, and the public HTTP API. Cassandra stores durable data on Fluxer.app, Valkey handles fast shared state, and NATS carries internal messages. The Worker uses the same TypeScript stack for jobs that shouldn't slow down a request, such as attachment expiry, CDN purges, search refreshes, billing reconciliation, trust and safety syncs, data exports, and bulk deletion.
The Gateway is where Fluxer stops looking like a normal web app. It's plain Erlang on OTP 28, and it owns the live side of the system: WebSocket connections, sessions, event fanout, presence, guild routing, voice and call state, and push notification delivery. In production it's split into specialised tiers for websocket connections, sessions, guilds, presence, calls, and push. The websocket tier stays stateless, while the stateful tiers own the live data they're responsible for, which makes production rollouts and failures easier to contain.
Media lives in Rust because it's CPU-heavy, memory-sensitive, and exposed to untrusted input. The Media Proxy runs on axum and Tokio and handles uploads, thumbnails, metadata, transforms, external media, static files, and the upload relay. The heavy lifting goes through libvips, libheif, and FFmpeg behind a hand-written C shim, with Rust wrapped around it for memory safety, strict input limits, bounded concurrency, and request coalescing. If a hundred people open the same image, they share one transform instead of making the system do the same work a hundred times.
Rust also sits behind the API for hot paths that benefit from a smaller, more predictable service. In the code that's public today, that means services for users, messages, link unfurling, and Snowflake ID generation, modelled on [the data services Discord built between its API and its database](https://discord.com/blog/how-discord-stores-trillions-of-messages). They use NATS-based RPC, caching, and request coalescing where it helps. Production search runs on Elasticsearch, and self-hosted instances can choose Meilisearch when they want a lighter search backend. The Snowflake service uses the same 2015 epoch and 64-bit layout as Discord's, so IDs sort by time the same way.
NATS carries internal RPC and background jobs, the Gateway calls back into the API when it needs app data, and Valkey handles caching, rate limits, locks, cache invalidation, pub/sub, and small internal queues.
The API is the authority for accounts, permissions, billing, and app data. The Gateway is the authority for live sessions, routing, presence, and real-time delivery.
> The public repository lagged until 15 June 2026 because the growth spike forced urgent anti-abuse and production work while I kept Fluxer.app stable. The sync separated Fluxer.app deployment settings from reusable server code and moved the remaining operator work into the open.
A typical self-hosted deployment is much smaller than the hosted setup. The Kubernetes clustering and role-split tiers described above are specific to Fluxer.app; you shouldn't need to recreate my production cluster to run your own instance. The small setup keeps the app services together and needs only a database, Valkey, and NATS. The goal is Docker images plus a web setup wizard that walks you through configuring an instance. Small instances can run on a Raspberry Pi.
The Electron desktop path starts with custom backends through in-app account switching in the regular client, so you can run your own instance without waiting for full federation.
### Why three languages?
A polyglot backend sounds like a maintenance tax, and it can be. It pays off here because the three languages line up with three very different kinds of work, while the fast-moving majority of the app stays in just one of them.
Most of Fluxer is ordinary app code: accounts, permissions, billing, moderation, settings, REST endpoints. It's I/O-bound and changes constantly, so what matters is how fast one person can move through it. That work lives in TypeScript, end to end. The same language runs the API, the Worker, and the web and desktop clients, and they share types, validation, constants, and even a Discord-compatible Markdown parser (written in Rust, compiled to WebAssembly) across the wire. One person can follow a feature from a button in the client to the row in the database without switching mental models. For a codebase built mostly by one developer, that shared surface is the main reason I can keep moving fast.
The real-time layer is a very different problem: hundreds of thousands of long-lived connections, each needing isolation, supervision, and cheap concurrency. Erlang/OTP was built for exactly that, and trying to rebuild it in a general-purpose language is how you end up living [Virding's First Rule](https://rvirding.blogspot.com/2008/01/virdings-first-rule-of-programming.html) (quoted below). Discord and WhatsApp both proved the model at a scale Fluxer won't reach for years.
The third kind of work is CPU-bound and sensitive to latency and memory: decoding untrusted media, and shielding the database from read amplification. There, a garbage collector and an interpreter get in the way. Rust gives predictable memory, no GC pauses, safe concurrency, and a safe way to wrap the C media libraries. It's also the language [Discord reached for](https://discord.com/blog/using-rust-to-scale-elixir-for-11-million-concurrent-users) when the BEAM VM's garbage collector struggled with large data structures.
So the split is: keep the fast-moving majority in the language that lets me move fastest, and use specialised runtimes only where the work needs them. A small team keeps moving while the parts that need to scale have room to scale.
### Why choose Erlang/OTP?
The Erlang runtime system is designed for distributed, fault-tolerant, soft real-time, highly available, non-stop applications, with hot swapping to change code without stopping the system.
<figure class="blog-media blog-media--video">
<video class="blog-embed-video" controls preload="metadata" poster="/blog/assets/erlang-the-movie-poster.jpg">
<source src="/blog/assets/erlang-the-movie.mp4" type="video/mp4" />
</video>
<figcaption>Hello Mike. Hello Joe. Hello Mike. Hello Robert. Hello Joe. Hello Mike. Hello Robert. Hello Mike. Hello. Looks like we fixed the bug!</figcaption>
</figure>
It was originally developed as proprietary software at Ericsson, a Swedish telecom company, by Joe Armstrong (who wrote his PhD thesis at my university, KTH, [in the year I was born](https://erlang.org/download/armstrong_thesis_2003.pdf)), Robert Virding, and Mike Williams in 1986. It was released as open source in 1998 and is maintained by the OTP unit at Ericsson.
Notable large-scale users include WhatsApp, plus Discord via Elixir (which compiles to bytecode for the same BEAM virtual machine that Erlang runs on). WhatsApp is far larger than Discord: more than 3 billion MAU, largely built on Erlang, with a famously small engineering team.
I tried several real-time architectures before accepting this rule:
> "Any sufficiently complicated concurrent program in another language contains an ad hoc informally-specified bug-ridden slow implementation of half of Erlang."
>
> [Robert Virding](https://rvirding.blogspot.com/2008/01/virdings-first-rule-of-programming.html), co-creator of Erlang
Fluxer's real-time system is heavily inspired by Discord and wire-compatible with enough of Discord's protocol to make porting existing Gateway bots easier. A websocket connection identifies itself, a session process owns that live client state, and guild and presence processes decide which sessions should receive each event. Short disconnects can replay missed events instead of rebuilding the whole initial state, while member and presence updates stay lazy, incremental, and compressed. This is the same direction Discord went when they [cut their WebSocket traffic by 40%](https://discord.com/blog/how-discord-reduced-websocket-traffic-by-40-percent).
A big guild's member list can hold hundreds of thousands of entries that change constantly, and your client only ever wants a small, sorted slice of it. Fluxer keeps the member rows in ETS, but the sorted index behind rank and range reads lives in a Rust NIF, just like the approach Discord described when their BEAM-side sorted member list created too much garbage-collector and allocator pressure. The NIF owns a compact order-statistic tree, so Erlang keeps supervising the guild process while Rust handles the insert/delete/rank/range work that needs predictable memory behaviour.
Kudos to the engineering team at Discord for the inspiration.
### Why choose Cassandra?
> Fluxer.app uses Cassandra in production today. Self-hosting is being designed to support Postgres too, because smaller instances shouldn't have to run the same database setup as the hosted service.
For a long time, ScyllaDB was my database of choice, and Discord also migrated to it after running Cassandra. As of December 2024, though, ScyllaDB is [no longer open source software](https://news.ycombinator.com/item?id=42457680), which is a deal-breaker for me.
<figure class="blog-bsky-embed">
<a class="blog-bsky-card" href="https://bsky.app/profile/jacob.gold/post/3ldmwlqy6gk24" target="_blank" rel="noopener noreferrer">
<span class="blog-bsky-header">
<span class="blog-bsky-author">
<img src="/blog/assets/bsky-jake-gold-avatar.jpg" alt="" width="48" height="48" loading="lazy" decoding="async" />
<span>
<span class="blog-bsky-name">Jake Gold</span>
<span>@jacob.gold</span>
</span>
</span>
</span>
<span class="blog-bsky-text">I completely understand @scylladb.com's challenge with maintaining an open source/open core business model.<br><br>On the other hand, I'm not sure I would have chosen ScyllaDB for Bluesky PBC's infra if there wasn't an open source version.<br><br>So this is unfortunate but I can't blame them for doing what they need to do.</span>
<span class="blog-bsky-link-card">
<img src="/blog/assets/bsky-scylladb-link-thumb.jpg" alt="" width="600" height="314" loading="lazy" decoding="async" />
<span>
<span class="blog-bsky-link-title">Why We're Moving to a Source Available Licence - ScyllaDB</span>
<span>ScyllaDB is moving to a source available licence. Learn why, directly from CEO and co-founder Dor Laor.</span>
</span>
</span>
</a>
</figure>
Cassandra has trade-offs. I keep using it because it fits Fluxer's write-heavy, event-driven design.
First, Cassandra makes it hard to be accidentally inefficient. You have to think upfront about how your data is modelled and queried, because inefficient queries are either impossible or explicitly opt-in through features like [ALLOW FILTERING](https://docs.datastax.com/en/cql-oss/3.3/cql/cql_reference/cqlSelect.html#:~:text=Only%20use%20ALLOW%20FILTERING).
Second, Cassandra prioritises high write throughput, and Fluxer is built to keep reads low. When you send a message, the database does very little: it validates your auth session (a read usually served from cache) and writes the message row. Permissions are answered by the Erlang Gateway over RPC, which keeps an in-memory cache of everything needed to compute permissions in a community (called a guild internally), so a permission check is a fast internal call instead of a query. Clients mostly read from the database when starting a new session to populate initial state; after that, everything stays in sync through the event dispatching system.
Third, operationally, Cassandra avoids a class of problems I've struggled with in traditional RDBMS setups at hosted-service scale. Postgres is the right direction for smaller self-hosted instances because people already know how to run it, but Fluxer.app has different constraints. Cassandra keeps the hosted deployment away from JOIN planning, index tuning, lock-heavy migrations, and many of the schema-change risks that show up under load.
Finally, Cassandra fits Fluxer's message rows. Unfurled links, file attachments, and similar metadata work well as embedded documents. Cassandra gives you rich types like sets and maps, along with user-defined types that can be nested, typed, and stored efficiently, so a single document doesn't need extra table queries or untyped JSON blobs.
Building Fluxer's hosted data model around relational joins doesn't fit the access patterns. The app's already shaped like an event-driven document and key-value system, so a NoSQL database fits the hosted service better.
### You mentioned Postgres for self-hosted instances?
Yes. The persistence layer is being shaped around explicit access patterns rather than ad hoc SQL sprinkled through the app. Cassandra is the hosted production backend today, and the same query model is meant to support Postgres for self-hosted instances.
Because Cassandra behaves like a key-key-value (KKV) store (partition key + clustering key, where the clustering key identifies a specific row within a partition), I already had to design my tables and queries so that every lookup requires the full key, or at least a sufficiently specific leading part of it for SELECT queries.
In Cassandra, secondary indexes are typically implemented at the application level using additional tables optimised for alternative access patterns, maintained on writes via logged batches, with optional denormalisation depending on how important it is to skip an extra read for the full primary row. Materialised views and native secondary indexes exist, but their caveats can make them risky in production.
Given those constraints, the first Postgres backend is careful on purpose: a KV-shaped storage layer keyed like the hosted database, with prefix range queries where listing is needed. It keeps the self-hosted path close to the production data model and leaves room to add relational tables later where they clearly help.
Other database backends can come later, but the priority is one reliable self-hosted path first.
## Fluxer's frontend
Fluxer's web app codebase is complex. The PWA is much better in the canary client than it was at launch. To try the newest client work, use the canary desktop build at [canary.fluxer.app/download](https://canary.fluxer.app/download) or the canary web client at [web.canary.fluxer.app](https://web.canary.fluxer.app).
Canary has fixed hundreds of bugs and adds major voice and video work: screen sharing with audio, text in voice, DM call fixes, a redesigned input and output device system, more mic processing controls, and DeepFilterNet3 noise suppression.
The web app has to handle the details people notice immediately: infinite scrolling, stable scroll position, bounded caches, jumping in time, state reconciliation, unread handling, and Discord-compatible Markdown. Those parts are tedious, but they're the difference between a usable client and a demo.
As of 15 June 2026, the native app, built with Flutter, has moved past Visionary testing. The iOS app is in a limited TestFlight beta with a small group of Plutonium subscribers, since TestFlight slots are capped, and the Android app is available now as an APK from [github.com/fluxerapp/flutter_client](https://github.com/fluxerapp/flutter_client). Both are early alphas, so expect missing features and bugs while they move towards a public release. The mobile app code is open source.
Unlike Discord, Fluxer welcomes client changes: custom themes, non-malicious account automation, third-party clients, and anything else that tickles your fancy. It's all open source anyway, so you're welcome to send changes that fit the goals of the project. Just open an issue first to discuss it :)
### Electron? Right to jail, right away
Fluxer currently uses Electron. I know, I know. I'm not too happy about it either. Tauri isn't mature enough for what I need yet, and I ran into hurdles there that I didn't have in Electron. In the spirit of choosing boring technology, Electron unfortunately wins. A lot of apps give Electron a bad name, but it doesn't have to be that way.
Tauri uses the system webview. That sounds great on paper, but it leaves the desktop app at the mercy of whatever runtime the OS provides, with whatever bugs come with it. And when those bugs happen, you can't ship a fix by updating your runtime, because the runtime is tied to OS updates.
I'll look at Tauri again when there's a mature, supported option for shipping a consistent runtime with it, like CEF ([cef-rs](https://github.com/tauri-apps/cef-rs)).
> [GitHub - tauri-apps/cef-rs](https://github.com/tauri-apps/cef-rs)
>
> Contribute to tauri-apps/cef-rs development by creating an account on GitHub.
>
> Source: GitHub / tauri-apps
For most people, Electron is an acceptable choice because it gives Fluxer a consistent desktop runtime while sharing the same client base as the web app.
I believe in the web platform for this kind of application. It's mature, cross-platform, well understood, and good at complex interfaces that need to run on many devices. It's also the platform I know best, which is part of why Fluxer could ship with a PWA from day one.
The first PWA had mobile issues, but it worked, and canary has improved it a lot. You can see that work today while the native Flutter app moves through its iOS TestFlight beta and open Android APK toward a public release.
The best mobile client is native, which is why the Flutter iOS and Android app comes first. Flutter can target desktop later, and that may become a better option than Electron for low-end devices down the line. Until then, the priority is simple: ship a reliable client that behaves consistently across web and desktop.
### The LLMephant in the room
I've used LLMs sparingly as a troubleshooting and planning aid on Fluxer. Fluxer isn't vibe-coded, and describing it that way would be false and unfair to years of work. I'd never outsource my judgement to an LLM; limited use was a necessary compromise while I was carrying too much of the project alone, when the alternative was abandoning Fluxer or raising venture capital to ship it. I'm strongly opposed to the venture-capital path for Fluxer, and open source contributions now mean I no longer need to handle every part of the project by myself. Fluxer's core predates LLMs becoming normal in software development. The architecture, safety decisions, technical choices, and product direction are mine, and I only ship changes I understand and can explain. I expect the same from external contributors.
## Who is building Fluxer now?
Fluxer is no longer a one-person project, but the team is still small for something this big.
I'm still the only person who has worked across the whole codebase from the beginning, and I spend most of my time building the app, working on backend architecture and reliability, and handling production operations. Support, trust and safety, abuse prevention, billing, accounting, and internal tools have all spent time on my desk too.
Around that, someone is helping with direction and safety: where Fluxer goes, report review, policy work, and safety tooling. An engineer focused on systems works on scaling, monitoring, CDN work, internal tooling, and voice server deployment. The native iOS and Android app has a paid contributor focused on the mobile app, and a newer team member is taking support and billing load off my plate.
## Frequently asked questions
### What about the open web?
Chat apps have swallowed useful knowledge that used to live on the public web, then made it invisible to search engines, archives, and people who aren't logged in.
> [Discord seeks to solve a problem that it created | TechCrunch](https://techcrunch.com/2025/05/23/discord-seeks-to-solve-a-problem-that-it-created/)
>
> Conversations on Discord can be hard to follow. Discord SVP Peter Sellis proposes making forum-like features, or using AI summaries.
>
> Source: TechCrunch / Amanda Silberling
> With LLMs, Sellis said, Discord could take a long, meandering conversation and turn it into "something that could be more sharable and syndicated across the web." However, he said that he and his team hadn't "seen a solution that we feel great about yet."
Fluxer disagrees with Discord's starting point here. LLMs shouldn't turn people's "meandering conversations" into web content. If something becomes public, it should be because a person or community chose to publish it.
This feature uses the web itself: public pages with stable URLs, server-rendered posts, clear titles, search indexing, archivable pages, RSS and Atom feeds, and links people can share anywhere. People's posts remain their posts.
Publishing stays opt-in, for communities that benefit from putting parts of their forum-style spaces on the open web: open source projects, developer communities, modding groups, creator communities, research groups, support forums, and any other space where public answers are meant to be searchable and useful later. Private chat stays private.
### This looks a lot like Discord!
Yes, on purpose. Community chat has a shape people already understand: server list, channel list, message timeline, member list, composer, and voice controls. Discord didn't invent that; it built on patterns already familiar from IRC, TeamSpeak, Slack, forums, game launchers, and older community tools.
Fluxer values a clear first screen over novelty. A Discord-like app needs to feel easy to understand for people coming from Discord. Familiarity makes switching less painful, keeps muscle memory intact, and lets Fluxer focus on the parts people actually care about: ownership, openness, privacy, performance, self-hosting, federation, safety, and who the app answers to.
Copyright works the same way. It protects specific expression, not the general idea of putting servers, channels, messages, members, and voice controls where people expect them. In the US, [17 U.S.C. § 102(b)](https://www.copyright.gov/title17/92chap1.html#102) says copyright doesn't cover ideas, procedures, systems, or methods of operation. [TRIPS Article 9(2)](https://www.wto.org/english/docs_e/legal_e/trips_e.htm) says the same internationally. EU software law says the ideas and principles behind interfaces aren't protected by copyright, and the CJEU held in [SAS Institute v World Programming](https://curia.europa.eu/jcms/upload/docs/application/pdf/2012-05/cp120053en.pdf) that functionality isn't expression. Nobody owns the basic pattern of a usable community chat interface.
<figure class="blog-media blog-media--image">
<picture>
<source type="image/avif" srcset="/blog/assets/discord-ui-revolutionary-640.avif 640w, /blog/assets/discord-ui-revolutionary-960.avif 960w, /blog/assets/discord-ui-revolutionary-1280.avif 1280w, /blog/assets/discord-ui-revolutionary-1881.avif 1881w" sizes="(max-width: 768px) 100vw, 768px" />
<source type="image/webp" srcset="/blog/assets/discord-ui-revolutionary-640.webp 640w, /blog/assets/discord-ui-revolutionary-960.webp 960w, /blog/assets/discord-ui-revolutionary-1280.webp 1280w, /blog/assets/discord-ui-revolutionary-1881.webp 1881w" sizes="(max-width: 768px) 100vw, 768px" />
<source type="image/png" srcset="/blog/assets/discord-ui-revolutionary-640.png 640w, /blog/assets/discord-ui-revolutionary-960.png 960w, /blog/assets/discord-ui-revolutionary-1280.png 1280w, /blog/assets/discord-ui-revolutionary-1881.png 1881w" sizes="(max-width: 768px) 100vw, 768px" />
<img class="blog-embed-image" src="/blog/assets/discord-ui-revolutionary-1280.png" alt="A comparison image showing similar community chat layouts across older apps." loading="lazy" decoding="async" />
</picture>
<figcaption>Source: <a href="https://imgur.com/whenever-someone-says-discords-ui-is-revolutionary-b5kdlfM" target="_blank" rel="noopener noreferrer">Imgur</a>, "Whenever someone says Discord's UI is revolutionary."</figcaption>
</figure>
Changing the layout only because people might compare it to Discord would make the app harder to use. Fluxer changes things where it actually improves the app; forcing people to relearn where messages, channels, servers, and voice calls live just adds friction.
I've tried several different takes on the UX, and they ended up messy. I've also tried newer apps that recreate the Discord experience while trying too hard to feel new, and so have many people I've spoken to. They're harder to use than they need to be.
Is Discord's UX perfect? Of course not, and parts of it have got worse over time. But the older Discord model is still one of the easiest ways to understand a modern community chat app. Most open source options fail because they neither feel familiar enough to switch to nor complete enough to replace what people already use. The result may be principled, but it feels worse to most people. Classic Discord also feels nostalgic for many of us longtime users, from before the redesigns. Fluxer can improve the details while keeping a working mental model people already understand.
Fluxer will go its own way over time. As self-hosting, public web publishing, multi-backend support, federation, moderation tooling, voice and video, and client customisation get better, Fluxer becomes more clearly its own thing. It starts from something familiar and changes where it has a better answer.
Fluxer also lets you fully customise your client's look with custom CSS, and UI experiments are welcome when they improve usability.
### Why the name Fluxer, and why Plutonium?
The honest origin is simple: Back to the Future is my favourite film series.
In the film, the flux capacitor is the thing that makes time travel possible. The DeLorean needs to hit 88 mph and draw 1.21 gigawatts, first from a plutonium-powered reactor. Fluxer and Plutonium are both little nods to that. Yes, naming your subscription after fictional nuclear fuel is a bit silly, which is part of the fun.
There's a more literal meaning too. Flux means change, movement, fluctuation. Chat is constantly moving: new messages, new people, new context, old conversations becoming something else over time. A chat app is almost never in a fixed state.
The logo comes from the same idea. The approximately-equal sign fits something in flux: recognisable, but always changing.
The name also points at the broader goal: a better path for community chat.
### How long did Fluxer take to build?
The first version started around 2020, while I was still in high school during the pandemic. It became my graduation project, then a long-running side project tested by friends while I studied and worked on other things.
The final run-up to public release began after I finished my bachelor's thesis in summer 2025. The private beta opened in October 2025, and Fluxer was publicly released on 2 January 2026.
The launch was quiet at first. Then Discord IPO news and Discord's age-verification announcement on 9 February 2026 put Fluxer in front of far more people than I expected. The hosted instance grew to around 195,000 users, with a peak of about 11,000 concurrent connections, while the team was still effectively one full-time engineer. The team is larger now, but still small.
### Why build a chat app at all?
Community chat has been my focus for years. I've been deep in Discord's world since 2017: testing, reading engineering posts, following architecture discussions, reporting bugs, and talking to people who understand that world.
Fluxer started as a technical challenge but became about more than code over time. I wanted a modern community chat app without one company controlling the app, data, network, and business model. Self-hosters, technical communities, open source projects, and creators all need software they can trust, customise, and move away from.
Fluxer preserves the parts of this kind of chat that work, makes the software free and open source, and builds towards decentralisation so the future is no longer decided by one company.
### I've built a Discord bot. Will it work on Fluxer?
Partly. Fluxer's HTTP API and WebSocket Gateway API are heavily inspired by Discord's and, in many cases, directly wire-compatible with it. That means you can reuse existing Discord libraries and abstractions in many languages.
For example, you can use the [core libraries from discord.js](https://discord.js.org/docs/packages/core/main) directly. It's a bit more low-level, but enough for many bots, and [the Fluxer API docs](https://docs.fluxer.app/) guide you through a quick start with this approach. Adapting existing Discord libraries can also work. Or build your own community-maintained Fluxer SDK and email me ([hampus@fluxer.app](mailto:hampus@fluxer.app)) to get it featured in the docs, or submit a pull request in [the GitHub repository](https://github.com/fluxerapp/fluxer).
> The API and self-hosting docs are being cleaned up in public after the 15 June 2026 repository sync. If something is unclear, file an issue or email me.
Slash commands and similar features are still coming. If you want that work to happen sooner, Plutonium and donations help buy the time to build and publish it properly.
### Why do attachments expire?
Because storage costs money, and Fluxer has no venture capital funding.
Large media adds up quickly. If every image, video, and random file stayed forever, the hosted instance would become free cloud storage with a chat app attached, which breaks the economics of a bootstrapped app trying to stay independent.
Expiry is based on file size. Small files last much longer, with the smallest lasting about three years, and files can be renewed a little when they're accessed. The exact behaviour is documented in [how attachment expiry works](/help/attachment-expiry).
Attachment expiry keeps storage under control and also protects privacy; most attachments have no reason to live forever. If you self-host Fluxer, the policy is yours to configure, including whether expiry is enabled and how much storage your instance allows.
### Will Fluxer become like Discord?
Fluxer's structure is meant to keep exits open. Fluxer.app funds the shared codebase through hosted revenue, including Plutonium. Its role is to improve the software for people who use the hosted instance and people who run it elsewhere. The software is intended to be self-hostable, and the long-term plan includes self-hosting, data portability, multiple backends in one client, and federation.
Open source makes trust easier because running your own instance is possible.
### Why monetise Fluxer?
Because running a real-time chat app costs real money, and so does maintaining the open source version people can run themselves. Compute, storage, bandwidth, monitoring, safety tooling, payment processing, support, and people's time all have to be paid for somehow.
The question is who the app has to answer to. Venture capital can make an app look free for a while, but it usually comes with pressure to grow faster, extract more, and eventually answer to investors before users.
The model is to keep a generous free tier, charge for higher limits on the hosted instance, accept donations from people who want Fluxer to keep going, and keep self-hosting free. Hosted revenue should buy time to improve the shared codebase, with public development at the centre of the work.
### Is Fluxer free and open source?
Yes. Fluxer is free and open source. The public repository is [github.com/fluxerapp/fluxer](https://github.com/fluxerapp/fluxer), and the goal is that the hosted Fluxer.app service and self-hosted instances share the same useful base. The hosted instance pays for the work and tests it with real traffic, while instance operators get the tools they need to run Fluxer themselves.
Until 15 June 2026, the public repository lagged because the growth spike forced urgent anti-abuse and production work while I kept Fluxer.app stable. The sync separated Fluxer.app deployment settings from instance settings other people can use, and moved the remaining operator work into the open.
Pull requests are open, and Fluxer is open by default.
### Will self-hosting cost money?
No. Running your own Fluxer instance requires no licence key, paid tier, or special enterprise unlock. You're responsible for hosting, uptime, moderation, safety, and legal duties, but the software itself is free to run.
The Operator Pass is planned for when the docs and setup guides are solid. It's a $199 or €199 one-time purchase for self-hosters who want to support the open source work and join the Operators community: a smaller place to get help from other self-hosters and the Fluxer team, share ideas, and make feedback heard before it gets buried on GitHub. The docs stay public.
### Federation?
Federation remains a major goal, and the order matters. People who run and use Matrix complain about specific things: large federated rooms can be slow and expensive to join, presence and device-list updates can create surprising background load, federation failures can leave rooms or encrypted messages half-working, and moderation gets harder when abuse, media, bans, and bridges cross instance boundaries.
The self-hosting path starts with account switching for custom backends in the Electron desktop app. The next client step is simultaneous connections to multiple backends without making you switch between workspaces. The goal is one client that can show several instances together before Fluxer has the unified identity and authentication model that full federation needs.
True federation can come after that, with OAuth2-based authentication against remote instances and a clearer model for where identity and data live.
### How does moderation work?
Each Fluxer instance is responsible for its own moderation. Fluxer Platform AB operates the hosted Fluxer.app instance, so that's where the company is responsible for reports, safety enforcement, legal compliance, and abuse prevention.
The 15 June 2026 repository sync also separated moderation, abuse-prevention, and operator tooling from Fluxer.app-specific settings. Instance operators should get the full toolset in a form they can actually use, with the remaining work happening in public.
Fluxer Platform AB is also a registered electronic service provider (ESP) with access to the CyberTipline Reporting API from the [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/home).
Once federation exists, moderation stays local to the instance enforcing it. A ban on one instance stays local unless other instances choose to share or honour that signal.
### Why do you follow local laws in my country or state?
For self-hosted instances, this is the responsibility of the person running the instance. If you run your own instance, you decide where you provide service and what legal risk you accept.
For the hosted Fluxer.app instance, Fluxer has to follow the law where it serves traffic. Some recent age-verification laws are invasive enough that I'd rather restrict access than collect government IDs or biometric data from everyone.
As of 24 May 2026, Fluxer restricts NSFW access in the United Kingdom and Brazil. In the UK, Fluxer can offer a less invasive optional adult check through a $0.00 credit card authorisation. Brazil currently lacks a private enough path I'm comfortable with. Mississippi requires age verification for access to the whole service, so the hosted Fluxer.app instance blocks access from Mississippi instead.
The current details are kept in [regional restrictions](/help/regional-restrictions) and [minimum age requirements](/help/minimum-age).
### Where does Fluxer run?
Fluxer.app currently runs in US East on Vultr. That location gives good connectivity to both North America and Europe, which helps message loading and real-time delivery.
I'm paying attention to the global legal and data issues. The US CLOUD Act and questions about where data lives are real. Longer term, federation makes it possible to separate accounts and communities by region, including EU-only hosting, and moving more systems to Europe remains an option.
Voice and video are already more distributed. Current RTC regions include Sydney, São Paulo, Santiago, Frankfurt, Stockholm, Warsaw, Madrid, Mumbai, Singapore, Seoul, Johannesburg, Newark, Atlanta, Dallas, Seattle, and Los Angeles.
### What happens when Google shuts down Tenor?
Fluxer currently relies on Tenor for GIF search in the app, and Google is shutting down the current Tenor API on 30 June 2026. Fluxer already has KLIPY support in the codebase. KLIPY was built by former Tenor people and is the planned replacement when the current Tenor API stops working.
GIFs are proxied through Fluxer either way, so the privacy promise stays the same: providers don't see your IP address just because you searched for or viewed a GIF through the app.
### End-to-end encryption?
Matrix does offer E2EE, but the complexity of the protocol and its client implementations often comes at the expense of what people actually want.
> [Why We Abandoned Matrix (2024) | Hacker News](https://news.ycombinator.com/item?id=46376201)
>
> Source: Hacker News
Most people want a Discord alternative they can use day to day: fast clients, search, profiles and statuses, custom emoji, roles and permissions, voice and video, and moderation tools. Fluxer's priorities are there first. Adding E2EE to text messaging makes the app much harder to build and maintain, and Fluxer is focusing first on a stable community chat app that covers the basics people expect.
Text on Fluxer has no end-to-end encryption today. Optional E2EE is still planned where it fits: personal notes, calendar data, DMs, and small groups. E2EE for large communities is out of scope.
Voice is further along. Canary already uses LiveKit's built-in E2EE support for voice and video in testing communities that have it turned on. It rolls out to everyone soon, then becomes required as supported clients catch up.
### Native mobile app?
Yes. The native app is built with Flutter for iOS and Android. As of 15 June 2026, the iOS app is in a limited TestFlight beta with a small group of Plutonium subscribers, since TestFlight slots are capped, and the Android app is available now as an APK from [github.com/fluxerapp/flutter_client](https://github.com/fluxerapp/flutter_client). Both are early alphas, with a public release to follow.
The Flutter app can also target desktop later, making it an alternative to Electron for low-end devices. For now, iOS and Android come first.
The canary PWA has improved in the meantime. To use the newest client work before stable, try [canary.fluxer.app/download](https://canary.fluxer.app/download) or [web.canary.fluxer.app](https://web.canary.fluxer.app).
### Can I help localise Fluxer?
Yes. Fluxer already supports 34 locales across the app, email templates, marketing site, and similar places:
- العربية
- Български
- 简体中文
- 繁體中文
- Hrvatski
- Čeština
- Dansk
- Nederlands
- English (United Kingdom)
- English (United States)
- Suomi
- Français
- Deutsch
- Ελληνικά
- עברית
- हिन्दी
- Magyar
- Bahasa Indonesia
- Italiano
- 日本語
- 한국어
- Lietuvių
- Norsk
- Polski
- Português (Brasil)
- Română
- Русский
- Español (Latinoamérica)
- Español (España)
- Svenska (Sverige)
- ไทย
- Türkçe
- Українська
- Tiếng Việt
Localisation matters. Fluxer shouldn't assume everyone speaks English, and it needs to feel usable internationally whether English is your first language or not.
Because the app changes quickly and the team is small, many translations are drafted and kept up to date with LLM help. LLM literally means large *language* model, and this is one place the tool fits: it saves humans from starting every locale from a blank page and helps non-English speakers get a better app sooner.
That first pass only gets Fluxer started. Native speakers catch what a model can miss: tone, terminology, awkward phrasing, and cultural details. People have told me the current localisation is already usable in many languages, but I still want native speakers involved before treating it as something people can rely on.
Localisation is moving into a self-hosted Weblate instance so the work can happen in the open. To improve an existing locale or add a new one, email [i18n@fluxer.app](mailto:i18n@fluxer.app).
### Do you have a Contributor Licence Agreement?
No. Fluxer had a CLA early on, mostly because the expected path at the time looked more like self-hosting support and companies running Fluxer themselves. A CLA would have made it easier to offer a separate commercial licence to organisations whose policies prohibit AGPLv3 software.
After Fluxer took off as a hosted app for regular users, that deal stopped making sense. The CLA is gone now that pull requests are open and the public repo has caught up.
### Where's the roadmap?
> [Roadmap 2026](/blog/roadmap-2026)
>
> The current 2026 roadmap for Fluxer: canary, mobile, self-hosting, localisation, federation, voice and video, and the backend reliability work behind it.
>
> Source: Fluxer Blog / Hampus Kraft
## Closing thoughts
The best ways to help are using Fluxer, buying Plutonium if the hosted instance works for you, donating to support the open source work directly, reporting bugs well, and giving feedback in the community.
Fluxer should stay independent and bootstrapped, with the hosted instance funding the systems and people needed to keep the open source app moving. Self-hosters should benefit from that work too.
Reach me at [hampus@fluxer.app](mailto:hampus@fluxer.app) if you're a content creator, run an open source project, manage a community of any size, or can help with CDN, trust and safety, or moderation work. Fellow independent, bootstrapped, privacy-first alternatives and press inquiries are welcome too.
Fluxer should become a real alternative to Discord without losing why it was started.
See you in the Fluxerverse!
<figure class="blog-media blog-media--gif">
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-delorean-poster.jpg">
<source src="/blog/assets/tenor-delorean.webm" type="video/webm" />
<source src="/blog/assets/tenor-delorean.mp4" type="video/mp4" />
</video>
<figcaption>A DeLorean time machine lifts off, heading for new adventures.</figcaption>
</figure>
@@ -1,181 +0,0 @@
---
title: "Mobile clients and Fluxer v2"
slug: "mobile-clients-and-fluxer-v2"
description: "Fluxer v2 is out, mobile clients are open source, self-hosting is improving, and public development is moving back to GitHub."
author: "Hampus Kraft"
published_at: "2026-06-15T12:00:00Z"
updated_at: "2026-06-15T12:00:00Z"
feature_image: "/blog/assets/mobile-clients-and-fluxer-v2-feature-image-1280.jpg"
feature_image_alt: "Mobile clients and Fluxer v2"
source_url: "https://fluxer.app/blog/mobile-clients-and-fluxer-v2"
tags:
- "News"
---
Hey Fluxers,
Hampus here. I know it sounds like a broken record to say the last few months have been intense, but there's no better word for it. We have a lot to cover, and I don't want to go further without saying thank you.
Fluxer has grown to more than 300,000 users. Today we're launching the mobile apps, big self-hosting and safety improvements, the reopening of pull requests, $1,500 in developer bounties, and a growing list of clients, tools, and bots. Thanks to @Fen and @Xeon, Fluxer is now also on Flathub.
> [Install Fluxer on Linux with Flathub](https://flathub.org/en/apps/app.fluxer.Fluxer)
>
> Fluxer is available on Flathub for Linux users, with x86_64 and aarch64 builds.
>
> Source: Flathub
We couldn't do this without all of you talking about Fluxer, subscribing to Plutonium, donating, filing bug reports, building things, testing rough edges, and helping us find what needs to be better. Volunteers have collectively put thousands of hours into Fluxer over the last few months. We're grateful to every one of you. Without you, none of this would be possible.
In the same spirit, we're glad to welcome a few new staff members: @Lilith, @Ferret, and @Stefan. They bring experience across support, safety, engineering, product, and more. Expect to see them around.
If you're new here, these two posts explain how Fluxer got here and what was next on the roadmap:
> [How I built Fluxer, a Discord-like chat app](/blog/how-i-built-fluxer-a-discord-like-chat-app)
>
> The backstory, architecture, and reasoning behind Fluxer.
>
> Source: Fluxer Blog / Hampus Kraft
> [Roadmap 2026](/blog/roadmap-2026)
>
> The current roadmap for mobile, self-hosting, federation, voice and video, and backend reliability.
>
> Source: Fluxer Blog / Hampus Kraft
Before the announcements, one quick note: the [self-hosting get-started guide](https://docs.fluxer.app/operator/get-started/) is live. The rest of the self-hosting documentation is still being finalised, and the API documentation is being cleaned up too. We don't expect either to take more than a few days longer.
With that said, we promised one launch: an open source mobile app. We're also finally announcing the long-awaited Fluxer v2.
## Fluxer v2
It's here. Fluxer v2.
<figure class="blog-media blog-media--gif">
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-cable-guy-well-look-who-decided-to-show-poster.jpg">
<source src="/blog/assets/tenor-cable-guy-well-look-who-decided-to-show.webm" type="video/webm" />
<source src="/blog/assets/tenor-cable-guy-well-look-who-decided-to-show.mp4" type="video/mp4" />
</video>
<figcaption>Jim Carrey as Chip Douglas in The Cable Guy: "Well, look who decided to show!"</figcaption>
</figure>
Fluxer v2 is what we've been calling a large refactor and a set of infrastructure stability improvements. Most of the app stayed intact. Fluxer is still very much a polyglot codebase in TypeScript, Erlang, and Rust, using each one for the parts it is good at. We worked on it privately for the last two months because the code was moving fast enough that pushing broken builds to everyone would have helped nobody. The refactor includes a lot:
- Self-hosting documentation and prebuilt images.
- A cleaner split between Fluxer.app deployment settings and self-hosted settings.
- The Erlang Gateway role split for websocket connections, sessions, guilds, presence, calls, and push notifications.
- Rust services for hot paths like users, messages, unfurling, media, and Snowflake IDs.
- Better tools for operators and safety work around spam, illegal content, and instance moderation.
- The codebase back in a shape where public issues and pull requests can move again.
And a lot more.
> [Fluxer on GitHub](https://github.com/fluxerapp/fluxer)
>
> The main Fluxer repository now has the v2 refactor, public issues, pull requests, self-hosting work, and bounties.
>
> Source: GitHub / fluxerapp
Now that the v2 refactor is out, we can keep GitHub in sync with active development, respond to issues, and review pull requests again. Thank you for your patience and kindness as we worked to reach this milestone, and thank you to the dozens of testers who helped get it launched.
## Mobile app open source
For the last six weeks, we've been alpha testing the iOS and Android apps. They're built with Flutter, which isn't a typo, and it has made the apps fast and smooth. Early adopters, the Visionaries, have been daily driving them, giving feedback, and finding bugs while @M0N7Y5 and @Elias have been hard at work getting the clients closer to desktop.
They aren't fully there yet, but we think the native apps are ready for Plutonium subscribers on iOS and for open testing on Android. As of 15 June 2026, you can find the mobile client on GitHub.
> [Fluxer mobile client on GitHub](https://github.com/fluxerapp/flutter_client)
>
> The open source Flutter client for iOS and Android.
>
> Source: GitHub / fluxerapp
We'll release prebuilt versions on F-Droid, Obtainium, Google Play, and the Apple App Store in the coming weeks. Accrescent will come as soon as they open up again.
The native client has about 50% of what we'd consider ready for a stable launch. Expect bugs. For now, we'd rather send new non-technical users to the Canary mobile PWA at [web.canary.fluxer.app](https://web.canary.fluxer.app), which has also improved a lot, than have them bounce off a half-finished native app.
> [Download Fluxer Canary](https://canary.fluxer.app/download)
>
> The Canary mobile PWA remains the most complete phone experience today, while the native apps continue through alpha testing.
>
> Source: Fluxer
If you're on iOS and don't have Plutonium, please hold on a little longer and be careful with prebuilt apps from other sources claiming to be the official Fluxer client.
## Safety, operations, and bounties
We've been hard at work behind the scenes on instance moderation and operations. We've dealt with spam attacks, CSAM, extremist content, and other abuse while continuing to improve reliability and uptime.
Many of those improvements are already part of the Fluxer v2 refactor, and more are coming soon. Check GitHub for the public roadmap, discussion boards, and feature requests.
> [Fluxer Discussions](https://github.com/fluxerapp/fluxer/discussions)
>
> Roadmap discussions, feature proposals, and design conversations for Fluxer.
>
> Source: GitHub / fluxerapp
Several upcoming features also have bounties on GitHub. The bounties are tagged by priority as low, medium, high, and urgent, with different payments attached. Please read the contribution guidelines before getting started.
One thing I'm especially excited to keep testing in the open is the new native A/V architecture I've been working on. The goal is screen sharing with audio at 1440p 60fps without audio or video frame drops, built through a lot of optimization and native platform work. That code is on GitHub now, so if you want to help fix bugs before Canary reaches stable, you can.
## Questions we know people have
We want to answer some of the obvious ones right away.
### What about Partnered and Verified Communities?
We've been working on updated requirements, and the plan is to review applications within the next month. After that, reviews will happen every so often, so expect some time between additions. Keep an eye out for responses or follow-up questions from the team.
### Why did GitHub lag behind internal development?
GitHub lagged because we made a call: the public code needed to be useful, not just current. Fluxer needed a cleaner foundation before it made sense to invite people back into issues, pull requests, and self-hosting.
Most of Fluxer was not rewritten. The v2 refactor was about separating what needed to move, cleaning up infrastructure, and making the codebase easier to run, self-host, and contribute to. Doing that privately made the public repo harder to follow, and I understand why that frustrated people.
At the same time, we were a small team carrying too much at once: backend, frontend, operations, moderation, mobile, and the v2 refactor. Native screen sharing, audio capture, and platform integration also came with a real learning curve for a mostly solo developer suddenly facing a lot of new technical requirements from a user base that needed more. I'm glad to be close to delivering that when Canary reaches stable.
Fluxer was also dealing with attacks involving CSAM and gore content across public communities. The new safety systems have put us in a much better place, but getting there was emotionally exhausting. I could not in good conscience push half-finished moderation tools, policy thresholds, or infrastructure changes to GitHub while that work was still tangled together.
So we finished the v2 foundation first. With the v2 refactor out, the safety work split out, and a roadmap in place, GitHub can be useful again. Issues, pull requests, self-hosting, and migration work can move in the open.
We're happy to be back there. Seriously, nobody is happier than we are to have this back in the open. Please submit issues and PRs. Pretty please :)
### Why is federation taking so long?
We're working on it, and we need your input. Seriously, come talk through the design standard with the Fluxer team and developer community on GitHub and in Fluxer Developers. Federation matters to me and the entire team, and we want to do it right. A lot of questions are still unanswered:
- What happens when a user keeps returning to one Fluxer instance through another instance?
- How does Fluxer prevent illegal content like CSAM from propagating across instances?
- In DMs, which instance is responsible for moderation, if any?
- How do users and communities migrate between instances?
- How should Fluxer handle Plutonium?
- Why is Hampus a ghost?
And dozens more.
We'd really like to avoid the email problem, where some instances or clients never support newer standards, by being careful with the design upfront. Some split between instances is inevitable, but Fluxer shouldn't start there through rushed implementation.
We want Fluxer to be the home for your community for years to come, no matter who hosts it. That means putting in the thought and building slowly.
### I'm worried about Fluxer disappearing!
Fluxer has a way forward, but we still need the community around it. Costs grow quickly with age and a larger user base, and the best version of Fluxer is built with people using it, testing it, supporting it, and telling us what needs work. Here's how you can help:
- Subscribe to [Plutonium](/plutonium). Plutonium supports Fluxer while giving you fun perks. It's the best way to contribute to the hosted instance.
- [Donate to Fluxer](/donate).
- File bug reports.
- Talk about feature requests, federation, and other parts of Fluxer in GitHub Discussions and the Fluxer Developers community.
- Invite your friends to hang out on Fluxer. We love company.
And thankfully, even if Fluxer were to close its hosted instance in the future, the point is that you can always run an instance on your own hardware. Fluxer is open source, now and forever.
### Why should my community use Fluxer over other platforms?
You should use what gives you joy.
For us, that's the promise of being able to own your communications and still connect with others. If your community finds joy on other platforms, use them. But give Fluxer a try. You may find that little spark of magic here.
Communication belongs to people, and people create communities. Building so much at once has been a lot, and honestly, I feel like I need a vacation at this point.
More than that, though, we're excited to at long last share everything with the developer community and keep building in the open. That's what we wanted all along: for Fluxer to be a home for open, free communities, no matter who hosts them.
Thank you all for reading, and let's make 2026 the year of ~~Linux~~ Fluxer.
@@ -1,234 +0,0 @@
---
title: "Roadmap 2026"
slug: "roadmap-2026"
description: "The current 2026 roadmap for Fluxer: canary, mobile, self-hosting, localisation, federation, voice and video, and the backend reliability work behind it."
author: "Hampus Kraft"
published_at: "2026-01-26T12:49:48Z"
updated_at: "2026-05-24T05:00:00Z"
feature_image: "/blog/assets/roadmap-2026-feature-image-1280.jpg"
feature_image_alt: "Roadmap 2026"
source_url: "https://fluxer.app/blog/roadmap-2026"
tags:
- "News"
---
> [Discord will require a face scan or ID for full access next month](https://www.theverge.com/tech/875309/discord-age-verification-global-roll-out)
>
> Age verification for all.
>
> Source: The Verge / Stevie Bonifield
Fluxer is still in public beta, but it's grown a lot since January. The hosted instance had to grow quickly after Discord's age-verification announcement. The next stretch is about reliability and keeping the app running: fewer client bugs, self-hosting that's easier to set up, stronger moderation tools, fewer incidents, and then the new features people are waiting for. The hosted service pays for that work and tests it under real traffic, and new work should happen in the open, with proper tools for people running their own instances.
I've also updated the longer post that explains how Fluxer got here, the architecture, and the reasoning behind it:
> [How I built Fluxer, a Discord-like chat app](/blog/how-i-built-fluxer-a-discord-like-chat-app)
>
> Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
>
> Source: Fluxer Blog / Hampus Kraft
## #1: Canary to stable, and native mobile
The canary client is the newest way to use Fluxer. It's past build v0.0.200, compared with v0.0.8 for the original stable desktop client, and includes hundreds of fixes plus big voice and video work: screen sharing with audio, text in voice, better DM calls, a stronger device selector, more mic processing controls, DeepFilterNet3 noise suppression, and LiveKit-backed E2EE for voice and video in testing communities that have it turned on.
Try it today through the canary desktop build at [canary.fluxer.app/download](https://canary.fluxer.app/download), or in the browser at [web.canary.fluxer.app](https://web.canary.fluxer.app). Canary moves to stable once the last serious bugs are fixed. Voice and video E2EE rolls out more broadly soon, then becomes required as supported clients catch up.
The launch PWA worked in the browser and as an installable app from day one, and it keeps improving while the native app moves through testing.
Native mobile is happening at the same time, built with Flutter. As of 15 June 2026, the iOS app is in a limited TestFlight beta with a small group of Plutonium subscribers, since TestFlight slots are capped, and the Android app is available now as an APK from [the open source repo](https://github.com/fluxerapp/flutter_client). Both mobile apps are early alphas, so expect missing features and bugs while they move towards a public release.
Flutter also gives Fluxer a desktop path later. Mobile comes first, but Flutter can become an alternative to Electron for low-end devices.
## #2: Self-hosting and the backend cleanup
Self-hosting work is back in the public repository after the 15 June 2026 sync. The goal is Docker images, operator-focused documentation, and a web setup wizard that walks you through configuring an instance. A typical self-hosted instance only needs the app services, a database, Valkey, and NATS.
Docs and operator tooling are now public work; the remaining gaps can be fixed as people try running real instances.
Fluxer.app is much larger than a normal self-hosted instance will ever need to be. It runs the TypeScript API and Worker, the clustered Erlang Gateway, the Rust Media Proxy, NATS, and newer Rust services for users, messages, search, unfurling, and Snowflake IDs. People running their own instances should get the benefit of that work without copying the hosted setup: Fluxer.app stress-tests the hard parts, and instance operators get the simpler shape.
Gateway clustering is a recent example. Fluxer started with one Gateway node because the original design was built for a much smaller user base. After the growth spike, that became a reliability problem. I rolled out Erlang Gateway clustering, then split the gateway into specialised tiers for websocket connections, sessions, guilds, presence, calls, and push notifications. A problem in one stateful tier is now contained instead of taking down every connected user, and each tier scales on its own.
> [Scheduled maintenance - Incident details - Fluxer - Status](https://fluxerstatus.com/cmpiwlw5e057vpbi74zh7ohmh)
>
> Scheduled maintenance window on 24 May 2026 for the Gateway role-split deployment and associated reliability improvements.
>
> Source: Fluxer Status
The Rust services are part of the same reliability work. They move hot read paths and ID generation out of the main API where that helps, while the Gateway keeps presence, member lists, and real-time routing in Erlang.
The Electron desktop path starts with custom backends through in-app account switching in the regular client, so you can point it at your own instance without waiting for full federation.
The Operator Pass is planned for when the docs and setup guides are solid. It's a $199 or €199 one-time purchase for self-hosters who want to support the open source work and join the Operators community: a smaller place to get help from other self-hosters and the Fluxer team, share ideas, and make feedback heard before it gets buried on GitHub.
The public repository lagged until 15 June 2026 because the growth spike forced urgent anti-abuse and production work while I kept Fluxer.app stable. The sync separated Fluxer.app-only deployment settings from instance settings other people can use, and moved the remaining operator work into the open.
## #3: Localisation in the open
Fluxer already supports 34 locales across the app, email templates, marketing site, and similar places:
- العربية
- Български
- 简体中文
- 繁體中文
- Hrvatski
- Čeština
- Dansk
- Nederlands
- English (United Kingdom)
- English (United States)
- Suomi
- Français
- Deutsch
- Ελληνικά
- עברית
- हिन्दी
- Magyar
- Bahasa Indonesia
- Italiano
- 日本語
- 한국어
- Lietuvių
- Norsk
- Polski
- Português (Brasil)
- Română
- Русский
- Español (Latinoamérica)
- Español (España)
- Svenska (Sverige)
- ไทย
- Türkçe
- Українська
- Tiếng Việt
Fluxer shouldn't assume everyone speaks English. It needs to feel usable internationally, whether English is your first language or not.
Because the app changes quickly and the team is small, the first translation pass is drafted with LLMs. LLM literally means large *language* model, and this is one place the tool fits: it saves humans from starting every locale from a blank page. Native speakers then catch what a model can miss: tone, terminology, awkward phrasing, and cultural details. People have told me the current localisation is already good in many languages, but I still want native speakers involved before treating it as something people can rely on.
Localisation is moving into a self-hosted Weblate instance so the work can happen in the open. To improve an existing locale or add a new one, email [i18n@fluxer.app](mailto:i18n@fluxer.app).
## #4: Federation and multiple backends
Federation remains part of the plan, and the order matters. People who run and use Matrix complain about specific things: large federated rooms can be slow and expensive to join, presence and device-list updates can create surprising background load, federation failures can leave rooms or encrypted messages half-working, and moderation gets harder when abuse, media, bans, and bridges cross instance boundaries.
The next client step is simultaneous connections to multiple backends: connect to more than one self-hosted instance, keep separate identities, and see them together without switching between workspaces. That already helps people who use more than one instance before true federation is ready.
True federation builds on that base later, with OAuth2-based authentication against remote instances and a clearer model for identity, communities, and data.
## #5: Threads, forums, and publishing to the web
> [Discord seeks to solve a problem that it created | TechCrunch](https://techcrunch.com/2025/05/23/discord-seeks-to-solve-a-problem-that-it-created/)
>
> Conversations on Discord can be hard to follow. Discord SVP Peter Sellis proposes making forum-like features, or using AI summaries.
>
> Source: TechCrunch / Amanda Silberling
Fluxer's threads and forums should be useful enough that people don't immediately work around them.
Forum-style spaces will also have an optional public web mode. When a community chooses to publish something, people can read it without logging in, find it through search engines, archive it, and follow it through RSS and Atom feeds.
> With LLMs, Sellis said, Discord could take a long, meandering conversation and turn it into "something that could be more sharable and syndicated across the web." However, he said that he and his team hadn't "seen a solution that we feel great about yet."
Fluxer disagrees with Discord's starting point here. LLMs shouldn't turn people's "meandering conversations" into web content. If something becomes public, it should be because a person or community chose to publish it.
This feature uses the web itself: public pages with stable URLs, server-rendered posts, clear titles, search indexing, pages that can be archived, RSS and Atom feeds, and links people can share anywhere. People's posts remain their posts.
Publishing stays opt-in, for communities that benefit from public knowledge: open source projects, developer communities, modding groups, creator communities, research groups, and support forums. Private chat stays private.
## #6: Discovery for communities, instances, and apps
Fluxer already has basic in-app community discovery per instance. Once self-hosting and public web publishing become normal, it needs to grow.
Public communities on Fluxer instances should be findable on the web without logging in. The registry is built from pull requests to a public repository, so listings are easy to inspect, review, and update.
The same directory can later include Fluxer bots and apps. If someone builds a moderation bot, bridge, game, tool, or integration, there should be a public place to find it without relying on word of mouth. The official discovery list still gets reviewed, but the list itself stays public and easy to check.
## #7: Slash commands, UI kit, and integrations
Fluxer needs first-class bots and integrations: slash commands, modals, components, interactions, and then the improvements people ask for once they start building on it.
## #8: Emoji and sticker packs
Many Discord users know the habit of joining communities just to use their emojis and stickers globally. Fluxer skips that: people will be able to create emoji and sticker packs that other users can equip directly on their account. Free users get an allowance too, and paid tiers can offer higher limits.
## #9: E2EE where it makes sense
Matrix does offer E2EE, but the complexity of the protocol and its client implementations often comes at the expense of what people actually want.
> [Why We Abandoned Matrix (2024) | Hacker News](https://news.ycombinator.com/item?id=46376201)
>
> Source: Hacker News
Most people want a Discord alternative they can use day to day: fast clients, search, profiles and statuses, custom emoji, roles and permissions, voice and video, and moderation tools. That's where Fluxer's priorities lie. Adding E2EE to text messaging adds real complexity, especially around search, moderation, history, recovery, and multi-device sync.
Optional E2EE still belongs in the roadmap for personal notes, calendar data, DMs, and small groups. E2EE for large communities is out of scope.
Voice and video are a better fit technically, and that work already runs in canary for testing communities that have it turned on. It rolls out to everyone soon, then becomes required as supported clients catch up.
## #10: Creator payments
Fluxer can let fans pay to unlock roles and access to a creator's exclusive community content. That access can be permanent or time-limited, billed as a one-off purchase or a recurring subscription. Creators can also sell event tickets for time-boxed sessions, where a ticket or temporary role unlocks specific text and voice channels for the duration of the event.
On Fluxer.app, the model is simple: creators bring the thing people want to support, Fluxer handles the community space and payments around it, and Fluxer.app takes a small, clear fee (for example, 5 to 10%) that helps pay for hosted systems and the people keeping both Fluxer.app and the open source project moving. Patreon has been adding Discord-style community features, and Discord has experimented with Patreon-style payments, but those efforts have been mixed. If this works, it keeps the free hosted tier generous, funds work that also improves the self-hosted app, and means the hosted instance relies less on Plutonium.
> [Patreon is adding a Discord-like chat feature for creators and fans](https://www.theverge.com/2023/9/7/23861171/patreon-community-chats-discord-chatroom-member-profile)
>
> The Discord integration will still be available.
>
> Source: The Verge / Mia Sato
If you'd like to use Fluxer as a combined Discord + Patreon-style app, and you already have a large audience you'd bring over, email [partners@fluxer.app](mailto:partners@fluxer.app). I'm looking for early creators to test paid community features, and Fluxer can offer a lower fee while the model gets tested.
## And more!
Polls and scheduled events, profile connections, a theme marketplace, stage channels, activity sharing, streamer mode, DM folders, popping calls out into their own desktop windows, soundboard clips, community templates, public profile URLs, and better tools for instance operators and safety work. Fluxer will keep adding features.
GIF search has maintenance work coming up. Fluxer currently relies on Tenor, but Google is shutting down the current Tenor API on 30 June 2026. KLIPY support already exists in the codebase and is the planned replacement. GIFs are proxied through Fluxer either way, so providers don't see your IP address just because you searched for or viewed a GIF through the app.
You can help shape the roadmap by joining the [Fluxer HQ community](https://fluxer.gg/fluxer-hq), submitting issues in [the GitHub repository](https://github.com/fluxerapp/fluxer), or [emailing me](mailto:hampus@fluxer.app). You can also support Fluxer directly:
- [Donate any amount](/donate), as an individual or a business.
- [Purchase Plutonium](/plutonium) on the hosted Fluxer.app instance.
- Spread the word about Fluxer with friends and on social media.
## But why?
Why use Fluxer when Discord is free and works well?
If Discord does what you need, and you're fine relying on a closed, investor-driven app that has reportedly [filed confidential IPO paperwork](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/), then you may not need Fluxer. Fluxer is for people who want an open, self-hostable alternative.
> [Discord's IPO could happen in March | TechCrunch](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/)
>
> Discord reportedly filed confidential IPO paperwork and has pinned its hopes on a debut in March.
>
> Source: TechCrunch / Julie Bort
Fluxer is for people who want a different model: free, open source, self-hostable software, with an optional hosted instance run by an independent European company that helps pay for the open source work. You shouldn't have to give up the basics you like just to leave Discord: Fluxer keeps the familiar shape of modern community chat while making the software open and self-hostable.
Fluxer can succeed without Discord getting worse. Discord's network effect is hard to beat head-on, so I'm starting where switching already makes sense: technical users and communities that value control and openness, and want software they can run on their own terms. You don't need to be technical for Fluxer to be yours: many people prefer a European-owned instance that has clearer reasons to treat users well.
Fluxer is free, open source, and self-hostable. The public code should be useful to people who run their own instance, not only to Fluxer.app. No community should have to depend on one company surviving. Fluxer.app should earn money by being a hosted service, then put that money back into hosting, documentation, review time, and development work that self-hosters benefit from too. Plutonium, donations, creator payments, and the optional Operator Pass all fit that model.
If the hosted free tier limits are too tight, you can run your own instance. Fluxer will keep the software free of feature paywalls, licence key checks, upgrade-for-quota gates, and [SSO tax](https://sso.tax/).
## Closing thoughts
The best ways to help are using Fluxer, buying Plutonium if the hosted instance works for you, donating to support the open source work directly, reporting bugs well, and giving feedback in the community.
Fluxer should stay independent and bootstrapped, with the hosted instance funding the systems and people needed to keep the open source app moving. Self-hosters should benefit from that work too.
Reach me at [hampus@fluxer.app](mailto:hampus@fluxer.app) if you're a content creator, run an open source project, manage a community of any size, or can help with CDN, trust and safety, or moderation work. Fellow independent, bootstrapped, privacy-first alternatives and press inquiries are welcome too.
Fluxer should become a real alternative to Discord without losing why it was started.
See you in the Fluxerverse!
<figure class="blog-media blog-media--gif">
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-delorean-poster.jpg">
<source src="/blog/assets/tenor-delorean.webm" type="video/webm" />
<source src="/blog/assets/tenor-delorean.mp4" type="video/mp4" />
</video>
<figcaption>A DeLorean time machine lifts off, heading for new adventures.</figcaption>
</figure>
@@ -1,25 +0,0 @@
You cannot change your date of birth from within the app. If yours needs correcting, our support team can update it for you.
This is one of the few times we ask for a government-issued ID. Fluxer does not require ID uploads or biometric scans for general access; see [minimum age requirements](/help/minimum-age) for the other case, which is age-related account appeals.
## What you will need
- A clear photo or scan of a valid government-issued ID
- The correct date of birth
- A short explanation of why the change is needed, such as a typo during sign-up
We only need to see your name and date of birth on the ID. Feel free to cover up the document number, photo, address, and anything else.
A note on image metadata: photos taken with a phone often carry hidden EXIF data such as GPS location and device identifiers. If you would rather not share that, take a screenshot of the photo (which strips most metadata) or use a metadata-stripping tool before sending.
## How to request a change
Email [support@fluxer.app](mailto:support@fluxer.app) from the address linked to your Fluxer account. Include the correct date of birth, a brief reason for the correction, and the redacted ID image.
## How we handle the ID
The image is used only to confirm your date of birth. Authorised support staff can access it, every access is logged, and the image is deleted within 60 days after your request is closed, regardless of the outcome. It is never used to train any system, shared with third parties, or attached to your account profile.
## Things to know
For security reasons we usually allow only one date of birth correction per account. If the new date of birth changes your access to age-restricted features, your account is updated to match. See [section 7 of our Privacy Policy](/privacy) for the underlying retention rules.
@@ -1,21 +0,0 @@
You can delete your messages and other content at any time through the Privacy Dashboard, or by contacting our privacy team directly.
## Delete all messages
1. Sign in at [web.fluxer.app/login](https://web.fluxer.app/login).
2. Open Settings (the cogwheel at the bottom left).
3. Go to Privacy Dashboard.
4. Select the Data Deletion tab.
5. Click Delete all my messages.
Bulk deletion runs in the background; how long it takes depends on how many messages you have sent. Messages inside a Community or channel that is already pending deletion are skipped during bulk delete; they will be removed when the Community or channel is permanently deleted after its 14-day grace period.
Deleted messages leave active systems within minutes. They may persist in our encrypted backups for up to about 30 days before being permanently removed. See [section 7.4 of our Privacy Policy](/privacy) and the [data retention article](/help/data-retention) for the full picture.
## Delete specific data
Email [privacy@fluxer.app](mailto:privacy@fluxer.app) from your account's registered address, with details of what you would like removed.
## Before you delete
Deleting a message also deletes its attachments. If you want to keep them, [export your data](/help/data-export) first. You cannot delete messages once your account has been removed, so do this before starting [account deletion](/help/delete-account), or choose the message-deletion option during account deletion. Attachments also expire based on size regardless of deletion; see [how attachment expiry works](/help/attachment-expiry).
@@ -1,29 +0,0 @@
You can request a complete export of your account data, including every message you have sent and URLs for downloading your attachments.
## How to request an export
1. Sign in at [web.fluxer.app/login](https://web.fluxer.app/login).
2. Open Settings (the cogwheel at the bottom left).
3. Go to Privacy Dashboard.
4. Select the Data Export tab.
5. Click Request Data Export.
You can request an export once every 7 days.
## What is included
The export is a ZIP archive of machine-readable JSON files covering your account information, per-channel message history, payment history, and security data, along with profile assets if there are any. Attachments are not bundled; the export includes CDN URLs for downloading them while they remain available.
Messages inside a Community or channel that is already pending deletion are excluded from the export, as are report snapshots and other internal records that are not your personal account data.
## Receiving your export
When your export is ready, you will receive an email with a download link to a ZIP file. The link expires after 7 days.
Treat the link as sensitive: anyone who has it can download the export until it expires. Do not forward the email, paste the link in chat, or share it in a tool that may log or archive URLs. If you think a link has been exposed, request a fresh export. The old link cannot be revoked individually, but it will expire on schedule.
Once downloaded, the ZIP is yours. Store it somewhere encrypted (your operating system's user folder behind a password, a personal password manager, or full-disk encryption) and delete it when you no longer need it.
## Before you delete anything
The export includes URLs for downloading your attachments, but deleting a message also deletes its attachments. Download anything you want to keep before deleting messages or [deleting your account](/help/delete-account). Attachments also expire based on size, so see [how attachment expiry works](/help/attachment-expiry).
@@ -1,33 +0,0 @@
You can disable or delete your account from your account settings.
## How to find these options
1. Sign in at [web.fluxer.app/login](https://web.fluxer.app/login).
2. Open Settings (the cogwheel at the bottom left).
3. Go to Security & Login.
4. Choose Delete account or Disable account.
## Deleting your account
Choosing delete schedules your account for permanent removal in 14 days. Signing in at any point during that window cancels the deletion. After 14 days, identifying information is removed from active systems and the rest is anonymised. Encrypted backups roll over on a cycle of up to about 30 days, after which the data is gone from there too. Records we are legally required to keep (such as payment records under Swedish bookkeeping law) are retained for the period the law requires. The full breakdown is in [section 7 of our Privacy Policy](/privacy) and the [data retention article](/help/data-retention).
### Messages
Your messages stay on Fluxer unless you remove them first or choose the message-deletion option during account deletion. Other people you talked to can still see past messages to them, though they are no longer linked to your account.
If you want to clear them out before deleting:
- Use the message-deletion option in the account deletion flow, if it is shown.
- Bulk delete from the Privacy Dashboard. See [requesting data deletion](/help/data-deletion).
- Export your data first if you want a copy. See [exporting your account data](/help/data-export).
- Email [privacy@fluxer.app](mailto:privacy@fluxer.app) from your registered address to request deletion of specific data.
You cannot delete messages once your account is gone, so do this before the deletion finishes.
## Disabling your account
Disabling signs you out of every device. The account stays in our systems exactly as it was; signing in at any time turns it back on. No data is removed.
## Inactive accounts
Accounts that go unused for two years may be scheduled for deletion. Before any deletion proceeds, we send advance notice to the registered email address so you can sign in and keep the account active if you want to.
@@ -1,71 +0,0 @@
You must meet the minimum age requirement for your country to create and use a Fluxer account. In most countries this is 13, but some jurisdictions set a higher minimum.
## Minimum age by country
A handful of countries set a minimum age higher than the default of 13. All other countries use 13.
- Aruba: 16
- Austria: 14
- Bulgaria: 14
- Caribbean Netherlands: 16
- Chile: 14
- Colombia: 14
- Croatia: 16
- Curaçao: 16
- Cyprus: 14
- Czech Republic: 15
- France: 15
- Germany: 16
- Greece: 15
- Hungary: 16
- Ireland: 16
- Italy: 14
- Lithuania: 14
- Luxembourg: 16
- Netherlands: 16
- Peru: 14
- Poland: 16
- Romania: 16
- San Marino: 16
- Serbia: 15
- Sint Maarten: 16
- Slovakia: 16
- Slovenia: 16
- South Korea: 14
- Spain: 14
- Venezuela: 14
- Vietnam: 15
## Age-restricted content
Regardless of the minimum age to use Fluxer in your country, you must be 18 or older to access age-restricted content. This covers NSFW channels inside Communities, Communities marked as age-restricted, and media flagged as containing explicit content by our automated classifier (see [section 5.1 of our Privacy Policy](/privacy) for how that classifier works).
Your basic access is set by the date of birth you provide when you register. In some regions, age-restricted content may also require an extra regional adult verification step. For current region-specific requirements and methods, see our [regional restrictions](/help/regional-restrictions) page.
## How your age is determined
When you create an account, you provide your date of birth. That self-declaration is all we ask for: no ID uploads, no biometric scans. We use IP geolocation to determine your approximate country and apply the corresponding minimum age requirement. If your date of birth does not meet the minimum for your detected country, registration is not permitted.
For details on how we use IP geolocation, see [section 3.2 of our Privacy Policy](/privacy).
## Reporting underage users
If you believe an account belongs to someone under the minimum age, you can report it. Our team reviews these reports, and if we have reason to believe the account holder does not meet the requirement, the account may be suspended.
Reports are confidential. The reported user receives a statement of reasons explaining the action and the rule applied, but we do not share your username, user ID, email, or any other identifying detail with them. See [Reporting violations](/guidelines) in our Community Guidelines for the full position.
## Appealing a suspension
If your account is suspended because it is suspected of belonging to someone under the minimum age, you can appeal by contacting [appeals@fluxer.app](mailto:appeals@fluxer.app) from the address linked to your account.
As part of the appeal, you may be asked to submit a government-issued identification document to verify your age. ID requests are limited to two narrow situations: this age appeal, and the rare case of correcting a date of birth on an existing account (see [change your date of birth](/help/change-date-of-birth)). We do not require ID uploads or biometric scans for general access. Any document you submit is used only to verify your age. Authorised staff can access it, each access is logged, and it is deleted within 60 days after the appeal closes, regardless of the outcome.
## Accounts that do not meet the minimum age
If Fluxer becomes aware that an account belongs to someone who does not meet the minimum age requirement in their country, we take steps to delete the account and the associated personal data. If you are a parent or legal guardian and believe your child has created an account without your consent or does not meet the minimum age, please contact [privacy@fluxer.app](mailto:privacy@fluxer.app) from the child's registered email address, or with enough proof that you are their parent or guardian.
For more information about how we handle children's data, see [section 11 of our Privacy Policy](/privacy).
## Contact
For questions about minimum age requirements, write to [support@fluxer.app](mailto:support@fluxer.app). For privacy-related questions, see our [Privacy Policy](/privacy).
@@ -1,41 +0,0 @@
Fluxer automatically expires older attachments. Smaller files stay available for longer, while larger files expire sooner. If someone opens a message with a file that is close to expiry, we extend its availability so it remains accessible.
## How expiry is decided
The timer starts when you upload the file. Files of 5 MB or smaller keep links for about three years, the longest window. Files near 500 MB keep links for about 14 days, the shortest. In between, larger files get shorter windows. Files above 500 MB are not accepted right now.
## Extending availability when accessed
If a message with a file is loaded and the remaining time falls inside the renewal window, we move the expiry forwards. The renewal window depends on size: small files can renew up to about 30 days, while the largest files renew up to about 7 days.
One view is enough to refresh a file. You do not need to click or download it. Multiple views inside the same window do not stack. The total lifetime is capped to the size-based budget, so repeated renewals cannot keep a file available indefinitely.
## What happens after expiry
We regularly sweep expired attachments and delete them from our CDN and storage. The same removal mechanism applies as for attachments you delete yourself: the file leaves active storage within hours and is irretrievable after the 24-hour disaster-recovery window described in [section 7.4 of our Privacy Policy](/privacy). Attachments are not included in our long-term backups.
## Why we expire attachments
Large media is expensive to store indefinitely, so expiry keeps storage fair for everyone. Clearing older uploads also reduces the chance that sensitive files remain accessible for longer than needed.
## Keeping important files
If you need a file, download it before it expires. For full account exports, including attachment URLs, see [exporting your account data](/help/data-export).
## Frequently asked questions
### Does Plutonium extend file expiry?
Not at the moment. The same attachment expiry limits apply to all users.
### Do I need to click or download a file to keep it available?
No. Viewing the message in chat or search is enough.
### What about Saved Media?
Saved Media lets you keep up to 50 files, or 500 with Plutonium. Saved Media is not subject to attachment expiry.
### Can I hide the expiry indicator?
Yes. Go to User Settings > Messages & Media > Media and switch off "Show Attachment Expiry Indicator".
@@ -1,87 +0,0 @@
Fluxer has three ways to showcase a community: the Partner programme, Verified status, and Discovery. Each one is built for a different kind of creator or organisation, and each has its own perks, requirements, and application process. This article explains which one fits you and how to apply.
## Partners
The Partner programme is for the long-term relationships we want to build with content creators and online personalities. It is meant to be mutually beneficial: we highlight people we believe make good content for the world, and they get perks that make running a community on Fluxer easier.
Our requirements here are the most flexible of the three, and deliberately so. If you bring something we think is uniquely valuable, we are happy to be lenient about metrics and other criteria.
Our [partner programme page](/partners) lists the full set of perks, from free Plutonium and a partner badge to a custom vanity URL and direct access to the Fluxer team.
### Partner requirements
- A community presence on Fluxer that you intend to grow and keep around. We have no size, age, or engagement thresholds here; we want to see that you plan to use Fluxer for the long run.
- Values and intent that line up with ours. Fluxer strives for open, honest, authentic content. We care about people, we respect autonomy, and we want the world to be a little better tomorrow than it is today.
- A varied social presence outside Fluxer. Again, no specific size, age, or engagement thresholds.
- A commitment to our [Community Guidelines](/guidelines) and to fostering a safe, positive community on the platform. No community is perfectly healthy all of the time, and we are not looking for a sanitised one. We are looking for a strong culture of positivity.
## Verified
Verification is for official entities: businesses, organisations, non-profits, groups, subreddits, and everything in between. It gives your community authenticity that members can see, along with a verified badge, a place in Discovery, upgraded streaming, more emoji slots, and a vanity URL. Content creators who do not meet our standards for the Partner programme may be verified instead.
### Verified requirements
- A community presence on Fluxer that upholds our [Community Guidelines](/guidelines) and fosters a safe, positive community on the platform.
- A known presence outside Fluxer. We have no size, age, or engagement thresholds, and that presence does not have to be an online one.
## Discovery
For every other community, Discovery is the way to go. It lists your community in the in-app Discovery directory, where people can find it by tag, title, or description. This is our most specific set of requirements, but it is also the easiest to meet. Creators and organisations who do not qualify for the Verified or Partner programmes can apply for Discovery instead.
### Discovery requirements
- At least 100 members in total.
- At least 30 days old. We may make exceptions based on a variety of criteria.
- A clear topic or theme. "Just chatting" or "general hangout" communities can be accepted, but that is exceedingly rare and they must be well moderated.
- A link to Fluxer's [Community Guidelines](/guidelines) in your rules channel.
- Your own rules, and evidence that you enforce them. That means appropriate moderation coverage and moderators who are reasonably active. We know things will not always be perfect, so do not sweat this one too hard.
- Activity within the last 14 days.
- No empty or placeholder channels.
## Disqualifiers
### All programmes
- The membership of the community is fake, inauthentic, or consists entirely of botted members.
- The community is demonstrably inactive, abandoned, or has been archived.
- Policy violations happen regularly and require Fluxer staff to keep restricting permissions, banning accounts, or deleting violating content.
- The community was made solely to promote hate, harass others, or host extremist content.
- The community exists to break local laws or for other illegal activity.
- The community exists to run scams, spam, or other deceptive practices.
- The community impersonates brands, individuals, or other communities on Fluxer.
- The community was approved for one purpose and then changed its topic or purpose after approval.
- The community name, description, or logo uses the Fluxer name, logo, or branding without clearly stating that the community is unaffiliated.
### Partner and Discovery
We cannot accept Partner or Discovery applications for NSFW communities at this time.
- This covers communities whose topic is almost entirely NSFW content, communities with an excessive number of age-restricted channels, and communities marked as NSFW. It also covers communities that excessively post NSFW content in channels that are not marked for it.
- Age-restricted communities can still be accepted when the topic of the community does not relate to NSFW, illegal, or harmful content, or when the age restriction is required by local law. A video game community for a title with a mature rating is a good example.
- No community may be listed in Discovery while it is requesting or processing identity documents of any kind from users, such as "provide me your passport to prove you are 18 or older".
## How to apply
### Partner and Verified
Email [partners@fluxer.app](mailto:partners@fluxer.app) with the programme and your community or creator name as the subject line, for example "Partner: Example Studios" or "Verified: Example Foundation". Please include:
- Your preferred name.
- Your community on Fluxer.
- Your social links.
- A brief description of you and what you intend to do on Fluxer.
- Anything else you think we should know.
### Discovery
Open Community Settings for your community in Fluxer, go to the Discovery tab, and submit the application from there.
### After you apply
You will receive a notification as soon as your application has been processed. Please do not send in multiple applications. If you are turned down for a programme, wait before you apply again:
- Discovery: 60 days.
- Verified: 75 days.
- Partner: 120 days.
Applying again before that period is up can lead to an automatic disqualification.
@@ -1,35 +0,0 @@
> The Fluxer Bug Hunter profile badge is currently only available as a reward for responsible disclosure through our [security bug bounty programme](/security), not for general bug reports submitted via this guide.
Use this guide to put together a clear report so we can reproduce and fix the bug quickly. Screenshots, short screen recordings, and relevant logs or files help us diagnose the issue faster.
## Bug report template
Give your report a specific title, for example "Media upload stalls at 95%". Then include the following sections:
- Steps to reproduce: number each step, and include the exact clicks or taps, inputs, shortcuts, and any timing or ordering details.
- Expected result: what you expected to happen.
- Actual result: what happened instead, including the exact error or on-screen message.
- System and client settings: in User Settings, tap your client info at the bottom of the sidebar to copy it, then paste it into the report.
## Add evidence
Include anything that shows the issue: screenshots, short videos, logs, or sample files and exports. The more specific your report is, the faster we can help.
### A note on privacy when sharing evidence
Only include data that is needed to demonstrate the bug. Screenshots and screen recordings can capture more than you intend: other people's usernames and avatars, message content from third parties in DMs or Communities, open tabs in the background, notification previews, tokens visible in network logs.
Before sending:
- Crop or blur unrelated chats, friend lists, and Community names.
- If you share a log file, search it for email addresses, IP addresses, session tokens, and other people's user IDs, and redact anything you do not specifically need to include.
- Prefer screenshots over photos. Photos often carry hidden EXIF metadata such as GPS coordinates.
- Never paste your own session token, password, or authentication cookie. We never need them to investigate a bug.
## Submit your report
Email [bugs@fluxer.app](mailto:bugs@fluxer.app) with the completed template. A concise but descriptive subject line helps us triage quickly. If you prefer GitHub, you are welcome to file issues in the [Fluxer GitHub repository](https://github.com/fluxerapp/fluxer).
## Security issues
If you believe the issue is security-related, visit our [security bug bounty page](/security) instead of emailing support. Follow the guidance there, and include clear steps, why you believe it is a security risk, and any impact you have identified. We respond quickly to assess the report, coordinate a fix, and discuss disclosure expectations.
@@ -1,55 +0,0 @@
Fluxer respects the intellectual property rights of others, and expects everyone using the service to do the same.
If you believe that content on Fluxer infringes your copyright or other intellectual property rights, you can notify us through the report form at [web.fluxer.app/report](https://web.fluxer.app/report) (choose the copyright or intellectual property option) or by emailing [copyright@fluxer.app](mailto:copyright@fluxer.app).
We review complaints in accordance with applicable law, including, where relevant, European Union law.
## How to submit a complaint
Include the following:
- a description of the copyrighted work or other intellectual property right you believe has been infringed
- the exact location of the material on Fluxer, including any relevant message links, channel IDs, user IDs, or other information that lets us identify the content
- an explanation of why you believe the material infringes your rights or is otherwise unlawful
- your full name, your email address, and any additional contact details you wish to provide
- a statement confirming that you have a good-faith belief that the use of the material is not authorised by the rights holder, its agent, or the law
- a statement confirming that the information in your complaint is accurate and complete and, where applicable, that you are the rights holder or are authorised to act on the rights holder's behalf
- your physical or electronic signature
If your complaint does not include enough information for us to identify the content or assess the claim, we may not be able to process it.
## How we review complaints
If a complaint includes enough information, we may take one or more of the following steps, depending on the case: remove the content, disable access to it, limit its visibility, suspend relevant account features, or suspend or terminate the account of the user responsible for the content.
When the law requires it, we will inform the affected user of the action taken and provide a statement of reasons. That statement identifies the content acted on, the rule or legal ground relied on, and the rights holder or category of right concerned, so the user can decide whether to appeal. We do not forward your email address, postal address, signature, or other direct contact details to the affected user unless the law requires it. If you would prefer to file under a pseudonym or through an authorised representative (such as a law firm or rights-protection service), say so in your complaint.
If you provide contact details, we may also tell you the outcome of your complaint, subject to legal, privacy, security, and confidentiality limits.
## Appeals and complaints about decisions
If you believe your content was removed or restricted in error, or by mistake or misidentification, you can challenge that decision.
An appeal should identify the content affected (and, where possible, its location before removal or restriction), explain why you believe the decision was incorrect, give your full name and email address, and include your physical or electronic signature.
We review appeals promptly, carefully, and fairly. When the law requires it, appeals receive human review and are not decided only by automated systems.
Depending on the law that applies, you may also have further rights, including the right to pursue the matter through courts or other dispute resolution routes.
## Misuse of the reporting process
You must not knowingly submit false, misleading, malicious, abusive, or duplicative complaints. You must not ask others to submit duplicate reports about the same content where a complaint has already been made on your behalf.
Misuse of Fluxer's reporting or complaints procedures may result in rejection of the report, and may lead to action against the relevant account, including suspension where appropriate.
## Repeat infringement
We may suspend or terminate the accounts of users who repeatedly infringe copyright or other intellectual property rights. When deciding, we may consider the nature, seriousness, frequency, and circumstances of the alleged infringements, together with any legal obligations that apply to us.
## Other rights and remedies
Nothing in this policy limits any rights or remedies available under applicable law. We may preserve relevant information and cooperate with competent authorities, courts, or other lawful processes when the law requires or allows it.
## Contact
Submit copyright and intellectual property complaints through the report form at [web.fluxer.app/report](https://web.fluxer.app/report) or by email to [copyright@fluxer.app](mailto:copyright@fluxer.app).
@@ -1,52 +0,0 @@
We keep as little as we can, for as short a time as we can, and you can delete most things yourself. Our [Privacy Policy](/privacy) is the main reference for what we collect and how long we keep it. Section 7 covers retention in full; this article summarises the points people ask about most.
## What we keep while your account is active
While your account exists, we hold the things needed to run it: your username, email address, password hash, date of birth, and the content you have created (messages, uploads, Communities, profile details). You can delete most of this yourself at any time, and you can close the account entirely.
A few things are commonly assumed to be kept that we do not actually keep:
- **Phone numbers.** Phone verification is handled by Twilio. The number is not written to your account. Internally we hold only an encrypted marker for about 30 days, with no user ID attached, used solely to stop the same number being reused more than twice during a suspicious-registration check. After that window the marker expires.
- **Card numbers.** Payments go through Stripe. We never see or store the full card number.
- **Message content for analytics or AI.** Aggregate metrics count events, not content. Nothing you share is used to train AI models.
## What you can remove yourself
- **Individual messages and attachments.** Delete them in the app. Deleting a message also deletes its attachments.
- **All your messages in bulk.** Privacy Dashboard > Data Deletion. See [requesting data deletion](/help/data-deletion).
- **Your whole account.** Settings > Account. See [how to delete or disable your account](/help/delete-account). After a 14-day grace period (during which signing in cancels the deletion), the account is removed.
- **A specific piece of data.** Email <privacy@fluxer.app> from your registered address.
## What happens when you delete something
- **A message or account record** is removed from active systems within minutes. It may persist in encrypted backups for up to 30 days, then it is permanently removed.
- **An attachment** is removed from active storage within hours. It is recoverable by authorised operators for up to 24 hours for disaster recovery only, then permanently erased. Attachments are not included in our long-term backups.
- **A Community or channel** enters a 14-day grace period during which it and everything in it (messages, attachments, roles, settings) is hidden and inaccessible, then it is permanently deleted using the rules above.
- **Your account** gets a 14-day grace period (cancellable by signing in), then identifying data is removed. Backup purge follows the 30-day cycle above.
Content you sent in Communities or direct messages may remain visible to the other people who received it after your account is gone, but it is no longer linked to you. If you want it gone first, delete it before closing the account or choose the message-deletion option during account deletion.
## What we keep longer, and why
A few kinds of data outlive your account, but only for specific, narrow reasons:
- **Report snapshots.** When someone reports a message, user, Community, or invite, we snapshot the reported item so there is a stable record for investigation and appeals. Snapshots live in an isolated bucket, are not served to users or included in exports, and are deleted after one year. Deleting the original does not remove the snapshot during that window.
- **Security and usage logs.** Up to 90 days under normal conditions. Specific logs may be kept longer only for an active security investigation, a legal obligation, or an ongoing dispute.
- **Audit logs.** Records of administrative actions and enforcement decisions are kept as long as needed for accountability and appeals, and reviewed periodically.
- **Payment and transaction records.** Kept at least seven years, as Swedish bookkeeping law (Bokföringslag 1999:1078) requires. Full card numbers are not stored.
- **Photo IDs sent to support** (for an age appeal or a date-of-birth correction): deleted within 60 days after the request is closed.
- **Support correspondence.** Held in Intercom for as long as needed to handle the conversation and any follow-up, then deleted on review.
- **Backups.** Encrypted, off-site, kept on a rolling cycle of up to about 30 days, then overwritten.
- **Legal records.** Anything else we are specifically required by law to keep, for the period the law requires.
Aggregated or anonymised information that can no longer identify you may be kept indefinitely to understand service trends.
## Inactive accounts
Accounts may be scheduled for deletion after two years of inactivity, with advance notice to the registered email address before deletion proceeds. See [how to delete or disable your account](/help/delete-account) for the exact criteria and notice schedule.
## Your rights
You can export your data, delete your messages, and close your account from the Privacy Dashboard and account settings. Anything you cannot do through the app can be requested at <privacy@fluxer.app> from your registered email.
If you are in the EEA or UK, the GDPR also gives you rights of access, rectification, erasure, restriction, portability, and objection, as well as rights around automated decisions. California residents have parallel rights under CCPA/CPRA. Both are described in section 10 of the [Privacy Policy](/privacy), along with how to exercise them and how to lodge a complaint with your local supervisory authority.
@@ -1,22 +0,0 @@
This guide explains how EU users covered by the European Union's Digital Services Act (DSA) can exercise certain rights. The DSA only applies to online platforms and to specific types of decisions, so not every report or action on Fluxer is covered by it.
## Reporting illegal content under the DSA
The quickest way to report content that breaches our [Terms of Service](/terms) or [Community Guidelines](/guidelines) is through the in-app reporting tools. EU users covered by the DSA can also flag illegal content through our web form at [web.fluxer.app/report](https://web.fluxer.app/report).
## DSA appeal rights
If the DSA applies to you and you disagree with one of the decisions listed below, you have six months from the date on the violation notice to appeal through our internal appeals process. The appeal link is on the notice itself.
The DSA appeals process only applies to decisions based on a finding that the information provided is illegal or breaches our [Terms of Service](/terms), such as:
- removing or disabling access to content, or restricting its visibility;
- suspending or terminating access to Fluxer, either in whole or in part;
- suspending or terminating a user's Fluxer account; or
- suspending, terminating, or restricting a user's ability to monetise their Fluxer activity.
## Out-of-court dispute settlement
You may also have the option to choose an out-of-court settlement body certified by a Digital Services Coordinator in an EU Member State to help resolve a dispute about any of the decisions listed above. [The European Commission](https://commission.europa.eu/index) maintains a list of those settlement bodies as they become certified.
Fluxer will cooperate with such a settlement body where required by law, but we are not bound by the decisions they issue. We also reserve the right not to engage with an out-of-court settlement body if the same dispute (same information and grounds) has already been resolved.
@@ -1,81 +0,0 @@
Some regions have enacted laws that require online platforms to verify users' ages. We believe an age check should not cost you your privacy, so we do not offer invasive methods such as mandatory government-issued ID uploads, biometric scans, or mandatory third-party age verification services. Where the law allows a less invasive method, we may offer it. Where the law does not give us a reasonable option, we restrict access to Fluxer from affected regions, or we restrict specific content categories without offering a verification path.
Fluxer is a community chat app, not a social media service or an adult content website. Many age verification laws define those terms in ways that do not cover an app like ours. We assess each law individually and apply restrictions only where we believe the law applies to Fluxer.
## How we determine your region
We use IP geolocation to determine your approximate location when you connect. The lookup runs against a local MaxMind GeoIP database that we download periodically and query entirely on our own servers, so no per-connection request leaves our infrastructure. Only the IP address is involved, no account identifiers are attached, and the result is used only to decide whether regional access rules apply. See [section 3.2 of our Privacy Policy](/privacy) for the underlying processing rules.
IP geolocation is not perfect. If you are travelling, using a VPN, or your IP address does not reflect your actual location, you may be affected by restrictions that do not apply to your home region.
## Currently affected regions
If a region is not listed here, no restriction currently applies.
### Mississippi (United States)
Restriction: full service. You cannot register, sign in, or access Fluxer from this region.
Mississippi's HB 1126 (Walker Montgomery Protecting Children Online Act) applies to services that allow users to socially interact, create profiles, and post content viewable by others, including in chat rooms. In our reading, this is broad enough to cover Fluxer. The law's messaging exemption only covers services that facilitate "only" email or direct messaging, which does not apply to a service with Communities and channels. The law requires age verification for all users and parental consent for users under 18.
The law was initially blocked by a district court, but the Fifth Circuit reversed that decision in April 2025, and in August 2025 the Supreme Court declined to re-block it. The constitutional challenge is ongoing, but the law is enforceable while litigation continues.
If you have an existing account, your data remains intact. If the restriction is lifted, or you access Fluxer from an unrestricted region, your account will be available as normal.
### United Kingdom
Restriction: age-restricted content only. You can register, sign in, and use Fluxer normally without adult verification, but NSFW channels and age-restricted Communities stay hidden unless you choose to verify your account as an adult under the UK flow.
The Online Safety Act 2023 regulates "user-to-user services", broadly covering any internet service where content from one user can be encountered by another. Unlike many US laws, it does not use "social media" as a legal category. The Act exempts email, SMS, and one-to-one phone calls, but not messaging or group communication services. In our reading, Fluxer falls under Part 3 of the Act, which requires age assurance for specific harmful content rather than age-gating the entire service. Because Fluxer's purpose is communication rather than hosting pornographic content, we restrict access to NSFW content only unless a UK user completes adult verification.
In the UK, users who are 18 or older can verify adult status by completing a $0.00 payment authorisation. This method is available in the UK only and currently accepts credit cards only. The check is entirely optional and is used only to unlock content marked as adult-only; we do not require it anywhere else on Fluxer. We are looking into alternatives for UK users without a credit card.
Enforceable since 25 July 2025.
### Brazil
Restriction: age-restricted content only. You can register, sign in, and use Fluxer normally, but NSFW channels and age-restricted Communities are hidden and inaccessible from this region. Unlike the UK, there is no adult verification option to unlock them.
Brazil's Digital ECA (Lei 15.211/2025), enforceable since 17 March 2026, applies to any digital service likely to be accessed by minors, including messaging and communication services. The law bans self-declared age verification and requires auditable age assurance mechanisms. Because Fluxer does not collect government IDs, biometric data, or CPF numbers, we cannot offer a compliant verification path. We restrict NSFW content entirely for users connecting from Brazil instead.
## Laws that do not apply to Fluxer
Several US states have enacted age verification laws whose definitions do not cover Fluxer. We do not restrict access in these states.
### Social media laws
These laws typically define a "social media platform" using criteria such as algorithmic content feeds, public profiles, or public social connection lists. Fluxer displays messages chronologically (no algorithmic feed), keeps friend lists private, and is primarily a communication tool rather than a content publishing or social networking platform.
- Tennessee (HB 1891): the law targets services where users communicate "through posts" made available for others to "consume". We read "consume" as implying passive content viewing rather than active conversation. The law does not mention chat rooms or messaging, and its title, framing, and litigation all focus on traditional social media.
- Florida (HB 3): requires platforms to use algorithms that analyse user data to select content. Fluxer does not do this.
- Virginia (SB 854): requires users to populate a public list of social connections. Fluxer's friend lists are private.
### Adult content laws
These laws target websites whose business involves hosting adult content. Fluxer's business is providing communication tools. We do not create, market, or profit from adult content. NSFW content on Fluxer is optional, user-generated, and a small fraction of activity on Fluxer.
- South Dakota (HB 1053) and Wyoming (HB 43): "regular course of business" standard. Hosting adult content is not part of ours.
- Ohio (ORC 1349.10, via HB 96): "significant or substantial portion" threshold. NSFW content is a small fraction of Fluxer's content. The law also exempts interactive computer services under Section 230.
- Arizona (HB 2112): one-third content threshold, well above Fluxer's NSFW fraction.
### Social media laws blocked by courts
Louisiana (HB 440), Arkansas (SB 396 / Act 689), Ohio (HB 33), Georgia (SB 351), Utah (SB 194 / HB 464), and Texas (HB 18) have all been enjoined. We will reassess if any of them take effect.
## Why we take this approach
Implementing invasive age verification requires collecting sensitive personal data, including government IDs, from every user in places such as Mississippi. For a small, independent service this is not feasible without significant resources, and it introduces new privacy and security risks. We believe these systems are disproportionate and create new attack surfaces for data breaches.
Where we can comply by restricting specific content, or by using lower-friction methods that do not collect government IDs (such as the UK-only $0.00 credit card adult verification), we do so. Where the law does not leave us those options, we restrict access entirely. Where a law does not cover a service like Fluxer, we do not restrict access unnecessarily.
We monitor legislative developments and court decisions, and we update this page whenever we add, change, or remove a restriction.
## What to do if you think a restriction is wrong
If you believe your access has been restricted incorrectly, for example because you are travelling or using a VPN, please contact us at [privacy@fluxer.app](mailto:privacy@fluxer.app) with your username and a short description of the issue.
Under applicable data protection laws (such as GDPR), you may have the right to obtain human review of automated decisions that significantly affect you. We honour those rights as described in [section 10 of our Privacy Policy](/privacy).
## Contact
For questions about regional restrictions, write to [privacy@fluxer.app](mailto:privacy@fluxer.app). For general information about how we handle your data, see our [Privacy Policy](/privacy).
@@ -1,69 +0,0 @@
> This promotion ended on 21 March 2026. You can no longer get Plutonium through it.
Fluxer ran a limited March 2026 promotion with two parts: a free Plutonium trial for people who did not already have Plutonium, and gift codes for existing Plutonium subscribers to share with friends.
## Free Plutonium trial
If you did not have an active Plutonium subscription or lifetime Plutonium (Visionary), you may have qualified for a 7-day free trial that unlocked all [Plutonium](/plutonium) benefits at no cost.
### Who qualified
- Your account was created before 28 February 2026 at 12:00 UTC.
- You did not have an active Plutonium subscription or lifetime Plutonium.
- You signed in before 7 March 2026 at 12:00 UTC.
### How it worked
1. Sign in to Fluxer before the cut-off.
2. If you qualified, you received a direct message confirming that your free trial had started and when it would expire.
3. Use all Plutonium perks for 7 days.
The trial was applied automatically. There was nothing to redeem or activate manually.
### After the trial
When your 7-day trial ended, your account returned to the free tier automatically. No payment details were collected, and you were not charged. If you would like to keep Plutonium, you can subscribe at any time from User Settings > Plutonium.
## Gift codes for existing subscribers
If you already had Plutonium, you received 3 free gift codes, each redeemable for 1 week of Plutonium. You can share these with friends or redeem them yourself.
### Who qualified
- Your account was created before 28 February 2026 at 12:00 UTC.
- You started an active Plutonium subscription or purchased Visionary before 28 February 2026 at 12:00 UTC.
- You signed in before 21 March 2026 at 12:00 UTC.
### How it worked
1. Sign in to Fluxer while the campaign was active.
2. Your 3 gift codes were generated automatically and appeared in your Gift Inventory (User Settings > Gift Inventory).
3. Share a gift code URL with someone, or redeem a code on your own account.
Each code is single-use. Once redeemed, it grants the recipient 1 week of Plutonium.
## Frequently asked questions
### Did I need to do anything to claim my trial or gift codes?
No. Signing in to Fluxer while the promotion was active was enough; people who qualified received their trial or gift codes automatically.
### Could I get both the free trial and the gift codes?
No. The free trial was for users without Plutonium, and the gift codes were for users who already had it.
### I created my account after 28 February 2026. Did I qualify?
No. Both parts of the promotion required your account to have been created before 28 February 2026 at 12:00 UTC.
### What happens if I do not use my gift codes?
Gift codes do not expire on their own, but they were only generated while the campaign window was open. You needed to sign in before the deadline so that your codes could be created.
### Did the free trial auto-renew or charge me?
No. The trial ended after 7 days with no automatic renewal and no payment required. You can upgrade at any time, but there is no obligation.
### I have Visionary. Did I get gift codes?
Yes. Visionary counts as lifetime Plutonium, so you qualified for the gift code campaign as long as you met the other requirements.
@@ -1,33 +0,0 @@
Fluxer Visionary was a limited, one-off lifetime Plutonium offer for early supporters. It opened on 25 October 2025 and was meant to run until October 2026, or until 1,000 slots sold out. The slots sold out on 17 February 2026, much earlier than expected, so the offer closed and is no longer available.
Each Visionary slot cost $299 and gives the same benefits as a Plutonium subscription, with no ongoing payments.
## Numbered badge
Each Visionary account is assigned a sequence number based on purchase time. You can choose to show a numbered badge on your public profile so others can see how early you supported Fluxer. The badge is optional, and you can turn it on or off in User Settings > Profile.
## Benefits
Visionary includes all current and future [Plutonium](/plutonium) benefits for as long as Fluxer runs. There are no renewals and no expiry.
## Frequently asked questions
### Can I still buy a Visionary slot?
No. Visionary sold out on 17 February 2026 and is not coming back.
### Does Visionary expire?
No. It lasts for as long as Fluxer runs, and you will not be charged again.
### Can I transfer Visionary to someone else?
No. Visionary cannot be transferred. During the sale window you could buy gifts, and those carry an engraved slot assigned to whoever redeems the gift.
### What if I delete my account?
Your Visionary status is tied to your account. If you delete your account, the benefit is removed permanently and cannot be refunded.
### Is Visionary the same as Plutonium?
Visionary is lifetime Plutonium plus extras. You get everything a Plutonium subscriber gets, the numbered Visionary badge, the right to use the #0000 discriminator, access to the Fluxer Visionaries Community, and an Operator Pass. The Operator Pass unlocks the Fluxer Operators Community, a smaller place for self-hosters to get help from each other and the Fluxer team, share ideas, and make feedback heard before it gets buried on GitHub. Think of it like donating with perks: it helps keep Fluxer going.
@@ -1,37 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Community Lead
Fluxer is an open-source chat app for text, voice, and communities, built for people who want a chat product that respects their time and data. The company is based in Sweden, and our employees and contractors work remotely across countries. We are a small team, so people are expected to own their work, communicate clearly, and stay close to the users affected by their decisions. The [code is public](https://github.com/fluxerapp/fluxer), so you can read how the product is built before you apply.
### What this role is
You would keep Fluxer's public voice and community spaces clear, active, and useful. You would talk with people who use the app, share what the team is working on, collect feedback, and help the team understand what people are excited about or frustrated by. Because Fluxer is open source, many of the best conversations happen in public, with people who care enough to read the code, ask hard questions, and suggest changes. Your job is to keep those conversations healthy: friendly, honest, and connected to making the app better.
### What you would actually be doing
- Running our social presence, including Bluesky, in a voice that sounds like someone who understands the product and the people using it
- Keeping our official communities welcoming, active, and useful for new users, regulars, contributors, and moderators
- Working with our volunteer moderators: backing them up, giving them clear guidance and a direct line to the team, and noticing burnout early
- Writing launch notes, changelogs, community spotlights, status updates, and posts that explain what changed or what we are fixing
- Turning long, messy feedback threads into clear notes the team can act on
- Hosting AMAs, feedback sessions, and small community events people actually want to join
- Noticing when the mood changes around a feature, policy, outage, or announcement, and telling the team early
- Coordinating with support, safety, and legal when a community issue turns into a support issue, policy question, press question, or escalation
### What makes someone good at this
- You write strong, clear English and know when a joke helps and when it gets in the way
- You understand how online communities work: excitement, frustration, inside jokes, recurring questions, and the speed at which a small issue can become the whole conversation
- You have run a social or community presence for an app, project, or group of real people
- You can reply calmly to someone who is upset, someone who is confused, and someone who is both at the same time
- You are comfortable in chat-based communities such as Fluxer, Discord, Slack, and forums
### Other things we would be glad to see
- Open-source or volunteer-led community experience, where nobody reports to you and everything runs on persuasion
- Content skills: images, short video, livestreams, or the ability to make something look intentional in under an hour
- Additional languages beyond English
### Who you would work with
You would be our main community voice, working closely with support and trust & safety, who often see hard conversations first, and with design and engineering, who build the features people are talking about. Small team, low ceremony, written things over meetings.
@@ -1,47 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Platform Engineer
Fluxer is an open-source chat app for text, voice, and communities, built for people who want a chat product that respects their time and data. The company is based in Sweden, and our employees and contractors work remotely across countries. We are a small team, so people are expected to own their work, communicate clearly, and stay close to the users affected by their decisions. The [code is public](https://github.com/fluxerapp/fluxer), so you can read how the product is built before you apply.
### What this role is
Platform engineers keep Fluxer's production systems reliable, observable, and straightforward to operate. The work covers real-time messaging, voice, media pipelines, queues, storage, deploys, certificates, databases, and the dashboards that help the team understand what is happening.
The backend services are TypeScript and Rust, the real-time infrastructure is Erlang/OTP, and performance-sensitive parts of the stack tend to be Rust. We expect platform work to include ownership. If you ship a deploy pipeline, the rollback story has to be clear. If an alert pages the team, acknowledge it and then make the system clearer or more reliable.
### What you would actually be doing
- Keeping CI/CD reliable, repeatable, and reversible
- Running container orchestration, deploys, rollbacks, migrations, and the operational details around them
- Tuning and recovering production databases, relational and wide-column, including during incidents
- Operating the real-time path, which runs on Erlang/OTP: WebSocket fan-out, voice media servers and SFUs, TURN and STUN, and edge points of presence
- Building reliability targets, capacity plans, and alerts that still mean something a month later
- Making metrics, logs, traces, and dashboards useful before an incident
- Writing runbooks that are accurate when someone needs them under pressure
- Investigating performance problems across services, queues, storage, and networks
- Owning secrets, certificates, DNS, TLS, and the security basics that keep production healthy
- Running post-incident reviews and following through on the action items
Most of this ships as visible commits in the public repo, so clear changes and clear explanations matter.
### What makes someone good at this
- You have spent real time in infrastructure, platform engineering, SRE, or production operations
- You are at home in Linux and a terminal, and comfortable with containers and deployment automation
- You understand distributed systems and the failure modes that come with them
- You have a working relationship with observability tools and the patience to make them useful
- When something breaks, you can separate evidence from guesses and move the team toward a fix
### Other things we would be glad to see
- Experience operating Erlang/OTP in production, or another actor runtime such as Elixir or Akka
- Reproducible build systems such as Nix
- Reading or writing Rust and TypeScript comfortably, since that is most of what you would be running
- S3-compatible object storage, CDN configuration, or experience delivering large amounts of media
- Real-time infrastructure depth: WebRTC, SFUs, media servers, and the specific patience they require
- The kind of network debugging where the answer turns out, once again, to be certificates
- On-call experience, especially the kind that left the system better than you found it
### Who you would work with
You would work alongside the engineers building the app, and with support, safety, and legal whenever an infrastructure decision affects users, policy, or compliance. Small team, real ownership, and a strong bias toward fixing causes rather than learning to live with symptoms.
@@ -1,42 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Privacy & Legal Counsel
Fluxer is an open-source chat app for text, voice, and communities, built for people who want a chat product that respects their time and data. The company is based in Sweden, and our employees and contractors work remotely across countries. We are a small team, so people are expected to own their work, communicate clearly, and stay close to the users affected by their decisions. The [code is public](https://github.com/fluxerapp/fluxer), so you can read how the product is built before you apply.
### What this role is
Legal and privacy work at Fluxer sits close to the product. It shows up in app decisions, data subject requests, copyright disputes, vendor contracts, law-enforcement process, policy drafts, and regulatory changes. We are headquartered in Sweden and built for people everywhere, so the work sits where EU data protection, platform regulation, intellectual property, and cross-border requests meet. The right person can turn legal requirements into advice the team can actually use: precise, grounded, and understandable.
### What you would actually be doing
- Handling GDPR data subject requests under the Dataskyddslagen (Lag 2018:218): access, deletion, portability, rectification, and the unglamorous follow-through behind each one
- Running our notice-and-action and statement-of-reasons processes under the Digital Services Act (Articles 16, 17, and 20), keeping our point of contact and complaint handling reliable, and producing Article 24 transparency reporting
- Managing copyright takedowns, counter-notices, and edge cases around user-generated content
- Reviewing law-enforcement requests, preservation demands, and production orders, and getting us ready for the e-Evidence Regulation (EU) 2023/1543, which applies from 18 August 2026 and arrives with European Production and Preservation Orders, an addressee to designate, and response clocks (10 days as standard, 8 hours in an emergency) that do not care it is a holiday
- Keeping track of evolving EU platform and privacy obligations, including DSA implementing rules and ePrivacy developments, and turning changes into clear next steps
- Drafting and maintaining privacy notices, terms, Community Guidelines, policy pages, and internal guidance people can understand
- Helping engineering and design think about privacy early enough to avoid avoidable rework
- Reviewing data processing agreements, subprocessors, and related compliance documentation
- Talking to regulators, outside counsel, and internal teams, including IMY on data protection and PTS as Sweden's Digital Services Coordinator, when something needs a careful and precise answer
### What makes someone good at this
- You have solid working knowledge of GDPR and EU data protection law, and you are comfortable with how it plays out under Swedish implementation rather than only in theory
- You have handled DMCA-style takedown workflows and the day-to-day side of intellectual property disputes
- You have worked with real law-enforcement requests or legal process and did not panic
- You can explain legal concepts clearly to engineers, support, and safety
- You can track many deadlines without losing the important details
- You have sound judgement about when to act, when to escalate, and when to say "we should not say anything until we know what we are talking about"
### Other things we would be glad to see
- Swedish data protection experience, including dealings with IMY
- A clear grip on the DSA: what it actually requires of a service our size, including where summaries overstate or flatten it
- Familiarity with the Terrorist Content Online Regulation (EU) 2021/784 and its one-hour removal-order mechanic
- Privacy work in open-source or federated software contexts
- CIPP/E, CIPM, or an equivalent privacy credential
- Swedish or other additional languages. The work itself can be done in English, so Swedish is a bonus rather than a requirement
### Who you would work with
You would be our central point for legal and privacy, working closely with trust & safety on hard cases, with engineering and design before product decisions ship, and with support on the first version of many user requests. Small team, direct lines, and a preference for advice that ends in a decision.
@@ -1,45 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Product Engineer
Fluxer is an open-source chat app for text, voice, and communities, built for people who want a chat product that respects their time and data. The company is based in Sweden, and our employees and contractors work remotely across countries. We are a small team, so people are expected to own their work, communicate clearly, and stay close to the users affected by their decisions. The [code is public](https://github.com/fluxerapp/fluxer), so you can read how the product is built before you apply.
### What this role is
Product engineers build the parts of Fluxer people use every day: messages, channels, calls, notifications, settings, desktop behavior, mobile flows, and the backend services behind them.
The backend services are TypeScript and Rust. The real-time infrastructure is Erlang/OTP. The desktop client is Electron, with Rust underneath. The mobile app is Flutter. You do not need to know all of that on day one, but you should be comfortable learning across boundaries.
The role is intentionally broad. Some weeks you will work on a client feature, some weeks on an API, a performance investigation, or a bug that crosses several parts of the stack. Because the work is public, we care about clear code, clear reasoning, and changes that are understandable after they ship.
### What you would actually be doing
- Shipping features across web, mobile, desktop, and backend
- Building APIs and services that support real product workflows
- Working with design from rough sketches through implementation details
- Helping decide what is worth building, what should wait, and which constraints are acceptable
- Reading support and moderation feedback directly, so product decisions stay connected to real user problems
- Improving performance, accessibility, internationalisation, and the small interactions that make the app feel reliable
- Reviewing code thoughtfully and helping keep the codebase understandable
- Working in public, where contributors and users can see, clone, and discuss what you ship
### What makes someone good at this
- You have shipped real software, professionally or through open source and side projects
- You are comfortable on at least one modern client stack (ours are a web and Electron client and a Flutter app) and curious enough to pick up whatever else turns up
- You have built and maintained REST or RPC APIs that real users depend on
- You can orient yourself in a large, multi-package codebase without a guided tour
- You care about how a feature feels to use as well as whether it technically works
- You are pragmatic: ship useful improvements, learn from them, and keep making them better
### Other things we would be glad to see
- Experience with any part of our stack: TypeScript or Rust on the backend, Erlang/OTP for real-time, Electron and Rust on the desktop, or Flutter on mobile
- Experience with actor runtimes such as Erlang/OTP or Elixir, since the real-time layer is built on Erlang/OTP
- WebSocket protocols, long-lived connections, reconnect logic, and the many small ways those go wrong
- Production database experience, relational or wide-column, at a scale where you have opinions about indexes
- Open-source or community-driven development
- Internationalisation (i18n) in large applications
### Who you would work with
You would work across the whole team: design from sketch to ship, platform engineering on the systems behind the app, and support and safety on the problems users are actually hitting. Small team, low ceremony, and enough trust that you help shape the problem rather than only implement the answer.
@@ -1,42 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Support Specialist
Fluxer is an open-source chat app for text, voice, and communities, built for people who want a chat product that respects their time and data. The company is based in Sweden, and our employees and contractors work remotely across countries. We are a small team, so people are expected to own their work, communicate clearly, and stay close to the users affected by their decisions. The [code is public](https://github.com/fluxerapp/fluxer), so you can read how the product is built before you apply.
### What this role is
Support is often the first place users tell us something is broken, confusing, or unfair. You would be the first real reply: clear, kind, curious, and able to make people feel like a person is helping them.
The job goes beyond closing support conversations. We want someone who notices patterns, reproduces bugs, writes useful help articles, tells the team what people keep tripping over, and helps us build an app that needs less explaining over time.
### What you would actually be doing
- Replying to users over email and in community channels with clear, personal answers
- Triaging and reproducing bugs, then writing them up clearly enough that an engineer can act on them
- Spotting repeated questions and turning them into app fixes, documentation, or clear notes for the team
- Writing help centre articles and FAQs that answer the question the person actually has
- Helping with account access, billing, login problems, and common product confusion
- Handling the harder conversations: frustrated users, appeals, refund requests, and the threads where someone has tried everything and is now just tired
- Keeping response times honest while still giving people thoughtful answers
- Working with trust & safety when a support issue turns into a safety issue, and with legal when a request turns out to be a data subject request
### What makes someone good at this
- You have done user support, customer success, community support, or closely related work
- You write clearly and avoid sounding like a template
- You are patient with upset people, and you have healthy boundaries, so you can care about the work without it following you home
- You are curious enough to investigate a problem instead of forwarding it too early
- You have good judgement about when to fix, escalate, refund, explain, or simply say "you are right, that is our fault"
- You are comfortable in a help desk such as Intercom, Zendesk, Front, HelpScout, or Plain
### Other things we would be glad to see
- A background at a chat, social, or developer-facing app
- Technical instincts: logs, repro steps, screenshots, environment details, and bug reports an engineer can act on
- A track record of writing help articles people actually find useful
- Comfort in community spaces like Fluxer, Discord, or forums, wherever people gather to talk about software they care about
- Additional languages beyond English
### Who you would work with
You would work most closely with the engineers building and running the app, since you will often be the first to know something is broken, with trust & safety on reports that turn serious, and with the community lead on questions that keep recurring in public. We expect the whole team to stay close to support and act on the patterns you raise.
@@ -1,46 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Trust & Safety Specialist
Fluxer is an open-source chat app for text, voice, and communities, built for people who want a chat product that respects their time and data. The company is based in Sweden, and our employees and contractors work remotely across countries. We are a small team, so people are expected to own their work, communicate clearly, and stay close to the users affected by their decisions. The [code is public](https://github.com/fluxerapp/fluxer), so you can read how the product is built before you apply.
### What this role is
Trust and safety at Fluxer covers casework, policy, tooling, escalation paths, appeals, pattern recognition, and enough product understanding to notice when a safety problem is also a design problem. The work can be heavy. You will see abusive behavior, urgent reports, and situations where the facts are incomplete. We take that seriously, and we say more about it below. The right person brings steady judgement, clear writing, firm boundaries, and care for the people using Fluxer.
This role involves regular exposure to distressing material, including illegal content and other exploitative, violent, or hateful things people report to us. We do provide the wellbeing support, rotation, and boundaries that make it sustainable to do well over time, and we would rather tell you now than have you find out in week two.
### What you would actually be doing
- Investigating reports of abuse, harassment, spam, scams, illegal content, and policy violations
- Making proportionate enforcement decisions, from warnings and removals to restrictions, suspensions, and bans, with clear reasoning behind each one
- Running notice-and-action and writing the statements of reasons we owe users under the Digital Services Act (Articles 16 and 17), and handling internal complaints under Article 20
- Handling appeals with clarity and respect, including the ones where the answer is still no
- Removing illegal content once it is reported, preserving what needs preserving, and handing it to legal and, where the law requires, to the relevant authorities
- Acting on removal orders that arrive from competent authorities, including the one-hour clock under the Terrorist Content Online Regulation
- Identifying repeat abuse patterns and turning them into policy fixes, better review tooling, or changes to the app
- Updating policies and internal guidance as real cases reveal where the current rules are vague, outdated, or incorrect
- Escalating urgent cases, such as credible threats or imminent harm, to legal and, where required, to authorities
- Documenting decisions well enough that someone reviewing the case six months from now can follow your reasoning without having to find you
- Working with engineering on safety tooling, the report queue, and audit trails that hold up when someone reviews a decision later
### What makes someone good at this
- You have worked in trust & safety, content moderation, abuse operations, or policy operations
- You understand how online abuse works on messaging and community platforms
- You make consistent, fair decisions when the clock is ticking and the facts are incomplete
- You write clearly, for users, for teammates, and in case notes
- You have emotional resilience and clear boundaries around distressing content
- You notice the small details that decide whether a decision is correct or merely close
- You are comfortable in moderation tools, ticketing systems, queues, or investigation workflows
### Other things we would be glad to see
- A background at a messaging, social, or creator app or service
- Familiarity with EU platform regulation, especially the DSA and how its notice-and-action, appeals, and transparency duties work in practice
- An understanding of how the reporting and escalation of illegal content works once something has been flagged
- Pattern recognition across reports, behaviour, and abuse signals
- Additional languages beyond English, which help with reports, appeals, and abuse patterns in more contexts
### Who you would work with
You would work closely with legal and privacy on illegal content, law-enforcement process, and unsettled policy questions; with engineering on tooling and the report queue; and with support and the community lead, who often see early signals. It is a small team, which means your reasoning is visible and your judgement carries weight.
@@ -1,36 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
**Effective date:** 2026-04-25
This page records significant changes to our [Terms of Service](/terms), [Privacy Policy](/privacy), and [Community Guidelines](/guidelines). Current versions are linked in the footer.
## 2026-04-25
**Privacy Policy**
- Clarified that phone numbers used for account verification are not linked to Fluxer accounts or user IDs. Successful phone verification now stores only a `has_verified_phone` account flag.
- Added the retention rule for phone verification reuse prevention: Fluxer keeps an encrypted internal marker for about 30 days, used only to allow the same phone number to verify at most twice in that period, with encryption keys rotated roughly every 30 days.
- Clarified that phone verification is used for suspicious registration anti-spam checks, not as a general identity or account-linking system.
- Clarified that SMS-based 2FA is not available for accounts registered on or after 25 April 2026.
## 2026-04-18
**Privacy Policy**
These changes clarify vendors, hosting, retention, and content-safety processing. None adds a new purpose for personal data or a new category of data leaving Fluxer.
- Updated the sub-processor list to match current infrastructure. Cloudflare, OVHcloud, Hetzner, Better Stack, and Sentry were removed because they are no longer used to process personal data. Porkbun was also removed because it handles domain registration only and does not process Fluxer users' personal data (sections 2.3, 4.2, 6.1, 9, 17.4).
- Rewrote Section 3.2 on IP geolocation. Fluxer now prefers fully local geolocation databases where they are sufficient. IPinfo is used only for registration and abuse-prevention checks that need IP network signals a local database cannot reliably provide, such as VPN provider, commercial proxy, Tor exit-node status, residential-proxy use, and related risk indicators.
- Clarified that IPinfo receives only the IP address being looked up. No account identifier, user identifier, session token, device context, message content, or other Fluxer user data is sent. Responses are cached on Fluxer systems, so repeat checks for the same IP do not go out over the network during the cache window.
- Removed the separate Backblaze sub-processor entry from Sections 4.2, 6.1, and 17.4. Off-site database backups are still kept with a storage provider for disaster recovery, but those backups are encrypted with keys held only by us. The provider cannot read, index, or otherwise process the contents, so we do not treat it as a sub-processor under GDPR Article 28 (section 6.1).
- Clarified error monitoring and observability. Metrics, logs, and traces run on Fluxer-controlled infrastructure, and application error or crash data is not sent to a third-party monitoring service (section 4.2).
- Updated Section 6.1. Primary hosting and object storage for user-uploaded files both run on Vultr in Piscataway, New Jersey, USA. Voice and real-time communication servers also run on Vultr across multiple regions worldwide so calls can be handled from a region close to you. Bunny.net continues to run the user-content CDN on `fluxerusercontent.com`.
- Added a 24-hour safeguard for accidentally deleted attachments. Deleted media is retained non-visibly in user-content object storage for up to 24 hours so it can be recovered from a bad bulk-delete or similar accident. After that window, and once CDN caches have been purged, it is permanently gone (section 7.3).
- Added Section 7.8 on snapshots of content reported through the in-app report feature. Snapshots are stored in an isolated bucket, accessible only to authorised trust-and-safety and engineering staff, audit-logged, retained for up to 1 year, then deleted automatically. If specific evidence must be preserved for longer to meet a binding legal obligation, we keep only what the law requires (sections 7.8, 7.9).
- Rewrote Section 5 to put the top-line position first: Fluxer does not use AI to scan your messages, files, voice calls, or anything else you share. The explicit-content classifier is a small, non-AI image model ([OpenNSFW2](https://github.com/bhky/opennsfw2)) that runs locally on our servers, does not contribute to AI training, and exists only to respect explicit-content preferences. Added the equivalent "no AI reads your content" position to the top of the policy.
- Updated the California disclosures table in Section 17.4 to match the new sub-processor list, including IPinfo for registration and abuse-prevention IP network signals and removing Backblaze.
## 2026-04-02
**Terms of Service**
- Added a clause clarifying that Fluxer is not designed or supported for safety-critical or critical-infrastructure use, and must not be relied on for military, emergency or first-response, healthcare, sanitation, utilities, or similar high-risk operations (section 3.4).
@@ -1,84 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Who we are
Fluxer Platform AB is a Swedish limited liability company, founded in Stockholm in 2025, that operates Fluxer, a community chat app.
## Company registration
- **Legal name:** Fluxer Platform AB
- **Registration authority:** Swedish Companies Registration Office (Bolagsverket)
- **Organisation number:** 559537-3993
- **Swedish VAT identification number:** SE559537399301
- **UK VAT registration number:** GB 518 3631 91
The UK VAT registration covers tax compliance on sales to UK customers. Fluxer is incorporated and operated entirely from Sweden.
## Registered address
Fluxer Platform AB
c/o Embassy House
Östgötagatan 12
116 25 Stockholm
Sweden
## Authorised representative
**Hampus Kraft**, Founder and CEO
Hampus is also the primary contact for privacy and data protection (see our [Privacy Policy](/privacy)).
## How we sustain Fluxer
Fluxer is funded by an optional premium subscription, Fluxer Plutonium. As binding commitments in our [Privacy Policy](/privacy) and [Terms of Service](/terms), we do not:
- sell advertising or maintain advertising partnerships
- sell, rent, license, monetise, or broker user data
- train AI models on user content
## Contact information
- **General support and account help:** <support@fluxer.app>
- **Privacy and data protection:** <privacy@fluxer.app>
- **Security vulnerabilities:** use our [Security Bug Bounty](/security) process
- **Copyright and intellectual property:** <copyright@fluxer.app>
- **Trust and safety:** <safety@fluxer.app>
- **Legal requests and law enforcement:** <legal@fluxer.app>
- **Account appeals:** <appeals@fluxer.app>
- **Press:** <press@fluxer.app>
- **Partnerships:** <partners@fluxer.app>
- **Accessibility:** <accessibility@fluxer.app>
- **Legal phone:** +46 79 101 18 18 (legal enquiries only; no support by phone)
For account-related support, contact us from the email address on your Fluxer account when possible.
## Official accounts
- **Bluesky:** [@fluxer.app](https://bsky.app/profile/fluxer.app)
- **GitHub organisation:** [fluxerapp](https://github.com/fluxerapp)
- **Reddit (employee accounts):** [u/Hampasaurus](https://www.reddit.com/user/Hampasaurus) and [u/Fluxer-Lilith](https://www.reddit.com/user/Fluxer-Lilith)
Only accounts listed on this page or linked from `fluxer.app` are official. Contact <support@fluxer.app> if you are unsure.
## EU Digital Services Act: single point of contact
As required by Article 11 of the EU Digital Services Act (Regulation (EU) 2022/2065):
- **Authorities, the European Commission, and the European Board for Digital Services:** <legal@fluxer.app>
- **Users contacting us about DSA questions:** <support@fluxer.app>
- **Postal address:** Fluxer Platform AB, c/o Embassy House, Östgötagatan 12, 116 25 Stockholm, Sweden
- **Phone for legal enquiries only:** +46 79 101 18 18
- **Languages:** English or Swedish
Because Fluxer Platform AB is established in Sweden, an EU Member State, no separate legal representative is required under DSA Article 13.
## Legal documents
- [Privacy Policy](/privacy)
- [Terms of Service](/terms)
- [Community Guidelines](/guidelines)
- [Security Bug Bounty](/security)
- [Changelog](/changelog)
## Communication security
Fluxer will never ask for your password, payment details, or other credentials by email. All official Fluxer emails come from addresses ending in `@fluxer.app` or `@fluxer.com`, or a subdomain of either (such as `@m.fluxer.app`). Today, `@fluxer.com` is used for staff addresses; over time, all Fluxer email will move to it. If you receive a suspicious message claiming to be from Fluxer, do not click links or provide information. Contact <support@fluxer.app> instead.
@@ -1,294 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
**Effective date:** 2026-03-10
## What these guidelines are for
Fluxer exists to help people communicate, connect, and build communities. These guidelines set the standards every user is held to. They form part of our [Terms of Service](/terms), and violations may lead to the enforcement actions described below.
These guidelines are deliberately specific: where a rule could be read more than one way, the resolution is written here.
They apply to every user, in every space on Fluxer: direct messages, Community channels, voice and video chats, profiles, statuses, custom emojis, usernames, bios, and anywhere else users interact or share content.
Communities may adopt rules stricter than these guidelines, but never more permissive. Where there is a conflict, these guidelines and our [Terms of Service](/terms) take precedence.
## The basic rule
**Treat others with respect. Behind every username is a real person who deserves basic dignity.**
If you would not want something said or done to you, do not say or do it to someone else. When in doubt, choose kindness.
## Building a good community
Fluxer is for everyone, including people from marginalised and underrepresented communities who are often made to feel unwelcome elsewhere. Help keep it that way:
**Assume good intent.** When something is unclear, ask before reacting. Misunderstandings happen, especially across languages and cultures.
**Respect identity.** Use the names, pronouns, and terms people use for themselves. If you are unsure of someone's pronouns, ask respectfully or use their username or display name.
**Use content warnings and age gates.** Label potentially distressing, graphic, or adult topics clearly and keep them in age-gated spaces.
**Set clear community rules.** If you run a Community, make your rules clear and easy to find.
**Help new members** understand Community rules. Do not dogpile or retaliate; use moderation tools instead.
**Protect privacy.** Share only what is needed, and be cautious about exposing personal information, yours or anyone else's.
**Disagree constructively.** Challenge ideas, not people. Disagreement is healthy; personal attacks, harassment, and demeaning behaviour are not.
**Report, do not retaliate.** If you see dangerous, abusive, or clearly rule-breaking behaviour, report it rather than amplify it. Retaliatory harassment is itself a violation.
## Prohibited conduct
Each section below explains the rule, gives examples, identifies exceptions, and describes how borderline cases are assessed.
### 1. Harassment and bullying
Do not harass, bully, or threaten any person or group.
**1.1 Sustained or targeted harassment.** Repeated hostile, degrading, or intimidating behaviour directed at a specific person or group, including following someone across Communities or channels to continue unwanted interactions.
**1.2 Threats.** Direct or implied threats of violence, harm, or other adverse action, including conditional threats ("if you do not do X, I will do Y").
**1.3 Doxxing.** Sharing or threatening to share someone's personal or identifying information without their explicit consent: real names, home addresses, workplaces, phone numbers, email addresses, financial information, government-issued identification, or anything else that could be used to locate, contact, or identify someone against their will. This applies even if the information is technically "public"; aggregating and weaponising public information is still doxxing.
**1.4 Unwanted contact.** Continuing to contact or interact with someone after they have clearly asked you to stop, or after you have been blocked or removed.
**1.5 Sexual harassment.** Unwanted sexual comments, advances, innuendo, requests for sexual content, or sexually explicit messages sent to someone who has not consented to receive them.
**1.6 Pile-ons and coordinated attacks.** Organising, encouraging, or participating in coordinated attacks or mass harassment against a person or group, whether on Fluxer or by directing others to harass someone on another platform.
**1.7 Encouraging harm.** Encouraging, inciting, or instructing others to harass or harm a specific person or group.
**How cases are assessed.** We consider frequency, duration, severity, power dynamics, whether the target asked the person to stop, whether the behaviour is part of a pattern, and the impact on the target's ability to use Fluxer safely.
### 2. Hate speech and discrimination
Do not attack, demean, dehumanise, or incite hatred or violence against people based on protected characteristics.
**Protected characteristics.** The following are explicitly protected on Fluxer: race, ethnicity, colour, national origin, or ancestry; immigration or citizenship status; caste; religion, faith, or lack of religion; sex; gender, gender identity, or gender expression; sexual orientation; sex characteristics, including intersex status; disability, chronic illness, or medical condition; neurodivergence; age or generational status; pregnancy or parental status; veteran or military status; socioeconomic status or housing status; and physical appearance, including body size.
This list is intentionally broad. Other characteristics may also be protected where the context makes clear that someone is being targeted for who they are.
**Severity tiers.** Hate speech falls into three tiers, each with a different enforcement response.
**Tier 1: dehumanisation and incitement (most severe).** Content that dehumanises people (comparing them to animals, insects, diseases, filth, subhuman entities, or objects); calls for violence, killing, or physical harm against a protected group; calls for exclusion, segregation, or denial of fundamental rights; or denies or celebrates well-documented atrocities or genocides targeting a protected group. Removed immediately; typically results in account suspension or termination.
**Tier 2: statements of inferiority, contempt, and stereotyping.** Content that asserts members of a protected group are inherently inferior, less intelligent, morally deficient, or otherwise lesser; generalises negative stereotypes as inherent traits of a group; expresses contempt, disgust, or hatred towards a group as a whole; or uses imagery, memes, or symbols historically associated with hatred of a protected group in a celebratory or affirming way. Removed; typically a warning for first-time violations, escalating for repeat offences.
**Tier 3: slurs, exclusion, and demeaning language.** Content that uses slurs or derogatory terms targeting protected groups; calls for exclusion from Communities based on protected characteristics (unless the Community's purpose requires it; for example, a women's support group may limit membership); or mocks, ridicules, or demeans someone specifically because of a protected characteristic. Assessed contextually. Self-referential use of reclaimed language by members of the relevant group is generally permitted (see Exceptions below).
**2.1 LGBTQ+ specific protections.** Fluxer is and will remain a safe and affirming place for lesbian, gay, bisexual, transgender, queer, intersex, asexual, and all other gender and sexual minority (LGBTQ+) users. The following are prohibited as forms of hate speech.
**Targeted misgendering and deadnaming.** Deliberately and repeatedly referring to a person by a gender, name, or pronouns that do not align with their gender identity, after being informed of, or having reasonable access to, their correct name or pronouns. This includes using someone's birth name ("deadname") against their wishes to harass, demean, or invalidate their identity. This rule targets deliberate, repeated behaviour; honest mistakes corrected when informed are not violations.
**Denial of identity.** Content that denies the existence or validity of transgender, nonbinary, intersex, or other gender identities, or of sexual orientations, when directed at or about specific individuals or used to advocate for discrimination. This includes claims that being transgender is a mental illness, a delusion, or a choice that can be "cured".
**Conversion therapy advocacy.** Promoting, advertising, or providing instructions for conversion therapy or any practice that attempts to change a person's sexual orientation, gender identity, or gender expression, including content that frames such practices as legitimate medical treatment, spiritual guidance, or parental responsibility.
**Sexualisation and fetishisation.** Reducing LGBTQ+ people to their sexual orientation or gender identity in a degrading or objectifying way, or treating LGBTQ+ identities as inherently sexual, deviant, or predatory.
**Outing.** Revealing or threatening to reveal someone's sexual orientation, gender identity, or intersex status without their explicit consent.
**2.2 Gender-affirming healthcare discussions.** Access to gender-affirming healthcare is especially important to transgender, nonbinary, and intersex people. There is a clear line between protected discussion and prohibited content.
**Allowed:** personal experiences with gender-affirming care (hormone therapy, surgery, and other treatments); peer support, resources, and discussion of healthcare options; medical information consistent with the consensus of major medical organisations (such as the World Health Organization, the American Medical Association, the Endocrine Society, and the World Professional Association for Transgender Health); advocacy for healthcare access, insurance coverage, or policy changes; coming-out discussion and identity exploration; and discussion of detransition in a personal, supportive, or informational context.
**Prohibited:** promoting conversion therapy or practices designed to change someone's sexual orientation or gender identity; deliberately spreading medical misinformation that contradicts established scientific and medical consensus to deny transgender identities or discourage evidence-based care; using concern about healthcare as a pretext to deny, mock, or undermine transgender identities; and targeting people who have shared healthcare experiences with harassment or ridicule.
**How borderline cases are assessed.** Good-faith discussion of healthcare policy, medical research, individual experiences (including critical ones), and evolving scientific understanding is permitted. We distinguish genuine engagement with complex topics from bad-faith efforts to delegitimise transgender people or deny them healthcare, looking at whether the content engages with evidence in good faith, targets people or communities with hostility, uses medical or scientific framing as a pretext for harassment or identity denial, and fits the wider context.
**2.3 Exceptions to hate speech rules.** The following are generally not treated as violations.
**Self-referential use of reclaimed language.** Members of a group may use reclaimed terms (including slurs) to refer to themselves or within their community. Assessed contextually: use within a space primarily composed of that community is treated differently from use directed at strangers.
**Academic, educational, and documentary content.** Discussion of hate speech, discrimination, and historical atrocities is permitted in academic, educational, journalistic, or documentary contexts when the purpose is to inform, educate, analyse, or condemn. The content must not promote hatred and should include appropriate framing.
**Counter-speech.** Calling out, criticising, or arguing against hateful content or ideologies is protected. Quoting hateful content in order to condemn it is not itself a violation.
**Satire and commentary.** Clearly satirical content that critiques power structures, ideologies, or prejudice may be permitted. Satire that targets marginalised groups rather than critiquing prejudice against them is not protected by this exception.
### 3. Violence and graphic content
Do not share or promote: real-world graphic depictions of violence, gore, mutilation, or animal cruelty (photographs, videos, or realistic recordings); content that promotes, encourages, glorifies, or provides instructions for self-harm, suicide, or harm to others; detailed instructions or encouragement for violence or illegal activity; or content that glorifies, celebrates, or promotes violence, violent extremism, or terrorism.
**Scope.** This rule targets real-world media. Media presented as real-world, even if generated, edited, or manipulated, is treated the same as actual footage. Fictional or artistic depictions of violence (drawings, animation, game content, horror) are permitted in age-gated spaces with clear content warnings, provided they are not presented as real-world footage, not used to glorify real-world violence or target a specific person, and not so extreme as to have no purpose other than shock.
**Contextual allowances.** Non-graphic discussion of difficult topics is permitted in appropriate contexts such as news, education, and historical analysis. It must include clear content warnings, be restricted to age-gated spaces when likely to be distressing, and not glorify or encourage the violence being discussed.
### 3a. Terrorism and violent extremism
Fluxer must not be used to promote, support, recruit for, or coordinate terrorism or violent extremism. This covers recruitment, incitement, material support, propaganda, manifestos, instructional materials, glorification of terrorist attacks or mass violence, and coordination, planning, or operational activity.
**EU Terrorism Content Online Regulation.** Where we receive a removal order from a competent authority under Regulation (EU) 2021/784, we will remove or disable access to the identified content within one hour, as required. Content under this section may also be reported to law enforcement where required or permitted by law. Removed content is preserved for six months for law enforcement purposes, as the regulation requires.
**Exceptions.** Legitimate news reporting, academic research, counter-extremism education, historical analysis, and artistic expression are not prohibited, provided they do not themselves glorify or promote the acts described above.
### 4. Sexual content and protection of minors
**Zero tolerance.** Child sexual exploitation is prohibited in any form. The rules in this section are among the most strictly enforced on Fluxer.
**4.1 Child sexual abuse material (CSAM).** CSAM, meaning sexual or sexually suggestive imagery depicting real children, is strictly prohibited and will be reported to law enforcement as required by law. This includes realistic AI-generated or digitally manipulated imagery indistinguishable from photographs of real children. Violations result in immediate and permanent account termination and reporting to law enforcement or relevant authorities.
**4.2 Sexualisation of real minors.** Do not share, distribute, request, or create sexual or sexually suggestive content depicting a real, identified minor in any medium (text, imagery, audio, or AI-generated content), regardless of your relationship to the minor.
**4.3 Fictional depictions of minors.** Sexual or sexually suggestive content featuring fictional characters who are explicitly described as minors, or who are unambiguously depicted as prepubescent, is prohibited in all spaces, without exception. This includes drawn, animated, AI-generated, and written content where the character is clearly a child. Fictional content is assessed on the totality of context: stated age, narrative framing, visual presentation, and setting. This rule does not apply to non-sexual coming-of-age narratives, survivor stories, educational content, or literary works that depict difficult subject matter without sexualising it.
**4.4 Grooming.** Using Fluxer to build a relationship with a minor for the purpose of sexual exploitation is strictly prohibited, whether or not explicit content is involved. Grooming behaviours include building inappropriate emotional intimacy with a minor, attempting to isolate a minor from trusted adults or support systems, gradually introducing sexual topics or content, requesting personal information, photos, or private communication in a sexualised context, and offering gifts, money, or special treatment in exchange for personal information or intimate interaction.
**4.5 Users under 18.** If you are under 18, you must not engage with, share, or distribute any sexual or sexually suggestive content on Fluxer, including in age-gated spaces.
**4.6 Adult content.** Sexual and explicit content involving adults is permitted only in clearly marked 18+ spaces. Communities must apply an age restriction to the Community as a whole, to individual channels, or both. Communities that fail to enforce these requirements may be restricted or removed. Community Owners are responsible for proper age gating.
**4.6a Direct messages.** DMs between adults are not subject to the "clearly marked 18+ spaces" requirement: adults may share explicit content with other adults in DMs, and can control what they see via their settings. However, if it becomes apparent that one party is a minor, any further sexually explicit content in that conversation is a violation. All other rules still apply in full, including consent (Section 1.5), non-consensual intimate media (Section 4.7), and sexual exploitation (Section 4.8).
**4.7 Non-consensual intimate media.** Sharing intimate images, videos, or recordings of any person without their explicit consent is strictly prohibited. This includes "deepfakes" and AI-generated or digitally manipulated content depicting someone in an intimate context without their permission, "revenge porn" and sexually explicit content shared to shame, coerce, or harm someone, voyeuristic content captured without the subject's knowledge or consent, and threatening to share intimate content to coerce, blackmail, or intimidate.
**4.8 Sexual exploitation.** Using Fluxer to facilitate sexual exploitation of any person, including sex trafficking, coerced sexual labour, or commercial sexual exploitation of minors, is strictly prohibited and will be reported to law enforcement.
### 5. Illegal activities
Do not use Fluxer to facilitate, promote, or engage in illegal activity. This includes malware or harmful software; fraud, scams, or deceptive practices, including phishing, impersonation, and financial scams; illegal goods, services, or controlled substances; copyright infringement or other intellectual property violations at scale or in a clearly abusive manner; hacking, unauthorised access, or cyberattacks; money laundering, terrorist financing, or similar financial crimes; evasion of lawful restrictions or sanctions; and any other activity that violates applicable law.
We may cooperate with law enforcement where required by law, or where necessary to protect individuals from serious harm.
### 6. Spam and abuse
Do not abuse or misuse Fluxer. This includes spam, bulk messages, unsolicited commercial content, fake accounts, impersonation, artificial Community member counts or reactions, buying or selling Fluxer accounts or Communities, abusing the free tier as unlimited cloud storage, fraudulent chargebacks or payment disputes, and automation used to evade limits, scrape or harvest data, mass-create accounts, or disrupt normal use.
Limited automation that complies with our policies and applicable law may be allowed where explicitly permitted by Fluxer. All other automated abuse is prohibited.
### 7. Harmful misinformation
Do not deliberately spread misinformation that is demonstrably false and likely to cause serious harm: misinformation that could endanger public health or safety, interfere with democratic processes or civic participation, cause direct physical harm to individuals or communities, or damage critical infrastructure or services.
**How misinformation is assessed.** We look at factual accuracy, potential real-world harm, intent and context, source credibility, and whether the content was shared where others might reasonably act on it.
**What this rule does not cover.** Personal opinions, political commentary, good-faith debate, satire, speculation clearly labelled as such, and discussion of contested or emerging scientific topics. We do not police opinions or enforce a single viewpoint. This rule targets deliberate falsehoods with the potential for serious, concrete harm, not disagreement, dissent, or unpopular views.
**Relationship to gender-affirming healthcare.** Sharing medical information about gender-affirming care that aligns with established medical consensus is not misinformation. Neither is advocacy for access to such care, nor personal accounts of it. See Section 2.2 for the full policy.
### 8. Privacy violations
Do not violate the privacy rights of other users. This includes doxxing (Section 1.3), recording voice or video communications without consent, where consent is legally required, trying to defeat privacy settings, user blocks, or safety features, stalking or invasive monitoring connected to someone's use of Fluxer, and sharing screenshots or recordings of private conversations without consent where doing so could cause harm or was done to harass.
When in doubt about whether something violates someone's privacy, err on the side of caution and do not share it.
### 9. Deceptive AI-generated and manipulated content
Do not use AI-generated or digitally manipulated content to deceive, defraud, or harm others. This covers deepfakes or synthetic media depicting real people without consent, AI-generated impersonation for fraud or harassment, manipulated media presented as authentic evidence of events that did not occur, and using AI-generated content to get around other rules in these guidelines.
**What is permitted.** AI-generated creative, artistic, satirical, or clearly fictional content, when it is not used to deceive, harass, or target people and does not violate other rules. Where AI-generated content could reasonably be mistaken for authentic material, labelling it is strongly encouraged; unlabelled AI content that causes harm or confusion may be treated more seriously.
## Reporting violations
If you see content or behaviour that appears to violate these guidelines or our [Terms of Service](/terms), please report it. Use the in-app reporting features, or email our safety team at <safety@fluxer.app>. Include relevant screenshots or message excerpts, direct links, user IDs or usernames, and a brief description of what is happening.
**Share only what is needed.** Screenshots and recordings often capture more than you intend (other open chats, friend lists, notification previews, third-party usernames). Crop or blur anything that is not the content you are reporting, and leave out unrelated personal information about others. Photos taken with a phone may carry hidden EXIF metadata such as GPS coordinates; prefer screenshots, or strip metadata, before sending.
**Do not engage in vigilante responses.** Do not harass, threaten, or dox someone in response to their violations. Report the issue and let our moderation team handle it. Retaliatory harassment is itself a violation, even when directed at someone who broke the rules first.
We may not always be able to share the outcome of a review, but all reports are reviewed in good faith. Reports involving imminent danger to life, child sexual exploitation, or credible threats of serious violence are treated as highest priority.
**Reports are confidential to the reporter.** When we act on a report, the affected user receives a statement of reasons explaining what was done and why (see Section 9.2 of the [Terms of Service](/terms)). It identifies the content and rule at issue, not the reporter. We do not pass on your username, user ID, email, or any other identifying detail. The only narrow exceptions are where a court, regulator, or other binding legal process compels disclosure, or where you chose to identify yourself in the report; in either case we follow the safeguards in our [Privacy Policy](/privacy). Abuse of the reporting system (false, malicious, or coordinated reports) is itself a violation.
### Trusted flaggers
Reports from entities designated as trusted flaggers under Article 22 of the EU Digital Services Act are given priority. If you are a designated trusted flagger, contact <legal@fluxer.app>.
## Enforcement
### What actions may be taken
Depending on severity, context, and risk, violations may result in: warnings, removal or restriction of violating content, temporary feature limits, temporary suspension, permanent account bans, limits on creating or managing Communities, deletion of Communities that repeatedly or seriously violate these guidelines, restriction of cosmetic items, premium services, or subscriptions, and reporting of illegal content or serious threats to law enforcement or relevant organisations.
### How decisions are made
We consider severity, actual or potential harm, intent, prior violations or warnings, the risk of future harm, whether minors or vulnerable people are affected, whether law requires a particular action, and mitigating context such as a genuine misunderstanding, immediate self-correction, or cooperation.
Enforcement starts with less severe measures for minor or first-time violations, such as warnings, content removal, or temporary restrictions, and escalates for repeat offences or failure to comply. Egregious violations, including child sexual exploitation or CSAM, credible threats of serious violence, large-scale or clearly malicious abuse, fraud, hacking, and terrorism or violent extremism content, may result in immediate and permanent action.
**Automated and human moderation.** Automated tools may flag content for review, but enforcement decisions are made by humans, with limited exceptions: automated spam and abuse defences may temporarily block actions pending review, and regional access restrictions based on IP geolocation operate automatically, as described in our [Privacy Policy](/privacy).
### Statement of reasons
When enforcement action is taken, you will receive a clear and specific statement of reasons: the guideline, term, or legal ground for the action, the facts relied on, whether automated means were used, and redress options, including how to appeal and, for users in the EU, the option to refer disputes to a certified out-of-court dispute settlement body.
This applies to all enforcement actions except where providing it would compromise an investigation, endanger safety, or conflict with legal obligations.
## Appeals
If you believe an enforcement decision was incorrect, you can appeal.
**How to appeal.** Email <appeals@fluxer.app> from the email address associated with your Fluxer account. State which enforcement action you are appealing, explain why you believe the decision was incorrect, incomplete, or disproportionate, and include any relevant context or evidence.
**Process.** Appeals can only be processed from the email associated with the affected account. Submit within 60 days of receiving the enforcement notice. Each enforcement action can be appealed once; duplicate submissions do not speed up review. Temporary enforcement actions generally remain in place during review. Responses come as promptly as volume and complexity allow.
After review, the appeal decision is generally final. Past decisions may be revisited if new, material information comes to light or if we update our policies in relevant ways. If a complaint shows that content was not illegal and did not violate these guidelines or our terms, the decision is reversed without undue delay.
### Out-of-court dispute settlement (EU)
If you are in the European Union and not satisfied with the outcome of our appeals process, you can refer the dispute to a certified out-of-court dispute settlement body under Article 21 of the EU Digital Services Act. A list of certified bodies is available through the Digital Services Coordinator in your Member State. We will engage in good faith with any certified body you select.
## Special considerations
### For younger users
Users must meet the Minimum Age to use Fluxer, as described in our [Terms of Service](/terms) and [Privacy Policy](/privacy). The general figure is 13, though some countries set it higher.
Stricter safety features may be enabled by default for users under 18, including tighter privacy defaults and restricted access to certain features. Some content or Communities may be restricted based on age. Communities focused on dating or romantic relationships between minors, or that sexualise minors in any way, are strictly prohibited.
If you are under 18, be particularly careful about sharing personal information, and do not meet people from Fluxer in person without involving a trusted adult.
### For Community Owners
If you own, create, or administer a Community, you are responsible for the content and behaviour within it, including user-generated content and moderation practices. Use the available tools (moderation roles, content controls, age gates) to keep your Community safe. Set clear, visible rules and enforce them fairly; as noted above, they can be stricter than these guidelines but never more permissive.
Failure to address serious or repeated violations can result in restrictions on your Community, removal of your Community, or enforcement action against your account. If you are unsure how to handle a safety issue, report it or contact <safety@fluxer.app>.
### For parents and guardians
Safety resources on our website help parents and guardians understand Fluxer and support young users. If you have concerns about your teenager's account, contact our support team; we may need to verify your relationship before discussing a specific account. If you believe a child is in immediate danger, contact local emergency services first, then let us know.
### Self-harm and crisis content
Our approach to self-harm content is built around compassion and support rather than punishment.
**What is prohibited.** Glorifying, encouraging, promoting, or providing specific instructions or methods for self-harm, suicide, or eating disorders; pressuring or daring anyone to harm themselves; creating or participating in content that gamifies or challenges self-harm; and sharing graphic imagery of self-harm.
**What is allowed.** Supportive, empathetic conversations about mental health: personal experiences shared in a supportive context, emotional support, recovery and coping strategies, and information about professional resources.
**How the rule is applied.** Users are not punished for saying they are struggling. If content suggests someone may be at imminent risk, the priority is connecting them with support: interstitial screens may link to crisis resources, content warnings may be placed on distressing messages, and in urgent cases, steps may be taken to help keep the person safe.
**If you see someone in crisis,** report the content via in-app tools or email <safety@fluxer.app> with as much detail as possible. If you know the person and can safely do so, encourage them to seek professional support or contact emergency services. We respond to safety reports as promptly as possible and, where appropriate, work with relevant services or authorities in line with applicable law.
**Crisis resources.** If you or someone else is in immediate danger, contact your local emergency services first. Fluxer is not a substitute for professional mental health care or emergency services, and cannot provide medical, psychological, or legal advice. If you or someone you know is struggling: internationally, Befrienders Worldwide ([befrienders.org](https://befrienders.org)) operates crisis centres in over 40 countries. In Sweden, Mind (mind.se) can be reached on 90101, and BRIS (for children and young people) on 116 111. In the United States, the 988 Suicide & Crisis Lifeline is available by calling or texting 988. In the United Kingdom, Samaritans can be reached on 116 123 (free, 24/7) or at [samaritans.org](https://www.samaritans.org). In the EU, many countries offer emotional support at 116 123. Crisis Text Line is available by texting HOME to 741741 (US), 85258 (UK), 686868 (Canada), or 50808 (Ireland).
## Transparency reporting
We plan to publish voluntary transparency reports covering content moderation, automated tools and their accuracy, complaints and outcomes, and orders from authorities. As a micro enterprise under the EU Digital Services Act, we are not yet subject to mandatory reporting under Article 15. Reports will appear on our website and cover the preceding calendar year.
## Changes to these guidelines
These guidelines may be updated as new features are introduced, community norms evolve, or laws change. Material changes come with at least 30 days' notice where reasonably practicable, and a [changelog](/changelog) is maintained. When updated alongside changes to our [Terms of Service](/terms) or [Privacy Policy](/privacy), you will be asked to confirm that you have reviewed and agreed. If you do not agree to updated guidelines, you can stop using Fluxer and delete your account at any time.
## Contact
**General questions:** <support@fluxer.app>
**Safety concerns:** <safety@fluxer.app>
**Appeals:** <appeals@fluxer.app>
If you are unsure whether something violates these guidelines, our support or safety teams can help.
## Law enforcement requests
For details on when law enforcement may obtain information from us, see the "Law enforcement and legal requests" section of our [Privacy Policy](/privacy).
Lawful process and urgent preservation requests should go to <legal@fluxer.app>, and must identify the requesting authority, the legal basis, and the specific data requested. We aim to notify affected users where the law permits. Overbroad or non-compliant requests may be rejected or narrowed.
Removal orders under the EU Terrorism Content Online Regulation are handled as described in Section 3a (Terrorism and violent extremism).
## A final note
Most people on Fluxer never run into these guidelines. Treat others with respect, use good judgement, and remember there is a real person on the other side of every interaction.
Fluxer is for everyone, regardless of who you are, who you love, how you identify, where you come from, or what you believe. Thank you for helping keep it safe and welcoming.
@@ -1,476 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
**Effective date:** 2026-04-25
## The short version
Fluxer is a chat service run by Fluxer Platform AB, a Swedish company based in Stockholm. This policy explains how we handle your data. It is binding under EU consumer protection law and part of our Terms of Service, so you can hold us to it.
- We do not sell, rent, or license your personal data. We have no advertising partners and no dealings with data brokers. Our revenue comes from Fluxer Plutonium, our optional premium subscription.
- AI does not read what you share on Fluxer. We run no AI or LLM inference over your messages, files, or voice and video calls, and none of your content is used to train or fine-tune AI models. The only automated content check is a local image classifier that helps respect explicit-content preferences.
- We do not track you around the web: no tracking cookies, no analytics SDKs, no browser fingerprinting.
- You can export your data, delete your messages, and close your account whenever you like.
- Most account data currently lives on servers in Piscataway, New Jersey, where US law, including the CLOUD Act, applies. Section 6 explains why, the privacy trade-offs, and the direction we are evaluating.
## 1. Who we are
Fluxer Platform AB is a Swedish limited liability company, organisation number 559537-3993. We operate Fluxer and related services. For GDPR purposes, we are the data controller for your personal data: we decide what data is processed and why, under the General Data Protection Regulation as implemented in Sweden, supervised by the Swedish Authority for Privacy Protection (Integritetsskyddsmyndigheten, known as IMY).
**Privacy contact:** Hampus Kraft, Founder and CEO
**Email:** <privacy@fluxer.app>
**Phone:** +46 79 101 18 18 (legal enquiries only; no phone support)
**Postal address:** Fluxer Platform AB, c/o Embassy House, Östgötagatan 12, 116 25 Stockholm, Sweden
Hampus handles privacy and data protection questions, including data subject requests.
We have not appointed a formal Data Protection Officer (GDPR Article 37) or a UK representative (UK GDPR Article 27). Both are kept under review as the service and its safety and security processing grow, and this section will be updated if that changes. In the meantime, UK residents can direct any data protection enquiry to <privacy@fluxer.app>.
## 2. What we collect, and what we do not
### 2.1 What you give us
**Account data.** Creating an account requires an email address, a username, a password, and your date of birth. Without those, we cannot sign you up. Everything else, including your avatar and bio, is optional. Passwords are stored using Argon2id, a memory-hard hashing algorithm, so even we cannot read them.
**Phone verification.** When registration triggers anti-spam checks, we may request phone verification to prevent large-scale registration abuse. The number is not linked to your account; completing verification stores only a flag saying it happened (full details in Section 7.3). SMS-based 2FA is not available for accounts registered on or after 25 April 2026.
**Content.** What you do on Fluxer: messages, files, images, voice and video calls where supported, Community data, reactions, and profile details such as your avatar, bio, and display name, plus any Communities you create or administer. All of it belongs to you.
**Support.** Support correspondence passes through Intercom, which handles the message body, attachments, anything else you choose to share, and the basic technical details (IP address, browser type) needed for support to work.
**Payments.** Stripe processes payments, not us. If you buy Fluxer Plutonium or anything else premium, we receive only what is needed to record and manage the purchase: billing country, the card's last four digits and expiry, payment status, and timestamps.
We do not ask for special-category personal data such as health, religion, race, ethnic origin, sexual orientation, political views, or trade union membership. If you choose to share any of that in a message or on your profile, it will not be used to profile you, target you, or treat you differently.
### 2.2 What we collect automatically
**Technical data:** the IP address you connect from, browser type and version, operating system, device type and identifiers, language settings, and similar details.
**Usage data:** aggregate, non-identifying records of which features get used and how often (voice calls started, files uploaded, reactions used), pages and screens visited, timestamps, session durations, crash reports, and performance metrics. Message content is not read to produce any of this; when a metric counts "messages sent", it counts the event and nothing more.
**Security and operational logs:** login attempts and authentication events, account setting changes, rate-limit triggers, API and system errors, and IP-based signals relating to spam, abuse, or unusual behaviour.
Fluxer has no advertising trackers, third-party analytics SDKs, browser fingerprinting, or cross-site tracking pixels. We do not build behavioural profiles or track which other sites you visit before or after using Fluxer.
### 2.3 What we receive from other sources
**Other users** generate data about your account when they interact with you. Someone mentioning you, adding you to a Community, or messaging you involves your username and user ID, the content of the interaction, and metadata such as timestamps.
**Service providers** relay limited operational information. Sweego reports whether transactional emails were delivered and when. Twilio reports whether SMS verification succeeded; for verification it processes the phone number needed to send the SMS, which is stored as described in Section 7.3. Stripe reports payment status and risk signals. Intercom carries support conversations and resolution status. Our infrastructure providers send alerts when they detect abuse or anomalies. IPinfo provides IP network signals for registration and abuse-prevention checks, under the conditions in Section 3.2.
**Public sources and fraud signals** occasionally reach us too, such as a reputation signal about a particular IP, or a risk score Stripe attaches to a transaction.
All of this is combined with what we collect directly only to run, secure, and maintain Fluxer.
## 3. How we use your information, and how we do not
### What we use it for
- **Operating Fluxer:** creating and managing your account, routing messages to the right recipients, and keeping features working end to end.
- **Security and abuse prevention:** blocking unauthorised access; investigating abuse, fraud, and spam; enforcing our Terms of Service and Community Guidelines.
- **Service communications:** security alerts, service updates, and the administrative emails your account needs to function.
- **Payments:** processing payments and managing subscriptions if you buy Plutonium or anything else premium.
- **Maintenance and improvement:** aggregate metrics showing which features are used, how performance is holding up, and where bugs occur. The data involved is error rates and feature counts, not message or file content.
- **Legal:** meeting legal requirements, responding to valid legal requests, and protecting the safety, rights, and property of our users, the public, and Fluxer.
### What we do not use it for
Your messages, files, voice or video calls, and anything else you create or share on Fluxer will never be used for:
- advertising, targeted or otherwise
- training, fine-tuning, or evaluating AI or machine learning models
- building profiles of you for ad targeting, marketing, or behavioural analysis
- sale, rental, or granting a licence to any third party for their own purposes
- mining or aggregating for commercial gain beyond operating the service
### 3.1 Lawful bases for processing (GDPR)
For readers in the EEA, the UK, or any other jurisdiction requiring a lawful basis:
**Contract necessity (Article 6(1)(b)).** Processing required to deliver the service you signed up for: delivering messages, running Communities, managing your account, processing payments, providing support.
**Legitimate interests (Article 6(1)(f)).** Service security, fraud prevention, reliability and performance, aggregate feature-use analysis, and writing to you about changes to our services or policies. Each activity has a documented assessment covering its purpose, necessity, and the balance against your rights. For example, abuse prevention uses IP signals, login events, device metadata, and rate-limit triggers; these are used only for security and administration, never for advertising or profiling. You can object at any time (Section 10).
**Legal obligations (Article 6(1)(c)).** Accounting, tax, and bookkeeping under Swedish law, responses to lawful requests from public authorities, and compliance with applicable data protection, security, and consumer laws.
**Consent (Article 6(1)(a)).** A smaller set of processing, such as optional communications or specific cookie uses on our marketing site where local law requires consent. Consent can be withdrawn at any time through your settings or by writing to us; withdrawal does not affect processing that was lawful beforehand.
### 3.2 IP address geolocation
Your IP address is used to determine approximate location (city, region, country) to:
- alert you to logins from new or unusual locations
- show you where your account is currently signed in
- spot fraud and abuse
- determine regional age requirements and access eligibility under local laws
- meet legal obligations relating to export control and sanctions
We prefer a fully local geolocation database (MaxMind GeoIP, downloaded periodically and queried entirely on our own servers, with no per-lookup network call to a third party) whenever it can answer the question.
Registration and abuse-prevention checks sometimes need IP network signals a local database cannot provide: VPN provider, commercial proxy, Tor exit-node status, residential-proxy use, and related risk indicators. For those we query IPinfo, sending only the IP address (no account identifier, user identifier, session token, or device information), so the lookup cannot be linked back to your Fluxer account. Responses are cached on our own servers, so an IP is sent at most once per cache window; stable residential IPs are cached longer, rotating proxy-pool IPs for less time. These signals are used only for security and abuse prevention, including registration checks and, in some cases, rejecting Tor or residential-proxy traffic at the API edge. They are never used for advertising, profiling, or personalisation.
**Automated regional access decisions.** Where local law requires platforms to verify user age, we rely on automated regional restrictions driven primarily by IP geolocation instead of government ID uploads or biometric scans, which are more invasive than we are willing to require for general access. This can affect whether you can use Fluxer, or specific features, from a given region.
The approach is imperfect: travel, VPNs, proxies, and unusual network setups can all produce the wrong outcome. If you believe your access has been restricted in error, write to <privacy@fluxer.app>. We will acknowledge your request promptly, conduct a human review while your account stays in its current state, and send you the outcome with the reasoning. You can put your point of view at any stage, and rights around automated decision-making under applicable law (GDPR Article 22, for instance) are honoured per Section 10. Current regional restrictions, their basis, and their effect are listed in our [Regional restrictions](/help/regional-restrictions) help article.
## 4. Who we share data with, and who we do not
Your personal data is not sold, rented, traded, or licensed to any third party. Sharing is limited to the situations below.
### 4.1 Sharing you initiate
Messages go to their recipients, Community posts are visible to members, your profile is visible to the extent you choose, and integrations you connect can access the data you grant them. Once shared, content can be saved or redistributed by other users outside Fluxer; as with any chat app, be deliberate about what you share and with whom.
### 4.2 Our service providers (processors)
A small, carefully chosen set of third parties processes data on our behalf. Each has been reviewed for privacy and security and is bound by a data processing agreement under GDPR Article 28.
_Infrastructure and storage._ Vultr provides our primary hosting, and Bunny.net operates our content delivery network for user-generated content on the `fluxerusercontent.com` domain. Section 6.1 details where data is stored.
_Security and safety._ IPinfo provides IP network signals for registration and abuse prevention under the conditions in Section 3.2. hCaptcha provides CAPTCHA challenges for bot detection.
_Third-party content._ Google provides YouTube embeds and GIF search (Tenor); KLIPY provides additional GIF search. Traffic to Tenor and KLIPY is proxied through our servers, so your IP address and device identifiers never reach them. YouTube metadata is fetched server-side (see Section 13).
_Payments and communications._ Stripe handles payment processing, Sweego (hosted in the EU) transactional email, Twilio SMS-based account verification, and Intercom our support tool. Phone numbers used for verification are never linked to Fluxer accounts (Section 7.3). Intercom handles your support messages, email address, and basic technical information under our instructions, not for its own purposes.
_Error monitoring and observability._ Our observability stack (metrics, logs, and traces) runs on infrastructure we control. No application errors or crash data are sent to any third-party monitoring service.
Data processing agreements are in place with Vultr, Bunny.net, IPinfo, hCaptcha, Stripe, Sweego, Twilio, and Intercom. A few providers act as independent controllers when you interact with them directly (Google for YouTube embeds, hCaptcha for challenge completion); in those interactions their own terms and privacy policies apply alongside ours. Additions or replacements to this list are reflected here, with material changes noted in our [changelog](/changelog).
### 4.3 When law or safety requires disclosure
Disclosure outside Fluxer happens only for:
- compliance with a valid legal obligation, legal process, or enforceable governmental request
- enforcement of our Terms of Service or other agreements
- protection of the safety, rights, or property of users, the public, or Fluxer
- detection, prevention, or handling of fraud, security, or technical issues
Where the law allows, and where notice would not create a safety, security, or legal-process risk, we try to notify affected users before disclosing data in response to a legal request, particularly when it concerns an account or its content.
### 4.4 Business transfers
If Fluxer Platform AB is part of a merger, acquisition, reorganisation, sale of assets, or similar transaction, personal data may need to be transferred. In that event:
- affected users receive at least 30 days' advance notice, where legally permitted, before personal data is transferred
- the acquiring entity is bound by this Privacy Policy for as long as it holds your data, unless it obtains your affirmative consent to a new policy
- you will have the opportunity, with clear instructions, to delete your account and request deletion of your data before the transfer takes effect
- data will not be transferred to any entity that does not agree to honour these protections
## 5. Content safety
Fluxer does not run AI or LLM inference on your content, and nothing you share is used to train or evaluate AI models, ours or anyone else's (Section 3). What we run instead is a small set of automated safety measures, plus limited human review in defined circumstances.
### 5.1 Explicit-content classification
For an explicit-content opt-out to work, we need to know which uploaded images and video thumbnails are likely to contain nudity. We use [OpenNSFW2](https://github.com/bhky/opennsfw2), an open-source image classifier running on our own servers. It is a small pretrained classifier, not a generative AI system or LLM: given an image, it returns a single probability that the image contains pornographic content. It cannot read text, generate output, retain memory across calls, or learn from what it sees. It runs entirely on our servers with no external API call, we use the published weights without fine-tuning them on your uploads, and no classifier output enters any training pipeline.
The classifier creates a per-image "likely explicit" flag, used only so delivery can respect recipients who have opted out of explicit content, for example when you send media in a DM or a Community channel. The aim is to spare people unsolicited nudity. A flag has no further consequences: the media is not banned, the account is not disabled, and no report is filed.
### 5.2 Other automated safety measures
Narrowly-scoped automated systems operate on metadata and patterns (message frequency, link structure, account age, IP reputation) rather than message content, to:
- block known malware, phishing links, and spam patterns
- detect and mitigate harassment, raiding, and coordinated abuse
- flag suspicious login attempts and account-takeover patterns
- enforce Terms of Service and Community Guidelines when they have been breached
They do not read message content and do not feed advertising or behavioural profiles.
### 5.3 Human review
Authorised staff may examine specific content when needed to investigate a user report, enforce policy, or respond to a credible safety issue. Access is controlled by role-based permissions and recorded in an audit log, so each access is attributable and reviewable internally.
### 5.4 Data Protection Impact Assessments
Where processing may create a high risk to people's rights and freedoms, we carry out Data Protection Impact Assessments (DPIAs), as GDPR Article 35 requires. Two are complete to date: the explicit-content classifier (Section 5.1) and IP-based automated access decisions (Section 3.2). Each examines necessity and proportionality, identifies risks, and documents the technical and organisational measures used to reduce them. DPIAs are revisited when we introduce or materially change processing, or on a regular schedule, whichever comes first.
## 6. Where your data lives
### 6.1 Storage locations
Our primary servers are at Vultr in Piscataway, New Jersey: your main data (account information, messages, Communities, and other persisted content) is stored there, together with the object storage for user-uploaded files. Voice and real-time communication traffic runs on Vultr across multiple regions worldwide so calls route near you. User-generated content is delivered and cached through Bunny.net edge locations worldwide (for content on `fluxerusercontent.com`).
Encrypted off-site backups of our databases exist for disaster recovery. The encryption keys are held only by us, so the backup provider cannot read, index, or otherwise process your data; that is why it is not treated as a sub-processor under GDPR Article 28.
### 6.1.1 Why our primary hosting is in the United States
At our present scale, US East Coast data centres offer a reasonable balance of connectivity, reliability, and latency for a global service. The choice is operational rather than privacy-driven, and alternatives are kept under review.
It has privacy consequences. Storing primary data in the United States brings it within reach of lawful access requests under US law, including the Clarifying Lawful Overseas Use of Data Act (the "CLOUD Act"), under which a provider subject to US jurisdiction can be required to produce data in its possession, custody, or control, even if stored outside the United States. US hosting therefore carries a foreign-government access exposure that some other setups do not. We treat this as a real privacy issue, and it is factored into our transfer impact assessments, provider reviews, and legal request procedures (Sections 6.2 and 14).
### 6.1.2 Regional hosting under evaluation
Reducing our reliance on US-hosted primary infrastructure is under active evaluation. One option is a separate EU region, where accounts and Communities could be hosted in Europe. Depending on the architecture and providers involved, that could reduce foreign-government access exposure, though not eliminate it entirely. If regional hosting ships, we will explain what it changes, what it does not, and which legal regimes still apply.
### 6.2 International data transfers
Because we operate globally and use providers in multiple countries, your data may be transferred to and processed in countries other than your own, including the United States and Canada, which may have different data protection laws.
Where the law requires it (under GDPR, for instance), safeguards are in place. Our data processing agreements include Standard Contractual Clauses approved by the European Commission or UK authorities, maintained even where other adequacy mechanisms may apply. Transfer Impact Assessments are carried out for each destination, covering the legal framework in the recipient country and the actual ability of authorities there to access data. Supplementary measures are contractual, organisational, and technical: encryption in transit and at rest, strict access controls, audit logging of access to user data, and contractual limits on provider use.
These measures have limits. We do not currently rely on jurisdiction-specific key separation, customer-controlled encryption keys, or an architecture that would make server-side data inaccessible to a provider served with a lawful order. For data we need to process on our servers to run Fluxer, these measures reduce transfer and access risk but do not eliminate it. Your data is never transferred to any third party for that party's independent advertising or marketing purposes.
## 7. Data retention
Your personal data is kept only as long as needed for the purposes in this policy, legal obligations, dispute resolution, and enforcing our agreements. Retention periods are reviewed, and data no longer needed is deleted or anonymised.
### 7.1 Active accounts
While your account is active, we hold your personal data, messages, Communities, and other content so the service can function. You can delete specific content yourself at any time.
### 7.2 Attachments and expiry
Attachments may remain available only for a limited time, depending on factors such as file size, age, and access frequency. Items saved to Saved Media are treated separately and are not subject to the same expiry. Current details are in our [help article on attachment expiry](/help/attachment-expiry).
### 7.3 Phone verification markers
Phone numbers used for account verification are not stored on your Fluxer account; when verification succeeds, your account stores only `has_verified_phone: true`.
To prevent repeated reuse during suspicious registrations, we keep an internal encrypted marker for the phone number for about 30 days. The marker contains no user ID or account reference, so it cannot be linked to an individual Fluxer account. It is used only to allow the same phone number to verify at most twice during that period, and not for SMS 2FA, recovery, advertising, profiling, contact discovery, or linking accounts together. The encryption key for these markers is rotated roughly every 30 days, with a short primary/secondary rollover so existing markers can expire naturally.
### 7.4 Deleted content
Database records (messages, account data) leave active systems quickly, typically within minutes. They may persist in encrypted backups for up to 30 days before being permanently removed.
Media attachments leave active storage typically within hours. As a short disaster-recovery safeguard against accidental deletion, the object storage behind our user-content bucket retains a non-visible copy of each deleted attachment for up to 24 hours before final erasure. During that window the attachment is invisible to you, other users, and our CDN, and only authorised operators acting on a genuine disaster-recovery scenario (such as recovery from a bad bulk delete) can restore it. Media attachments are not included in our long-term encrypted backups. Bunny.net's cache purge API is used to invalidate CDN-cached attachments as soon as possible after deletion, though short delays can occur due to rate limits and global propagation.
If you exercise your right to erasure under GDPR Article 17, the 30-day backup retention period is treated as a documented technical limitation: during that window, deleted data remains in encrypted backups that are not used for active processing and are subject to scheduled purging. Your erasure request is completed once the data has been removed from both active systems and backup cycles. Longer retention applies only where the law requires it (for example, tax or legal compliance).
### 7.5 Deleted Communities and channels
Deleting a Community or channel starts a 14-day grace period. During that window, it and all its contents (messages, attachments, roles, settings, and other associated data) are hidden from users and inaccessible through the API, media proxy, search, data exports, and bulk-deletion operations, but remain in our systems to allow recovery from accidental or unauthorised deletions. Restoration can be requested through support during the window; after 14 days, the Community or channel and all its data are permanently deleted via the procedures above, and restoration is no longer possible.
### 7.6 Inactive accounts
Accounts may be scheduled for deletion after 2 years of inactivity, with advance notice sent to the registered email address. The inactivity definition, notice schedule, and deletion process are in the [guide to deleting or disabling an account](/help/delete-account). Once deletion completes, the account can no longer be signed into and its remaining data is inaccessible, though messages you sent in Communities or direct messages may still be visible to other users unless you deleted them first or chose the message-deletion option during account deletion.
### 7.7 Payment and transaction data
Transaction records are kept for at least seven years, as required by Swedish bookkeeping law (Bokföringslag 1999:1078), and for as long thereafter as needed for legal compliance, dispute resolution, or fraud prevention. Retention is reviewed periodically. Full payment card numbers are not stored.
### 7.8 Logs and security data
Security and usage logs are kept for up to 90 days under normal conditions, then deleted or anonymised. Some may be retained longer for an active security investigation, a specific legal obligation, or an ongoing dispute, and are deleted once that reason ends. Audit logs (records of administrative actions, enforcement decisions, and account changes) are kept as long as needed for accountability, appeals, dispute resolution, and legal compliance, and are reviewed periodically.
### 7.9 Reported content snapshots
When someone uses the in-app report feature to flag a message, user, Community, or invite, we take a snapshot of the reported item so there is a stable record for investigation, action, and any appeal. A reported message snapshot typically includes the message itself, a short window of surrounding messages for context, any attachments in the report, and metadata about who reported it and why.
Report snapshots live in a separate object-storage bucket, isolated from the main user-content bucket. They are not served to end users, included in data exports, or indexed for search. Access is limited to authorised trust-and-safety and engineering staff who need them for report review, and every access is recorded in an audit trail.
Snapshots are kept for up to 1 year from the report date, after which an automated storage-lifecycle rule deletes them permanently. Deleting the original message, attachment, account, or Community does not remove the snapshot during this window, because it preserves the record needed for investigation and appeals. In rare cases where specific evidence must be kept longer to meet a binding legal obligation, only what the law requires is retained.
### 7.10 Retention at a glance
- **Account information:** while your account is active.
- **Phone verification reuse markers:** about 30 days; encrypted, no user ID, used only to allow at most two verifications per phone.
- **Messages and user content:** while your account is active, unless you delete them earlier.
- **Deleted messages (database records):** removed from active systems within minutes, and from encrypted backups within 30 days.
- **Deleted media attachments:** removed from active systems within hours; recoverable for up to 24 hours for disaster recovery; not backed up.
- **Deleted Communities and channels:** 14-day grace period, then permanently deleted per the above procedures.
- **Report snapshots (in-app reports):** up to 1 year; access limited to authorised staff and audit-logged.
- **Security and usage logs:** up to 90 days (longer only for active investigations or legal obligations).
- **Audit logs:** kept as needed; reviewed periodically.
- **Payment and transaction records:** at least 7 years (Swedish bookkeeping law); reviewed periodically after that.
- **Inactive accounts:** scheduled for deletion after 2 years of inactivity, with advance notice.
## 8. Your controls
### 8.1 Privacy dashboard
**Data export.** Available from the Data Export tab as a ZIP archive of machine-readable JSON files, covering account data, per-channel message history, payment history, and security data, along with any profile assets. Message attachments are not bundled; the export includes CDN URLs for downloading them while they remain available. Messages in Communities or channels in a deletion grace period are excluded. Current details are in [the help article on exporting your account data](/help/data-export).
**Attachment downloads.** Use the URLs in your export to keep copies before you delete messages or before attachments expire.
**Message deletion.** Individual messages can be deleted in-app; deleting a message also deletes its attachments. Bulk deletion of all your messages is available from the Data Deletion tab. It runs in the background, can take some time for large accounts, and skips messages in Communities or channels in a grace period. See [the article about requesting data deletion](/help/data-deletion).
**Account deletion.** Can be scheduled from settings and proceeds after a grace period, unless you sign back in to cancel. The [guide to deleting or disabling an account](/help/delete-account) has the full details.
### 8.2 Requests by email
To remove or correct a specific piece of data rather than delete everything, write to <privacy@fluxer.app> from the address associated with your Fluxer account, telling us clearly what you want us to do. We may ask for more information to verify your identity. If you want a copy of your data before deleting messages or your account, request an export first and wait for it to complete.
## 9. Security
Technical and organisational measures protect your personal data against accidental or unlawful destruction, loss, alteration, disclosure, and unauthorised access. Current measures include:
- standard encryption for data in transit (TLS)
- strong encryption for data at rest on our servers and backups
- professionally managed data centres with physical security
- security updates, patch management, and infrastructure hardening
- rate limiting and protections against abuse and attacks
- access controls restricting user data to authorised staff with a demonstrated need
- regular encrypted backups for disaster recovery
- audit logging of access to user data
**A note on encryption.** Nothing on Fluxer is currently end-to-end encrypted. Your data is encrypted in transit between your device and our servers, and at rest on our servers and backups, but because the service relies on server-side processing to function, message content is technically accessible to our systems while it is being handled. The same holds for real-time voice and video, which runs on Vultr across multiple regions (Section 6.1) with traffic encrypted in transit. In plain terms, you are trusting Fluxer and our hosting provider to protect that traffic.
Opt-in end-to-end encryption is planned for Personal Notes, DMs, Group DMs, and voice chats. Until that feature exists and you turn it on for a supported area, content and call media are not end-to-end encrypted on Fluxer.
**Responsible disclosure.** Security vulnerabilities can be reported through our [Security bug bounty page](/security). Responsible disclosure is appreciated and may be acknowledged publicly with your consent.
### 9.1 Data breaches
In the event of a personal data breach, we will investigate and take appropriate remedial steps. The relevant supervisory authority (IMY) will be notified within 72 hours of our becoming aware of a breach likely to pose a risk to your rights and freedoms, as GDPR Article 33 requires; affected users will be notified without undue delay where the risk is high, as Article 34 requires; and other applicable breach notification obligations will be met. Notifications will explain what happened, what data is likely affected, the likely consequences, and what you can do to protect yourself.
## 10. Your rights
Depending on where you live, you may have rights over your personal data. We honour them promptly and in good faith.
### 10.1 Under GDPR (EEA and UK)
- **Access:** confirmation of whether we process your personal data, and if so, a copy of it.
- **Rectification:** correction of personal data that is inaccurate or incomplete.
- **Erasure ("right to be forgotten"):** deletion of personal data no longer needed for the purposes it was collected for, or in other circumstances the law recognises.
- **Restriction:** restriction of processing in certain circumstances, such as while accuracy is being verified or an objection assessed.
- **Objection:** to processing based on legitimate interests. Processing stops unless compelling legitimate grounds override your interests, rights, and freedoms, or it is necessary for legal claims.
- **Data portability:** a copy of your personal data in a structured, commonly used, machine-readable format, or transmission to another controller where technically feasible. Self-service exports are a ZIP archive of JSON files, with download URLs for message attachments and certain related assets if there are any.
- **Withdrawal of consent:** at any time, for processing based on consent, without affecting the lawfulness of prior processing.
- **Automated decision-making:** where automated decisions significantly affect you (such as whether regional access rules apply), you can request human review, put your point of view, and contest the decision.
### 10.2 Under CCPA/CPRA (California residents)
California residents have the right:
- to know what personal information is collected, used, disclosed, and shared
- to delete personal information in certain circumstances
- to correct inaccurate personal information
- to opt out of the sale or sharing of personal information (no such sale or sharing occurs)
- to limit the use and disclosure of sensitive personal information
- to be free from discrimination for exercising any of the above
Additional California-specific disclosures are in Section 17.
### 10.3 Exercising your rights
Several of these rights can be exercised directly through your Privacy dashboard and account settings. Requests can also be sent to <privacy@fluxer.app>. We may need to verify your identity, for instance by asking you to reply from your registered email or provide additional details. Responses are returned within the timeframe required by applicable law, usually within 30 days, or up to 45 days where permitted. If we cannot fully comply (due to legal obligations or the rights of others, for example), we will explain why and what options remain. You can authorise an agent to submit requests on your behalf where the law permits; proof of authorisation may be requested.
### 10.4 Complaints to supervisory authorities
You have the right to lodge a complaint with your local data protection authority. In Sweden, that is the Swedish Authority for Privacy Protection (IMY) at [imy.se](https://www.imy.se); in the UK, the Information Commissioner's Office (ICO) at [ico.org.uk](https://ico.org.uk); the authority in your country of residence is also an option. You can also raise concerns with us first, so we have an opportunity to resolve them directly.
## 11. Children's privacy
### 11.1 Minimum age
Meeting the minimum age requirement in your region is a condition of using Fluxer. The general minimum, including in Sweden, is 13, though some countries set it higher; the full list is in our [help article on minimum age requirements](/help/minimum-age). Users above the minimum age but below the age of legal majority (for example, under 18) may use Fluxer, but our Terms require a parent or guardian to review and agree to them on the user's behalf.
### 11.2 Protections for younger users
Eligibility is determined from approximate geographic location and self-reported information. Users identified as under 18 may have stricter safety features enabled by default, including tighter privacy defaults and restrictions on age-restricted features. Because no user is profiled for advertising or commercial purposes on Fluxer, minors are not either. Invasive verification methods such as government ID uploads or biometric scans are not used for general access; where a legal framework would require methods we do not support, access is restricted as described in Section 3.2 and on the [Regional restrictions](/help/regional-restrictions) page.
### 11.3 If a child below the minimum age is identified
We do not knowingly collect personal information from children below the minimum age for their region; in the United States, that means children under 13, in line with the Children's Online Privacy Protection Act (COPPA). If information from such a child reaches us, we take steps to delete it and, where appropriate, the account. A parent or legal guardian who believes their child has used Fluxer without consent, or does not meet the minimum age, should write to <privacy@fluxer.app> from the child's registered email, or with sufficient proof of guardianship, to request deletion of the account and data.
## 12. Cookies and similar technologies
### 12.1 Approach
Third-party advertising and tracking cookies are not used anywhere on Fluxer (Section 2.2). Operational logging and limited feature-usage telemetry live server-side and are not used for advertising or cross-site profiling.
### 12.2 Cookies we set
A small number of cookies are set, all strictly necessary for operation and security, which under the ePrivacy Directive do not require consent:
- **`locale`:** remembers your language preference. Lasts 1 year; set on the marketing site (`fluxer.app`).
- **`csrf_token`:** protects against cross-site request forgery (CSRF) attacks. Lasts 24 hours; set on the marketing site.
- **`__flx_sudo` or `__flx_sudo_<user_id>`:** verifies your identity during sensitive account operations. Lasts 5 minutes; set in the Fluxer application. Sudo-mode cookies tied to a specific account have the user ID appended to the cookie name.
### 12.3 Client-side storage
The Fluxer application does not use cookies for authentication or session management. It uses your browser's local and session storage for preferences such as theme, media volume, and playback settings. That data stays on your device and is not sent to our servers.
### 12.4 Third-party cookies
Embedded third-party content may set its own cookies when you interact with it. hCaptcha may set cookies during CAPTCHA challenge completion, for bot detection, governed by [hCaptcha's privacy policy](https://www.hcaptcha.com/privacy). YouTube may set cookies when you play an embedded video (Section 13), governed by [Google's privacy policy](https://policies.google.com/privacy).
### 12.5 Managing cookies
Cookies can be controlled through your browser settings, though because all Fluxer cookies are strictly necessary, disabling them may stop some features from working. If non-essential cookies are ever introduced, for example analytics cookies, this section will be updated and consent obtained before they are set.
### 12.6 Opt-out preference signals
Browser-level opt-out signals such as Global Privacy Control (GPC) are honoured and recognised as valid opt-out requests as the CCPA requires. Because we do not sell or share personal information for advertising, they do not change underlying processing. Do Not Track (DNT) signals are not treated differently, as there is no industry consensus on interpreting them; in practice, Fluxer already reflects the intent behind DNT, since users are not tracked across third-party sites.
## 13. Third-party services and links
Fluxer may include links to, or integrations with, third-party services.
_GIF search (Tenor, KLIPY)._ Search queries and GIF embedding are both proxied through our servers. These providers never see your IP address or device identifiers.
_Links sent in messages._ Sending a URL in a message may cause our backend to fetch it to generate a rich embed or embedded media. Such requests identify themselves with a `User-Agent` string containing `Fluxerbot`. Site operators can block requests whose `User-Agent` contains `Fluxerbot`; doing so prevents rich embeds and embedded media from appearing in Fluxer when someone links to the site.
_YouTube links._ Video metadata is fetched server-side from the YouTube API so previews render without your device contacting YouTube. Playing an embedded video loads content directly from YouTube, which may collect information under its own privacy policy.
_Other third-party content._ Embedded third-party content may load directly from that third party upon interaction, with information collected under the third party's own terms.
Third-party services operate under their own privacy policies and data practices, which apply alongside ours when you use them.
## 14. Law enforcement and legal requests
Every legal request for user data receives careful review, with the privacy and security of the people involved as the primary consideration. Requests should be directed to <legal@fluxer.app> and must identify the requesting authority, legal basis, and scope of data requested. Overbroad, legally invalid, or inconsistent requests may be narrowed or refused. Where the law allows, and where notice would not create a safety, security, or legal-process risk, affected users are notified before disclosure so they have an opportunity to object. In genuine emergencies, disclosure may occur without prior notice where reasonably necessary to prevent harm, protect safety, or respond to an urgent situation, in line with applicable law.
## 15. Changes to this policy
This policy may be updated to reflect changes in our practices, services, or legal obligations. Material changes come with at least 30 days' advance notice through email, in-app notification, or a notice on our website, and the effective date at the top is updated. In the app, a persistent notice may link to the new version, and you may be asked to review and acknowledge the changes so we have a record that you saw them.
After the effective date, the updated policy applies to your continued use of Fluxer. If you disagree with an updated policy, you can export your data, delete your messages, and delete your account at any time, using the tools in Section 8. A [changelog](/changelog) is maintained for reference.
## 16. Contact
**Privacy and data protection:** <privacy@fluxer.app> (Hampus Kraft, Founder and CEO)
**General support:** <support@fluxer.app>
Our postal address, phone number, and all other contact routes (press, security, legal requests) are listed in Section 1 and on our [Company Information page](/company-information).
For account-related requests, write from the email address on your Fluxer account where possible; it makes verifying your identity easier and protects the account.
## 17. Additional information for California residents
This section sets out the further disclosures required by the California Consumer Privacy Act, as amended by the California Privacy Rights Act (together, "CCPA"). It applies only to California residents and sits alongside the rest of the policy.
### 17.1 Categories of personal information collected
- **Identifiers:** username, email address, user ID, IP address, device identifiers. Sources: you, automatic collection, service providers.
- **Customer records (Cal. Civ. Code § 1798.80(e)):** billing country, partial payment card details (via Stripe). Sources: you, Stripe.
- **Internet or other electronic network activity:** pages visited, features used, session timestamps, crash reports, browser type, OS. Source: automatic collection.
- **Geolocation data:** approximate location (city/region/country) derived from IP address. Source: automatic collection.
- **Audio, electronic, visual, or similar information:** voice and video communications (where supported), uploaded images and files. Source: you.
- **Inferences:** approximate region for eligibility checks, spam/abuse risk signals. Source: automatic collection.
Biometric information, professional or employment information, and education information are not collected.
### 17.2 Sensitive personal information
Of the categories of "sensitive personal information" defined by the CCPA, only account log-in credentials (email address combined with password) are collected, for the purposes of running the service and securing the account.
From 1 January 2026, the CPRA also classifies personal information of consumers under 16 as sensitive. Because Fluxer permits account creation from age 13 (depending on jurisdiction), information meeting that definition may be collected and processed. It is not used or disclosed beyond what is needed to run the service.
### 17.3 Business purposes for collection
Personal information is collected and used for the purposes described in Section 3: running the service, securing accounts, preventing abuse, processing payments, improving reliability, and meeting legal obligations.
### 17.4 Categories disclosed for a business purpose
- **Identifiers:** to infrastructure providers (Vultr, Bunny.net), Stripe for payments, Sweego and Twilio for communications, Intercom for support, and IPinfo for registration and anti-abuse IP network signals. Purposes: hosting, delivery, payments, support, registration checks, and abuse prevention.
- **Customer records:** to Stripe, for payment processing.
- **Internet or electronic network activity:** to hCaptcha and IPinfo, for bot prevention and registration and abuse-prevention IP network signals.
- **Geolocation data:** to IPinfo, only for registration and abuse-prevention IP lookups when local databases are insufficient (IP address only, no account context; results cached).
- **Audio, electronic, visual, or similar information:** to Bunny.net, for content delivery.
### 17.5 Sale and sharing of personal information
Personal information is not sold. It is not "sold" or "shared" (using the CCPA's definitions of those terms) for cross-context behavioural advertising or any other purpose. This applies to all consumers, including those under 16.
### 17.6 Retention
Each category of personal information is retained for the periods set out in Section 7.
### 17.7 Your California rights
California residents have the rights listed in Section 10: to know, to delete, to correct, to opt out of sale or sharing (none occurs), and to be free from discrimination. These rights can be exercised by writing to <privacy@fluxer.app> or through the controls described in Section 8. An authorised agent may be designated.
### 17.8 Opt-out preference signals
Global Privacy Control (GPC) and similar signals are honoured, as described in Section 12.6.
@@ -1,55 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
## Reporting security issues
If you find a vulnerability in Fluxer, email **<security@fluxer.app>**.
A good report has a clear title, the affected area, the impact, and steps to reproduce. Screenshots, logs, requests, and environment details all help. The more precise the report, the faster we can fix the issue. Use test accounts where you can, and leave out real users' data and session tokens unless they are strictly needed.
Reports must include a proof of concept demonstrating impact against Fluxer's production services or a supported self-hosted setup. Repository review, local builds, forks, and modified deployments can support a report, but they are not enough on their own.
## Scope
In scope: websites, apps, and services operated by Fluxer Platform AB, including `fluxer.app`, `fluxer.gg`, `fluxer.gift`, `fluxerapp.com`, `fluxer.dev`, `fluxerusercontent.com`, `fluxerstatic.com`, `fluxer.media`, and their subdomains. Infrastructure we directly manage is also in scope, as is abuse of Fluxer features that allows unauthorised access, persistence, or data disclosure. Supported self-hosted releases are in scope when the issue reproduces in the documented setup without custom patches.
Out of scope:
- Third-party services and infrastructure we do not control.
- Physical security, social engineering, and phishing.
- DoS, flooding, resource exhaustion, and noisy scanning. An application-layer DoS provable with a few requests can be reported, just do not exploit it at scale.
- Modified, unsupported, or misconfigured self-hosted deployments, unless the issue also affects Fluxer's production services or a supported self-hosted setup.
- UI bugs, feature requests, and support issues.
- Theoretical findings, like missing best-practice headers, without a realistic attack path.
## Safe harbour
**Good-faith research that follows this policy is authorised.** We authorise good-faith research under this policy for the purposes of Swedish, EU, US, and equivalent anti-hacking laws, and we will not take legal action against you for it. If a third party takes action against you over such research, we will make clear that it was authorised under this policy. Safe harbour applies by default and is not revoked retroactively.
It does not cover extortion, intentional harm to users, service degradation, or data destruction. If you are not sure whether a test is in scope, ask first.
## Testing rules
- Only test with accounts, Communities, and data you own or have permission to use.
- Do not access, change, or delete other people's data. If you reach someone else's data by accident, stop, do not keep it, and tell us.
- Do not degrade the service, message users outside your test, scrape, flood, or brute-force.
- If a test could trigger real notifications, billing, or payments, ask us first.
- Delete any user data from your testing when you are done, and follow the law.
## What happens next
We aim to get back to you within a few days. Severity is judged on real impact: who is affected, what data is at risk, and how exploitable it is. The more severe the issue, the faster we move.
If several people report the same issue, the first clear report gets the credit. If we cannot reproduce something, we will ask for more detail before closing the report.
## Disclosure
Please hold off on public disclosure until we have confirmed and fixed the issue, typically up to 90 days. If a fix takes longer, we will keep you in the loop and agree a timeline; we will not ask for indefinite silence. If we publish an advisory, we will credit you and coordinate timing with you where we can.
## Rewards
Valid reports may earn a Bug Hunter badge and Fluxer Plutonium gift codes, scaled to severity and report quality. To stay eligible, report privately, follow this policy, and do not exploit the issue beyond demonstrating it. Fluxer staff, contractors, and their immediate family are not eligible.
## Contact
Security: <security@fluxer.app>. Everything else: <support@fluxer.app>.
Thank you for helping keep Fluxer safe.
-457
View File
@@ -1,457 +0,0 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
**Effective date:** 2026-04-02
## The short version
These terms are a legal contract between you and Fluxer Platform AB.
Your content is yours. We claim no ownership of anything you create on Fluxer, and the licence you grant us is limited to making the service work: delivering your messages, displaying your profile, and similar functions. Your content is never used for advertising, AI training, or anything outside the features you use. We collect as little personal data as we can, and we never sell it.
Nothing in these terms overrides your rights under applicable consumer law, including refunds, remedies for defective digital content, and access to courts in your home country.
These terms will not change without notice. Material changes come with at least 30 days' notice, an explanation of what changed, and time to export your data and delete your account before they take effect if you disagree.
If we restrict your content or account, you receive the reasons, the evidence relied on, and a route to appeal. This is both our policy and our legal obligation under the EU Digital Services Act.
The service is provided "as is" because we cannot guarantee perfect uptime, but we do not disclaim responsibility for our own negligence or for problems we cause.
Fluxer is a general-purpose communication service. It is not a safety-critical or critical-infrastructure system, and must not be relied on for military, emergency or first-response, healthcare, sanitation, utilities, or similar high-risk operations.
This summary is for convenience. The full terms below govern your use of Fluxer.
## Definitions
**"Services"** means the Fluxer applications (web, mobile, desktop), HTTP and WebSocket APIs, related websites and domains, and any other software, features, or services provided by Fluxer.
**"User Content"** means any data, text, messages, media, files, communities, reactions, or metadata you or other users submit, upload, transmit, store, or display on or through the Services.
**"Plutonium"** means Fluxer's optional paid subscription, which provides additional features and benefits.
**"Community"** means a server, space, or similar environment created or administered on Fluxer where users can communicate or share content.
**"Community Owner"** means a user who creates, owns, or administers a Community and is responsible for setting and enforcing rules within it, subject to these terms and our [Community Guidelines](/guidelines).
**"Account"** means a user account registered with Fluxer, associated with a unique identifier and typically an email address.
**"Minimum Age"** means the lowest age at which applicable law in your country permits you to use an online service like Fluxer. It is usually 13, though some jurisdictions set it higher.
## 1. Agreement and eligibility
### 1.1 Accepting these terms
By creating an account or using Fluxer, you agree to these terms, our [Privacy Policy](/privacy), and our [Community Guidelines](/guidelines). If you do not agree, do not use Fluxer.
Where these terms conflict with mandatory local law, the law prevails. Where they conflict with our [Privacy Policy](/privacy) on the handling of personal data, the Privacy Policy controls to the extent of the conflict.
### 1.2 Who can use Fluxer
You may use the Services only if you meet the Minimum Age where you live and can enter into a binding contract, or if your parent or legal guardian agrees to these terms on your behalf as described below.
We determine eligibility from your self-reported information and approximate geographic location. The full list of minimum ages is in our [help article on minimum age requirements](/help/minimum-age).
**Younger users.** If you are at or above the Minimum Age but under the age of legal majority in your jurisdiction, your parent or legal guardian must review and agree to these terms on your behalf before you use the Services. If you allow a minor to use your account or the Services, you confirm that you are the minor's parent or legal guardian, that you have reviewed and agreed to these terms, and that you are responsible for the minor's activity on the Services.
**Regional restrictions.** Some countries require age-verification methods we do not offer, such as government ID uploads or biometric scans. Where requirements go beyond what we support, we may restrict or disable access from those regions, using automated systems such as IP geolocation. If you believe your access has been restricted in error, contact us. For details, see our [Regional restrictions](/help/regional-restrictions) page and our [Privacy Policy](/privacy).
**You must not use the Services if** applicable law in your jurisdiction prohibits you from doing so, if you are subject to relevant export control or sanctions restrictions (see Section 14), or if your account has previously been terminated by us for breach of these terms, unless we have expressly agreed in writing to let you return.
### 1.3 Consumer use and custom contracts
These terms govern your use of Fluxer as a consumer and for general personal or community use. If you or your organisation sign a separate written enterprise, business, or custom agreement with us that expressly supersedes these terms, that agreement governs where it conflicts. In all other respects, these terms still apply.
## 2. Your account
### 2.1 Account security
Most Fluxer features require an account. You are responsible for keeping your login credentials confidential and secure, for activity under your account except where applicable law says otherwise, for providing accurate registration information, and for keeping it up to date.
If you become aware of unauthorised access to or use of your account, let us know promptly at <support@fluxer.app>. Use a strong, unique password and two-factor authentication (2FA) where available.
Nothing in this section affects any non-waivable rights you may have under applicable consumer or payment laws in relation to unauthorised charges or security incidents.
## 3. Using Fluxer
### 3.1 What you can do
Fluxer is a communication and community service. You can send and receive messages, files, and media; create, manage, moderate, and participate in Communities; engage in voice and video communications; and subscribe to Plutonium for premium features.
Your use must comply with these terms, our [Community Guidelines](/guidelines), and applicable laws.
### 3.2 What you must not do
You must not use the Services to:
- violate any applicable law or regulation
- violate our [Community Guidelines](/guidelines)
- promote, glorify, encourage, or provide instructions for self-harm or harm to others
- threaten, harass, or bully other users
- infringe, misappropriate, or violate the intellectual property or other rights of others
- distribute malware, viruses, or other harmful code
- carry out cyberattacks or unauthorised access
- impersonate any person, entity, or organisation
- circumvent, disable, or interfere with security-related features or access controls
- abuse our free services or resources (see Section 5)
### 3.3 Service changes and availability
The Services are provided without a service-level agreement (SLA). Outages, interruptions, and performance issues may occur. Features may be added, modified, removed, or discontinued; when changes matter, we give notice where reasonably practicable, by email, in-app notification, or update notes. Some features may be limited, unavailable, or different depending on your region, device, account type, or applicable law. Access may be temporarily limited or suspended for maintenance, security, legal, or technical reasons.
**Your rights when paid features change.** If we make changes that significantly and adversely affect paid features you have already purchased, you may have additional rights under applicable consumer laws, including refunds or price reductions. Nothing in these terms limits those mandatory rights, and the consumer protections that apply where you live will be honoured in full.
### 3.4 Unsupported safety-critical use cases
Fluxer is a general-purpose communication and community service. It is not designed, intended, or supported for safety-critical, mission-critical, or other high-risk use where outages, delays, errors, security failures, or incorrect or delayed information could reasonably be expected to cause death, personal injury, physical or environmental harm, or material disruption to essential services or critical infrastructure.
You must not use, or permit others to use, the Services as a primary, backup, or failover system for:
- military or defence operations
- emergency, first-response, or public-safety dispatch or coordination
- healthcare, clinical, medical, or life-sustaining decision-making
- utilities, water, wastewater, sanitation, energy, transport, or other critical infrastructure or essential public services
## 4. Your content
### 4.1 You own your content
You keep full ownership of all User Content you create and share on or through the Services. We claim no ownership of your content. You are responsible for having the rights, licences, and permissions needed to share your content on the Services and to grant the rights described below.
### 4.2 The licence you grant us, and its limits
We need a licence to your content only so the features you use can work. By making User Content available on or through the Services, you grant Fluxer a limited, worldwide, non-exclusive, royalty-free licence to use it solely to carry out the actions you and other users take on the Services. This licence covers the following.
**Delivering your content to its intended recipients.** When you send a message or share a file, it is reproduced, transmitted, cached, and displayed so the people you chose to share it with can receive it.
**Displaying your content where you placed it.** When you set an avatar, send a message in a Community, or upload an emoji, your content is hosted and displayed in the context you selected.
**Applying technical processing the feature requires.** Media may be compressed, transcoded, re-encoded, resized, or reformatted so it can be viewed or played across devices and network conditions, for example by generating a video thumbnail.
**Operating infrastructure on your behalf.** These rights are sublicensed to our hosting and infrastructure providers, such as content delivery networks and cloud storage, strictly so they can help deliver the features above. Contractual safeguards prevent those providers from using your content for their own purposes.
The licence is purpose-limited: it exists only to make the Services function as you and other users direct, and gives no independent right to use your content beyond that scope.
### 4.3 Uses that are always excluded
These uses are excluded from the licence above and from every other part of these terms. We will never:
- use your User Content to train, fine-tune, or evaluate AI or machine learning models
- license, sell, or share your User Content with any third party for advertising, marketing, analytics, research, or any purpose not directly needed to deliver a feature you used
- build profiles from your User Content for ad targeting or behavioural advertising
- mine, analyse, or aggregate your User Content for our own commercial benefit outside running the Services
- display your User Content in any context other than the one you chose, or feature your messages in marketing without your explicit, separate consent
Outside this scope, your User Content may be disclosed only when valid legal process, a court order, or applicable law compels us (with notice to you where legally permitted), or as described in our [Privacy Policy](/privacy).
### 4.4 Revoking this licence
You can revoke this licence for specific User Content at any time by deleting that content from the Services. Deletion and retention timelines are in Section 4.5 and our [Privacy Policy](/privacy); deleted content is never used for any purpose while it persists in backups.
### 4.5 Content deletion and retention
When you delete User Content, it is removed from our active systems within a reasonable period. Deleted messages may persist in encrypted database backups for up to 30 days. Media attachments are not included in our backups and are permanently removed once cleared from active storage and CDN caches.
Attachments may expire over time based on factors such as file size and age. Items saved to Saved Media are treated differently. For details, see [the help article on attachment expiry](/help/attachment-expiry).
If you plan to delete messages or your account, first download any attachments or other content you want to keep. For export details, see [the help article on exporting your account data](/help/data-export).
Certain information may be retained after you delete content or close your account where we have a legal obligation to do so (for example, tax compliance or evidence preservation). Any such retained data is handled in line with our [Privacy Policy](/privacy).
### 4.6 Content scanning and safety
Automated systems and, where necessary, human review help keep the Services safe and compliant with the law.
**NSFW classification.** A small pretrained image classifier (not a generative AI system, not an LLM) detects probable nudity and explicit content in uploaded media. It runs entirely on our own servers, no media is sent to any third party for this purpose, and the classifier output is used only for this age-restriction check and never to train AI models. Flagged content is restricted to users who are 18 or older.
**Other safety measures.** Automated tools and signals are used to detect spam, malware, phishing, abuse, and violations of our terms and Community Guidelines.
For full details, see Section 5 of our [Privacy Policy](/privacy).
### 4.7 Copyright and intellectual property
If you believe content on Fluxer infringes your copyrights, let us know at <copyright@fluxer.app>. Include:
- a description of the copyrighted work
- the location of the allegedly infringing material on Fluxer (message links, channel IDs, user IDs, or similar)
- a good-faith statement that the use is not authorised by the rights holder, its agent, or the law
- a statement that the information is accurate and that you are the rights holder or authorised to act on their behalf
- your signature
The statement of reasons we send to the affected user identifies the content acted on, the rule or legal ground, and the rights holder or category of right concerned. We do not forward your email address, postal address, signature, or other direct contact details unless the law requires it. We accept pseudonymous or representative-filed complaints (for example, through a law firm or rights-protection service).
We may remove or disable allegedly infringing material and notify the user who sent it. Where appropriate and in line with applicable law, repeat infringers may have their accounts terminated. If content is removed in response to a copyright notice, the affected user may be offered a chance to submit a counter-notice where the law permits.
## 5. Acceptable use and service integrity
### 5.1 Fair use of free services
Our free tier is intended for communication and community use. Enforcement action may follow if:
- Fluxer is used primarily as unlimited cloud storage
- excessive data or unusual load negatively affects other users
- malware or illegal content is distributed
- Fluxer infrastructure is used for command-and-control of harmful systems
- our infrastructure is deliberately stress-tested or overloaded without prior written permission
This policy targets abuse that harms Fluxer and other users, not good-faith use of Fluxer for its intended purposes.
### 5.2 Service integrity
We monitor for and act against:
- automated spam, bulk messaging, and abuse
- large-scale or unauthorised data scraping or harvesting
- manipulation of service metrics or engagement statistics
- coordinated inauthentic behaviour and fake engagement
- community raiding, brigading, or mass harassment
- attempts to bypass safety, moderation, or rate-limiting systems
Violations may result in immediate content removal, feature restrictions, or account suspension or termination, with or without prior warning depending on severity and risk.
## 6. Paid services and subscriptions
### 6.1 Payment authorisation
By providing a payment method, you authorise us to charge it for any Services you purchase, including recurring subscription fees. You confirm that you have the legal right to use that payment method and authorise our payment processor (Stripe) to store your payment information securely. You also authorise us to retry failed payments or charge backup payment methods you have added, and agree that we share necessary payment information with Stripe only to process transactions, prevent fraud, and comply with legal obligations.
You are responsible for applicable taxes, fees, and charges related to your purchases, except where we are required by law to collect and remit them.
### 6.2 Fluxer Plutonium
Plutonium is digital content that works across all platforms and browsers where Fluxer is available (web, desktop, and mobile) and includes no technical protection measures (DRM) that restrict its use. Plutonium features require an active internet connection and a Fluxer account in good standing. Specific features and benefits are described on our website and may change over time, subject to the protections in Section 3.3.
**Automatic renewal.** By subscribing to Plutonium, you agree to recurring automatic payments. Unless you cancel, your subscription renews at the end of each billing period and your payment method is charged the applicable fee and taxes. You can cancel at any time through your account settings; cancellation takes effect at the end of your current billing period, so you keep premium access until then. Refunds for partial billing periods are not issued unless required by law or as otherwise described in these terms.
**Price changes.** Subscription prices may change from time to time. Price increases do not apply to an active, continuously renewing subscription while it remains in good standing: you continue to pay the price that applied when you started or last changed your subscription (excluding expired temporary discounts). Price reductions or discounts may be applied at our discretion. If your subscription is cancelled, expires, or lapses and you later resubscribe, the price at the time of resubscription applies and will be shown to you before you confirm.
### 6.3 Refunds
**Self-service refunds.** You can request a refund for any Plutonium purchase (including subscriptions and gifts) within 3 days of payment completion, directly from the billing history in your account settings. Self-service refunds are processed automatically.
When a self-service refund is processed, subscriptions are immediately cancelled and premium access ends. For gift purchases, the recipient's premium access is adjusted or revoked, and the recipient may be notified.
You can use one self-service refund per rolling 30-day period. If you need help outside the self-service window (for example, a billing error or exceptional circumstances), contact <support@fluxer.app>.
Nothing in this section limits your mandatory consumer rights, including statutory rights to refunds, remedies for defective digital content, or withdrawal rights under applicable law.
### 6.4 EU/EEA right of withdrawal
If you are a consumer in the EU or EEA, you have a statutory right to withdraw from a purchase of digital content within 14 days of the purchase date, under the EU Consumer Rights Directive (Directive 2011/83/EU).
Plutonium is digital content delivered immediately on purchase. Before every purchase, you will be asked to (i) expressly consent to performance beginning during the withdrawal period, and (ii) acknowledge that you will lose your right of withdrawal once the digital content is provided. We then send you confirmation of this consent. By confirming, you waive your right of withdrawal for that specific purchase in accordance with Article 16(m) of the Directive, as amended by Directive (EU) 2019/2161.
If you have not given this consent, or if the digital content has not yet been fully provided, you may exercise your right of withdrawal by contacting <support@fluxer.app> within 14 days. No reason is needed. The refund is processed without undue delay and no later than 14 days after we are informed of your decision, using the same payment method unless you expressly agree otherwise.
This waiver applies only to the EU/EEA statutory right of withdrawal and does not affect non-waivable consumer rights under other applicable laws.
### 6.5 Failed payments
If a payment fails, we automatically retry a reasonable number of times and may charge backup payment methods you have added. Premium features may be suspended or downgraded until payment succeeds, and you remain responsible for any unpaid amounts.
Fees, charges, or penalties imposed by your bank or financial institution in connection with failed payments or chargebacks are not our responsibility.
### 6.6 Chargebacks and payment disputes
If you think there is a billing error or unauthorised charge, try the self-service refund option or contact <support@fluxer.app> first; that is usually the fastest way to resolve it.
You can always exercise your non-waivable rights under applicable law to dispute charges through your bank or payment provider. When notice of a chargeback reaches us, premium purchases on the account may be temporarily disabled while the dispute is under review, the recipient's premium access may be adjusted for disputed gift purchases, and additional information may be requested to investigate.
Fraudulent chargebacks or bad-faith payment disputes may result in enforcement action. Stripe or your payment provider may also contact you directly.
## 7. Privacy and data protection
How we handle personal data is covered in our [Privacy Policy](/privacy), which sets out what data is collected, how it is used, and the rights you have. The main points:
- your personal data is not sold, rented, or traded
- AI models are not trained on your content
- nothing on Fluxer is currently end-to-end encrypted; opt-in end-to-end encryption is planned for Personal Notes, DMs, Group DMs, and voice chats
- strong encryption is used for data in transit and at rest
- data collection is limited to what is needed
- you can export, manage, and delete your data through your Privacy Dashboard
Depending on where you live, you may have rights including access, rectification, deletion, data portability, objection, and restriction.
Please read our [Privacy Policy](/privacy) carefully. Where these terms conflict with it on the handling of personal data, the Privacy Policy controls.
## 8. Third-party services
Fluxer uses third-party services to operate, including hosting and infrastructure providers, payment processors, content delivery networks, security services, and communication services. Our [Privacy Policy](/privacy) describes these services and how they handle data.
Third-party services have their own terms and privacy policies. Your use of those services may be subject to their terms, and we are not responsible for their content, availability, or practices.
Some integrations involve direct interaction with third-party content (for example, playing an embedded YouTube video). In those cases, the third party may receive information directly from your device and process it under its own terms and privacy policy.
## 9. Account termination and inactivity
### 9.1 Deleting your account
You can delete or disable your Fluxer account at any time through your account settings.
Disabling signs you out of every device. The account remains in our systems with no data removed, and you can sign back in at any time to turn it back on.
Deleting starts a 14-day grace period during which signing back in cancels the deletion. After that period, identifying information leaves active systems and the rest is anonymised, with encrypted backups purged on the rolling cycle in our [Privacy Policy](/privacy) Section 7. Before deletion, you can opt to schedule all of your messages for deletion so they are removed from Communities and direct messages at the same time; if you do not, messages you sent may stay visible to other users. For details, see [the guide to deleting or disabling an account](/help/delete-account).
### 9.2 Suspension and termination by Fluxer
Accounts may be suspended or terminated, or access to the Services restricted, if we reasonably believe:
- the account or user has violated these terms or our [Community Guidelines](/guidelines)
- the account has been used for illegal activity
- the Services are being abused through spam, fraud, or other malicious behaviour
- the account is involved in fraudulent chargebacks or payment abuse
- restriction is necessary for security, service integrity, or legal compliance
**Due process.** Advance warning is usually given before suspending or terminating an account, unless the violation is severe, poses immediate risk, involves illegal conduct, or we are legally prohibited from giving notice. If your account is terminated for cause, you may lose access to your User Content and associated data, subject to applicable law and our data retention practices.
**Statement of reasons.** When content is restricted, features are suspended, or an account is terminated, you will receive a clear and specific statement of reasons. It will include the rule or legal ground for the action, the facts relied on, whether automated means were used, and available redress options, including how to appeal and, for users in the EU, the option to refer disputes to a certified out-of-court dispute settlement body.
**Appeals.** If you believe an enforcement decision was incorrect, you can appeal in line with our [Community Guidelines](/guidelines), for example by emailing <appeals@fluxer.app> from the email address associated with your account.
### 9.3 Account inactivity
To protect user privacy and manage resources, inactive accounts may be deleted after 2 years with no sign-ins or other meaningful activity. Where feasible, we send advance notice to the registered email address first. For current criteria, notice periods, and timelines, see [the guide to deleting or disabling an account](/help/delete-account).
When an account is deleted, messages and content you sent may stay visible to other users unless you delete them first or choose the message-deletion option during account deletion. You may no longer be able to access your User Content unless you exported it beforehand.
## 10. Disclaimers and limitation of liability
### 10.1 Service quality
Fluxer is maintained with care, but the Services are provided on an "as is" and "as available" basis. To the fullest extent the law allows, no express or implied warranties are given about the Services, including warranties of merchantability, fitness for a particular purpose, or that the Services will be uninterrupted, secure, or error-free.
We cannot guarantee 100% uptime or availability, that the Services will be free from defects or vulnerabilities, or that content sent through the Services will always be delivered or stored.
**What this disclaimer does not cover.** This disclaimer does not limit our responsibility for problems that are our fault. It does not override any mandatory consumer protection rights that apply to you, and it does not excuse our own negligence, wilful misconduct, or failure to meet obligations we have voluntarily taken on in these terms or our [Privacy Policy](/privacy).
### 10.2 Limitation of liability
To the maximum extent the law allows, Fluxer is not liable for any indirect, incidental, consequential, special, or punitive damages, or any loss of profits, revenues, data, goodwill, or other intangible losses, arising out of or in connection with your use of, or inability to use, the Services, regardless of the legal theory, even if we have been advised of the possibility.
To the extent we are liable under applicable law, our total aggregate liability for all claims arising out of or relating to the Services or these terms is limited to the greater of €100 or the total amount you have paid to Fluxer during the 12 months immediately preceding the event giving rise to the claim.
**What we never exclude.** Nothing in these terms limits or excludes any liability that cannot be limited or excluded under applicable law, including liability for gross negligence, wilful misconduct, death or personal injury caused by our negligence, or any non-waivable rights under mandatory consumer protection laws. If you are a consumer in the EU/EEA, the UK, or another jurisdiction with mandatory consumer protection laws, these limitations apply only to the extent those laws permit and do not affect your statutory rights.
### 10.3 Your responsibility
If your use of the Services or your User Content causes a third party to bring a claim against Fluxer, such as a copyright infringement claim, you agree to cooperate with us in resolving that claim and, to the extent the law permits, to bear the reasonable costs and damages directly attributable to your actions. This does not apply to the extent a claim arises from our own breach, negligence, or wilful misconduct.
If you are a consumer in the EU/EEA, the UK, or another jurisdiction where indemnification clauses are restricted against consumers, this section applies only to the extent permitted by the mandatory laws of your jurisdiction.
## 11. Dispute resolution and governing law
**Informal resolution first.** If you have a concern or dispute, contact <support@fluxer.app> first. We will work with you in good faith to resolve it informally within 30 days.
**Governing law.** Unless otherwise required by mandatory local law, these terms and any disputes arising from them or the Services are governed by Swedish law, without regard to conflict-of-law rules.
**Jurisdiction.** Disputes will be submitted to the courts of Stockholm, Sweden, which will have exclusive jurisdiction, subject to the exceptions below.
**Small claims.** Either party may bring an individual claim in a competent small-claims court where venue is proper, instead of in Stockholm.
**EU/EEA consumers.** If you are a consumer in the EU, EEA, or another jurisdiction that gives you mandatory rights to bring claims in your home courts, nothing in these terms limits those rights. You can bring proceedings in the courts of your country of residence.
**Collective action rights preserved.** Nothing in these terms stops you from participating in class actions, collective actions, representative proceedings, or any other form of collective redress available under the laws of your jurisdiction. Mandatory binding arbitration is not required, and you are not asked to waive your right to collective action.
**Alternative dispute resolution.** If you are a consumer in the EU and want to resolve a dispute through alternative dispute resolution, you can refer the matter to a certified out-of-court dispute settlement body. Information about available bodies is provided through the Digital Services Coordinator in your EU Member State. For content moderation disputes specifically, you may also refer the matter to a certified body under DSA Article 21; see Section 15.5.
## 12. Changes to these terms
These terms may be updated to reflect changes in our Services, legal requirements, or business practices.
Material changes come with at least 30 days' advance notice by email, in-app notification, or a notice on our website. Updated terms are published with their effective date, and a persistent in-app notice may link to them.
Before material changes take effect, you can review them and, if you do not agree, export your data and delete your account. Continuing to use the Services after the effective date means you accept the updated terms.
A [changelog](/changelog) is maintained for reference.
## 13. Account communications and verification
Send account-related communications from the email address associated with your account; that is our primary way to verify your identity.
For security reasons, account support, sensitive information, and account changes are normally only handled when you contact us from that email address. If you lose access to your registered email, additional verification may be needed, and account recovery or modification may not always be possible.
Fluxer will never ask for your password, full payment card number, or other sensitive security information by email. Our official email domains are listed on our [Company Information page](/company-information). If you receive a suspicious message claiming to be from Fluxer, do not click links or provide information; contact us directly at <support@fluxer.app>.
## 14. Export controls and sanctions
You must comply with all applicable export control, sanctions, and related laws when using the Services. You may not use the Services if you are located in, or ordinarily resident in, a comprehensively embargoed country or region, or if you appear on any applicable sanctions, denied-party, or restricted lists. You agree not to export, re-export, or transfer the Services in violation of these laws.
Access may be restricted or terminated to comply with these requirements. If you have questions about how these laws may apply to you, seek your own legal advice.
## 15. EU Digital Services Act
As a provider of intermediary services established in the European Union, we comply with the EU Digital Services Act (Regulation (EU) 2022/2065).
### 15.1 Single point of contact
For EU authorities, the European Commission, and the European Board for Digital Services: <legal@fluxer.app>. Our postal address and phone number are in Section 17 and on our [Company Information page](/company-information).
For users with DSA questions: <support@fluxer.app>. Communications may be conducted in English or Swedish.
### 15.2 Legal representative
Since Fluxer Platform AB is established in Sweden (an EU Member State), no separate legal representative is required under DSA Article 13.
### 15.3 Statements of reasons
When content is restricted, features are suspended, or an account is terminated, the affected user receives a clear and specific statement of reasons, as described in Section 9.2.
### 15.4 Internal complaint handling
Users affected by content moderation decisions can submit a complaint through the appeals process described in our [Community Guidelines](/guidelines). Complaints are handled free of charge, reviewed by qualified staff (not resolved by fully automated means alone), and decided without undue delay.
If a complaint shows that content is not illegal and does not violate our terms or guidelines, the decision is reversed without undue delay.
### 15.5 Out-of-court dispute settlement
If you are in the EU and are not satisfied with the outcome of our internal complaint handling, you can refer the dispute to a certified out-of-court dispute settlement body under DSA Article 21. A list of certified bodies is available through the Digital Services Coordinator in your Member State. We will engage in good faith with any certified body you select.
### 15.6 Trusted flaggers
Priority is given to reports submitted by entities designated as trusted flaggers under DSA Article 22. If you are a designated trusted flagger, contact <legal@fluxer.app> so an appropriate workflow can be set up.
### 15.7 Transparency reporting
As a micro enterprise under the DSA, we are currently exempt from the transparency reporting obligations in Article 15. Voluntary transparency reports are planned as Fluxer grows, covering content moderation activities, action types, automated tools, complaints and outcomes, orders from authorities, and our responses. When published, reports will be available on our website and cover the preceding calendar year.
### 15.8 UK Online Safety Act
Our safety measures, content moderation practices, and transparency work are designed to meet our obligations under the UK Online Safety Act 2023 for user-to-user services accessible in the United Kingdom, including the illegal content duties set out in Ofcom's codes of practice. The required risk assessments and children's access assessments are in progress, and this section will be updated as that work moves forward.
## 16. General provisions
### 16.1 Severability
If any provision of these terms is found invalid or unenforceable, it will be modified to the minimum extent necessary to make it enforceable (or removed if modification is not possible), and the remaining provisions continue in full force.
### 16.2 Entire agreement
These terms, together with our [Privacy Policy](/privacy) and [Community Guidelines](/guidelines), constitute the entire agreement between you and Fluxer regarding the Services, superseding all prior agreements on the same subject matter. This does not affect any separate written agreement under Section 1.3.
### 16.3 No waiver
Failure to enforce any right or provision does not amount to a waiver. Any waiver must be in writing and signed by an authorised representative.
### 16.4 Assignment
You may not assign your rights or obligations without our prior written consent. We may assign ours in connection with a merger, acquisition, or sale of substantially all our assets, provided the assignee agrees to be bound by these terms. Any attempted assignment in violation is void.
### 16.5 Force majeure
Neither party is liable for failure to perform obligations (other than payment) to the extent caused by circumstances beyond reasonable control, including natural disasters, pandemics, acts of government, war, terrorism, power outages, telecommunications failures, or internet disruptions. The affected party will use reasonable efforts to mitigate and resume performance.
### 16.6 Electronic communications
By creating an account, you consent to receive electronic communications necessary to operate the Services for you: transactional and security messages (login alerts, password resets, payment receipts), administrative notices (account changes, enforcement decisions, appeals), and material updates to these terms or our policies. These may be sent by email, in-app notification, or other electronic means, and you cannot opt out of them while you maintain an account.
Marketing, promotional, and feature-announcement messages are separate. They are sent only where the law allows, or where you have opted in, and you can unsubscribe at any time through the link in those messages or from your notification settings without affecting required service communications.
### 16.7 Language
These terms are written in English. If translations are provided, the English version prevails in the event of a conflict.
## 17. Contact information
These terms are entered into with Fluxer Platform AB, organisation number 559537-3993, c/o Embassy House, Östgötagatan 12, 116 25 Stockholm, Sweden.
**Support:** <support@fluxer.app>
**Privacy:** <privacy@fluxer.app>
For our phone number and all other contact routes (press, security, legal requests), see our [Company Information page](/company-information).
-17
View File
@@ -1,17 +0,0 @@
{
"name": "fluxer_marketing",
"private": true,
"scripts": {
"build:css": "tailwindcss -i ./src/styles/app.css -o ./target/app.css --minify",
"preprocess:blog-image": "cargo run -p fluxer-dev -- marketing preprocess-blog-image",
"preprocess:blog-video": "cargo run -p fluxer-dev -- marketing preprocess-blog-video"
},
"devDependencies": {
"@tailwindcss/cli": "catalog:",
"tailwindcss": "catalog:"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1"
},
"packageManager": "pnpm@10.29.3"
}
-180
View File
@@ -1,180 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::{self as cfg, GeoipS3Config, GeoipSourceConfig};
use std::env;
const DEFAULT_SECRET_KEY_BASE: &str = "development-marketing-secret";
#[derive(Clone, Debug)]
pub struct MarketingConfig {
pub env: RuntimeEnv,
pub host: String,
pub port: u16,
pub secret_key_base: String,
pub base_path: String,
pub api_endpoint: String,
pub static_cdn_endpoint: String,
pub marketing_endpoint: String,
pub geoip_db_path: String,
pub geoip_source: GeoipSourceConfig,
pub geoip_s3_config: Option<GeoipS3Config>,
pub trust_client_ip_header: bool,
pub client_ip_header_name: String,
pub release_channel: ReleaseChannel,
pub build_version: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeEnv {
Development,
Production,
Test,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ReleaseChannel {
Stable,
Canary,
}
pub const DOWNLOAD_RELEASE_CHANNEL: ReleaseChannel = ReleaseChannel::Canary;
impl MarketingConfig {
pub fn from_env() -> Self {
let geoip_source = cfg::parse_geoip_source_config(
&cfg::read_first_env(&["FLUXER_GEOIP_DB_PATH", "MAXMIND_DB_PATH"], ""),
"marketing",
);
let geoip_s3_config = cfg::read_geoip_s3_config_from_env(&geoip_source);
let geoip_db_path = geoip_source.maxmind_db_path().unwrap_or_default();
let env = RuntimeEnv::from_env_value(&cfg::read_env("FLUXER_ENV", "development"));
let secret_key_base =
cfg::read_env("FLUXER_MARKETING_SECRET_KEY_BASE", DEFAULT_SECRET_KEY_BASE);
if env == RuntimeEnv::Production && secret_key_base == DEFAULT_SECRET_KEY_BASE {
panic!(
"FLUXER_MARKETING_SECRET_KEY_BASE must be set to a non-default value in production"
);
}
Self {
env,
host: cfg::read_env("FLUXER_MARKETING_HOST", "0.0.0.0"),
port: cfg::read_env("FLUXER_MARKETING_PORT", "3010")
.parse()
.unwrap_or(3010),
secret_key_base,
base_path: cfg::normalize_base_path(&cfg::read_env("FLUXER_MARKETING_BASE_PATH", "")),
api_endpoint: cfg::trim_trailing_slash(&cfg::read_env(
"FLUXER_API_ENDPOINT",
"https://api.fluxer.app",
)),
static_cdn_endpoint: cfg::trim_trailing_slash(&cfg::read_env(
"FLUXER_STATIC_CDN_ENDPOINT",
"",
)),
marketing_endpoint: cfg::trim_trailing_slash(&cfg::read_env(
"FLUXER_MARKETING_ENDPOINT",
"https://fluxer.app",
)),
geoip_db_path,
geoip_source,
geoip_s3_config,
trust_client_ip_header: cfg::read_bool_env(
&["FLUXER_TRUST_CLIENT_IP_HEADER", "TRUST_CLIENT_IP_HEADER"],
false,
),
client_ip_header_name: cfg::read_first_env(
&[
"FLUXER_CLIENT_IP_HEADER_NAME",
"FLUXER_CLIENT_IP_HEADER",
"CLIENT_IP_HEADER_NAME",
"CLIENT_IP_HEADER",
],
"x-forwarded-for",
)
.trim()
.to_ascii_lowercase(),
release_channel: ReleaseChannel::from_env_value(&cfg::read_env_preferred(
&["RELEASE_CHANNEL", "FLUXER_RELEASE_CHANNEL"],
"stable",
)),
build_version: cfg::read_env_preferred(
&["BUILD_VERSION", "FLUXER_BUILD_VERSION"],
env!("CARGO_PKG_VERSION"),
),
}
}
pub fn base_url(&self) -> String {
if self.base_path.is_empty() {
return self.marketing_endpoint.clone();
}
if self.marketing_endpoint.ends_with(&self.base_path) {
return self.marketing_endpoint.clone();
}
format!("{}{}", self.marketing_endpoint, self.base_path)
}
pub fn is_dev(&self) -> bool {
self.env == RuntimeEnv::Development
}
pub fn is_canary(&self) -> bool {
self.release_channel == ReleaseChannel::Canary
}
}
impl RuntimeEnv {
fn from_env_value(value: &str) -> Self {
match value {
"production" => Self::Production,
"test" => Self::Test,
_ => Self::Development,
}
}
}
impl ReleaseChannel {
fn from_env_value(value: &str) -> Self {
if value.eq_ignore_ascii_case("canary") {
Self::Canary
} else {
Self::Stable
}
}
pub const fn is_canary(self) -> bool {
matches!(self, Self::Canary)
}
pub const fn segment(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Canary => "canary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_geoip_s3_source_like_typescript_startup() {
let source = cfg::parse_geoip_source_config(
"s3://geoip/GeoLite2-City.mmdb?download_path=/tmp/city.mmdb&asn_key=GeoLite2-ASN.mmdb",
"marketing",
);
assert_eq!(
source,
GeoipSourceConfig::S3 {
maxmind_db_path: "/tmp/fluxer/geoip/marketing/city.mmdb".to_owned(),
maxmind_asn_db_path: Some(
"/tmp/fluxer/geoip/marketing/GeoLite2-ASN.mmdb".to_owned()
),
s3_bucket: "geoip".to_owned(),
s3_key: "GeoLite2-City.mmdb".to_owned(),
s3_asn_key: Some("GeoLite2-ASN.mmdb".to_owned()),
}
);
}
}
File diff suppressed because it is too large Load Diff
-116
View File
@@ -1,116 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use moka::future::Cache;
use serde::Deserialize;
use std::time::Duration;
const FETCH_TIMEOUT: Duration = Duration::from_millis(1500);
const CACHE_TTL: Duration = Duration::from_secs(120);
#[derive(Clone, Debug, Default)]
pub struct LatestDesktopVersions {
pub windows: Option<LatestVersionInfo>,
pub macos: Option<LatestVersionInfo>,
pub linux: Option<LatestVersionInfo>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct LatestVersionInfo {
pub version: String,
#[serde(default)]
pub minimum_system_version: Option<String>,
}
#[derive(Clone)]
pub struct LatestVersionsCache {
entries: Cache<String, LatestDesktopVersions>,
}
impl LatestVersionsCache {
pub fn new() -> Self {
Self {
entries: Cache::builder()
.max_capacity(8)
.time_to_live(CACHE_TTL)
.build(),
}
}
}
impl Default for LatestVersionsCache {
fn default() -> Self {
Self::new()
}
}
pub async fn fetch_latest_desktop_versions_cached(
cache: &LatestVersionsCache,
client: &reqwest::Client,
api_endpoint: &str,
channel: &str,
) -> LatestDesktopVersions {
if let Some(cached) = cache.entries.get(channel).await {
return cached;
}
let fresh = fetch_latest_desktop_versions(client, api_endpoint, channel).await;
if fresh.windows.is_some() || fresh.macos.is_some() || fresh.linux.is_some() {
cache
.entries
.insert(channel.to_owned(), fresh.clone())
.await;
}
fresh
}
pub async fn fetch_latest_desktop_versions(
client: &reqwest::Client,
api_endpoint: &str,
channel: &str,
) -> LatestDesktopVersions {
let windows = fetch_latest_desktop_version(client, api_endpoint, channel, "win32", "x64");
let macos = fetch_latest_desktop_version(client, api_endpoint, channel, "darwin", "arm64");
let linux = fetch_latest_desktop_version(client, api_endpoint, channel, "linux", "x64");
let (windows, macos, linux) = tokio::join!(windows, macos, linux);
LatestDesktopVersions {
windows,
macos,
linux,
}
}
pub fn format_latest_version_line(info: &LatestVersionInfo) -> String {
format!("v{}", info.version)
}
async fn fetch_latest_desktop_version(
client: &reqwest::Client,
api_endpoint: &str,
channel: &str,
platform: &str,
arch: &str,
) -> Option<LatestVersionInfo> {
let url = format!(
"{}/dl/desktop/{channel}/{platform}/{arch}/latest",
api_endpoint.trim_end_matches('/')
);
let response = client
.get(url)
.timeout(FETCH_TIMEOUT)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let body = response.bytes().await.ok()?;
if body.is_empty() {
return None;
}
let info = serde_json::from_slice::<LatestVersionInfo>(&body).ok()?;
if info.version.is_empty() {
None
} else {
Some(info)
}
}
-93
View File
@@ -1,93 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
include!(concat!(env!("OUT_DIR"), "/static/fonts.rs"));
pub fn asset(file_name: &str) -> Option<(&'static str, &'static [u8])> {
ASSETS
.iter()
.find(|(name, _, _)| *name == file_name)
.map(|(_, content_type, bytes)| (*content_type, *bytes))
}
#[cfg(test)]
mod tests {
use super::*;
fn stylesheet() -> &'static str {
let (content_type, bytes) =
asset(STYLESHEET_FILE_NAME).expect("the generated stylesheet must be servable");
assert_eq!(content_type, "text/css; charset=utf-8");
std::str::from_utf8(bytes).expect("stylesheet must be UTF-8")
}
#[test]
fn stylesheet_is_served_and_content_hashed() {
let css = stylesheet();
assert!(css.contains("font-family: 'Fluxer Sans'"));
assert!(css.contains("font-family: 'Fluxer Mono'"));
assert!(
!css.contains("?v="),
"content hashing replaces cache-bust tokens"
);
assert!(
!css.contains("fluxerstatic"),
"fonts must not be fetched from the static CDN"
);
assert!(
STYLESHEET_FILE_NAME.starts_with("fonts.") && STYLESHEET_FILE_NAME.ends_with(".css"),
"unexpected stylesheet name {STYLESHEET_FILE_NAME}"
);
}
#[test]
fn every_face_the_stylesheet_references_is_served() {
let css = stylesheet();
let mut referenced = 0;
for fragment in css.split("url('").skip(1) {
let file_name = fragment.split('\'').next().expect("unterminated url()");
let (content_type, _) = asset(file_name)
.unwrap_or_else(|| panic!("stylesheet references unserved font {file_name}"));
assert_eq!(content_type, "font/woff2");
referenced += 1;
}
assert_eq!(referenced, 16, "expected the 16 bundled Latin-core faces");
}
#[test]
fn non_latin_locales_keep_a_real_fallback_face() {
let css = stylesheet();
for (selector, os_face) in [
(":root:lang(ja)", "'Yu Gothic'"),
(":root:lang(ko)", "'Malgun Gothic'"),
(":root:lang(zh-CN)", "'Microsoft YaHei'"),
(":root:lang(zh-TW)", "'Microsoft JhengHei'"),
(":root:lang(ar)", "'Geeza Pro'"),
(":root:lang(he)", "'Arial Hebrew'"),
(":root:lang(hi)", "'Nirmala UI'"),
(":root:lang(th)", "'Leelawadee UI'"),
] {
assert!(css.contains(selector), "missing fallback chain {selector}");
assert!(css.contains(os_face), "missing OS fallback face {os_face}");
}
}
#[test]
fn ofl_attribution_ships_with_the_binaries() {
let notice = ASSETS
.iter()
.find(|(name, _, _)| name.starts_with("NOTICE.") && name.ends_with(".md"))
.expect("the OFL modification disclosure must ship with the modified fonts");
assert!(!notice.2.is_empty());
assert!(
ASSETS
.iter()
.any(|(name, _, _)| name.starts_with("LICENSE-IBM-PLEX."))
);
}
#[test]
fn unknown_files_are_not_served() {
assert!(asset("fonts.css").is_none());
assert!(asset("../../../etc/passwd").is_none());
}
}
-40
View File
@@ -1,40 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::MarketingConfig;
use axum::http::HeaderMap;
use fluxer_common::geoip::{GeoipConfig, GeoipResolver};
pub fn resolver_from_marketing_config(config: &MarketingConfig) -> GeoipResolver {
GeoipResolver::from_config(&GeoipConfig {
geoip_source: config.geoip_source.clone(),
geoip_s3_config: config.geoip_s3_config.clone(),
trust_client_ip_header: config.trust_client_ip_header,
client_ip_header_name: config.client_ip_header_name.clone(),
})
}
pub fn country_code(resolver: &GeoipResolver, headers: &HeaderMap) -> String {
resolver.country_code(headers)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn no_reader_returns_default() {
let resolver = GeoipResolver::from_config(&GeoipConfig {
geoip_source: fluxer_common::config::GeoipSourceConfig::Filesystem {
maxmind_db_path: None,
},
geoip_s3_config: None,
trust_client_ip_header: true,
client_ip_header_name: "x-forwarded-for".to_owned(),
});
let mut headers = HeaderMap::new();
headers.insert("cf-ipcountry", HeaderValue::from_static("SE"));
headers.insert("x-vercel-ip-country", HeaderValue::from_static("SE"));
assert_eq!(resolver.country_code(&headers), "US");
}
}
-40
View File
@@ -1,40 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod app;
pub mod blog;
pub mod company;
pub mod content;
pub mod donations;
pub mod download;
pub mod footer;
pub mod languages;
pub mod launch;
pub mod navigation;
pub mod partners;
pub mod platform;
pub mod press;
pub mod pricing;
pub mod product;
pub mod security;
pub mod shared;
pub mod social;
pub mod voice_regions;
pub use app::*;
pub use blog::*;
pub use company::*;
pub use content::*;
pub use donations::*;
pub use download::*;
pub use footer::*;
pub use languages::*;
pub use launch::*;
pub use navigation::*;
pub use partners::*;
pub use platform::*;
pub use press::*;
pub use pricing::*;
pub use product::*;
pub use security::*;
pub use shared::*;
pub use social::*;
pub use voice_regions::*;
@@ -1,433 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const APP_COMMUNITIES_COMMUNITY_SUPPORT_DESCRIPTOR = {
key: "app.communities.community_support",
message: "Community support",
comment: "Compact UI label used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_FEATURED_IN_DISCOVERY_DESCRIPTOR = {
key: "app.communities.featured_in_discovery",
message: "Featured in discovery",
comment: "Compact UI label used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_MODERATION_ACTIONS_AND_TOOLS_DESCRIPTOR = {
key: "app.communities.moderation.actions_and_tools",
message: "Moderation actions and tools",
comment: "Button or link label used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_MODERATION_TOOLS_DESCRIPTOR = {
key: "app.communities.moderation.tools",
message: "Moderation tools",
comment: "Compact UI label used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_ROLES_PERMISSIONS_AUDIT_AUDIT_LOGS_DESCRIPTOR = {
key: "app.communities.roles_permissions_audit.audit_logs",
message: "Audit logs for transparency",
comment: "Compact UI label used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_ROLES_PERMISSIONS_AUDIT_GRANULAR_ROLES_AND_PERMISSIONS_DESCRIPTOR = {
key: "app.communities.roles_permissions_audit.granular_roles_and_permissions",
message: "Granular roles and permissions",
comment: "Compact UI label used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_ROLES_PERMISSIONS_AUDIT_KEEP_RUNNING_SMOOTHLY_DESCRIPTOR = {
key: "app.communities.roles_permissions_audit.keep_running_smoothly",
message: "Keep your community running smoothly with roles, permissions, and logs.",
comment: "Body copy used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_TITLE_DESCRIPTOR = {
key: "app.communities.title",
message: "Communities",
comment: "Short UI label or heading used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_VERIFICATION_LABEL_DESCRIPTOR = {
key: "app.communities.verification.label",
message: "Community verification",
comment: "Short UI label or heading used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_VERIFICATION_VALUE_STATEMENT_DESCRIPTOR = {
key: "app.communities.verification.value_statement",
message: "Your community gets verified status for authenticity and trust.",
comment: "Body copy used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_COMMUNITIES_VERIFICATION_VERIFIED_COMMUNITY_DESCRIPTOR = {
key: "app.communities.verification.verified_community",
message: "Verified community",
comment: "Compact UI label used in the home-page communities/moderation feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_ANIMATED_PROFILE_ANIMATED_AVATARS_AND_BANNERS_DESCRIPTOR = {
key: "app.customization.animated_profile.animated_avatars_and_banners",
message: "Animated avatars and banners",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_ANIMATED_PROFILE_STAND_OUT_ANIMATED_PROFILE_DESCRIPTOR = {
key: "app.customization.animated_profile.stand_out_animated_profile",
message: "Stand out with animated profile pictures and banners to express your personality.",
comment: "Body copy used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_APP_ICON_BADGES_DESCRIPTOR = {
key: "app.customization.app_icon_badges",
message: "See badge counts on the app icon.",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_COMPACT_MODE_DESCRIPTOR = {
key: "app.customization.compact_mode",
message: "Compact mode and display options",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_CUSTOM_CSS_THEMES_DESCRIPTOR = {
key: "app.customization.custom_css_themes",
message: "Custom CSS themes",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_CUSTOM_SOUNDS_ENTRANCE_SOUNDS_DESCRIPTOR = {
key: "app.customization.custom_sounds.entrance_sounds",
message: "Custom entrance sounds",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_CUSTOM_SOUNDS_SET_PERSONALIZED_JOIN_SOUNDS_DESCRIPTOR = {
key: "app.customization.custom_sounds.set_personalized_join_sounds",
message: "Set personalized sounds when you join voice channels to make your presence known.",
comment: "Body copy in the home-page customization feature card. Explains custom join sounds for voice channels; keep the wording concrete and avoid making it sound like an audio setting label.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_CUSTOM_THEMES_DESCRIPTOR = {
key: "app.customization.custom_themes",
message: "Custom themes",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_GLOBAL_EMOJI_AND_STICKER_ACCESS_DESCRIPTOR = {
key: "app.customization.global_emoji_and_sticker_access",
message: "Global emoji and sticker access",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_SAVED_MEDIA_AND_CSS_DESCRIPTOR = {
key: "app.customization.saved_media_and_css",
message: "Add custom emojis, save media for later, and style the app with custom CSS.",
comment: "Body copy used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_TITLE_DESCRIPTOR = {
key: "app.customization.title",
message: "Customization",
comment: "Short UI label or heading used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_UPLOAD_CUSTOM_EMOJIS_AND_STICKERS_DESCRIPTOR = {
key: "app.customization.upload_custom_emojis_and_stickers",
message: "Upload custom emojis and stickers",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_CUSTOMIZATION_USE_ANIMATED_EMOJIS_DESCRIPTOR = {
key: "app.customization.use_animated_emojis",
message: "Use animated emojis",
comment: "Compact UI label used in the home-page customization feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_DESCRIPTION_DESCRIPTOR = {
key: "app.messaging.description",
message: "DM your friends, chat with groups, or build communities with channels.",
comment: "Body copy used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_BOOKMARKED_MESSAGES_DESCRIPTOR = {
key: "app.messaging.features.bookmarked_messages",
message: "Bookmarked messages",
comment: "Compact UI label used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_FILE_SHARING_DESCRIPTOR = {
key: "app.messaging.features.file_sharing",
message: "Share files and preview links",
comment: "Compact UI label used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_FULL_MARKDOWN_SUPPORT_DESCRIPTOR = {
key: "app.messaging.features.full_markdown_support",
message: "Full Markdown support in messages",
comment: "Compact UI label used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_MESSAGE_SCHEDULING_DESCRIPTION_DESCRIPTOR = {
key: "app.messaging.features.message_scheduling.description",
message: "Schedule messages to be sent at a specific time in the future. No {delorean} required.",
comment: "Body copy used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_ORGANISED_CHANNELS_FOR_COMMUNITIES_DESCRIPTOR = {
key: "app.messaging.features.organised_channels_for_communities",
message: "Organized channels for communities",
comment: "Compact UI label used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_PRIVATE_DMS_AND_GROUP_CHATS_DESCRIPTOR = {
key: "app.messaging.features.private_dms_and_group_chats",
message: "Private DMs and group chats",
comment: "Compact UI label used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_SAVE_MEDIA_DESCRIPTOR = {
key: "app.messaging.features.save_media",
message: "Save images, videos, GIFs, and audio",
comment: "Body copy used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_SEARCH_FILTER_OPTIONS_DESCRIPTOR = {
key: "app.messaging.features.search.filter_options",
message: "Filter by users, dates, and more",
comment: "Compact UI label used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_SEARCH_FIND_OLD_MESSAGES_DESCRIPTOR = {
key: "app.messaging.features.search.find_old_messages",
message: "Find old messages or jump between communities and channels in seconds.",
comment: "Body copy used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_SEARCH_LABEL_DESCRIPTOR = {
key: "app.messaging.features.search.label",
message: "Search message history",
comment: "Short UI label or heading used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_SEARCH_QUICK_SWITCHER_SHORTCUTS_DESCRIPTOR = {
key: "app.messaging.features.search.quick_switcher_shortcuts",
message: "Quick switcher with keyboard shortcuts",
comment: "Body copy used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_FEATURES_SEARCH_SEARCH_AND_QUICK_SWITCHER_DESCRIPTOR = {
key: "app.messaging.features.search.search_and_quick_switcher",
message: "Search and quick switcher",
comment: "Compact UI label used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_MESSAGING_TITLE_DESCRIPTOR = {
key: "app.messaging.title",
message: "Messaging",
comment: "Short UI label or heading used in the home-page messaging feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_OPEN_OPEN_FLUXER_DESCRIPTOR = {
key: "app.open.open_fluxer",
message: "Open {product_name}",
comment: "Button or link label used in the main navigation/open-app call to action. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_OPEN_OPEN_WEB_APP_DESCRIPTOR = {
key: "app.open.open_web_app",
message: "Open the web app",
comment: "Button or link label used in the main navigation/open-app call to action. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_PROFILES_IDENTITY_CUSTOM_IDENTITY_DESCRIPTOR = {
key: "app.profiles_identity.custom_identity",
message: "Custom identity",
comment: "Compact UI label used in profile and identity feature copy. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_PROFILES_IDENTITY_CUSTOMISE_PER_COMMUNITY_DESCRIPTOR = {
key: "app.profiles_identity.customise_per_community",
message: "Customize your profile differently for each community you're part of.",
comment: "Body copy used in profile and identity feature copy. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_PROFILES_IDENTITY_MANAGE_FRIENDS_AND_BLOCK_USERS_DESCRIPTOR = {
key: "app.profiles_identity.manage_friends_and_block_users",
message: "Manage friends and block users",
comment: "Compact UI label used in profile and identity feature copy. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_MUTE_CONTROLS_DESCRIPTOR = {
key: "app.voice_and_video.features.mute_controls",
message: "Mute, deafen, and camera controls",
comment: "Compact UI label used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_NOISE_SUPPRESSION_DESCRIPTOR = {
key: "app.voice_and_video.features.noise_suppression",
message: "Noise suppression and echo cancellation",
comment: "Body copy used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_SCREEN_SHARING_DESCRIPTOR = {
key: "app.voice_and_video.features.screen_sharing",
message: "Built-in screen sharing",
comment: "Compact UI label used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_STREAM_4K_60FPS_DESCRIPTOR = {
key: "app.voice_and_video.features.stream_4k_60fps",
message: "Stream up to 4K resolution at 60 fps so others can see you in stunning clarity.",
comment: "Body copy used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_UP_TO_4K_VIDEO_QUALITY_DESCRIPTOR = {
key: "app.voice_and_video.features.up_to_4k_video_quality",
message: "Up to 4K video quality",
comment: "Compact UI label used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_VIDEO_BACKGROUNDS_DESCRIPTION_DESCRIPTOR = {
key: "app.voice_and_video.features.video_backgrounds.description",
message: "Store up to 15 video backgrounds for calls. No more Downloads folder scavenger hunts.",
comment: "Body copy used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_VIDEO_QUALITY_DESCRIPTOR = {
key: "app.voice_and_video.features.video_quality",
message: "Video quality",
comment: "Compact UI label used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_VIDEO_QUALITY_FREE_DESCRIPTOR = {
key: "app.voice_and_video.features.video_quality_free",
message: "720p/30fps",
comment: "Compact UI label used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_FEATURES_VIDEO_QUALITY_PREMIUM_DESCRIPTOR = {
key: "app.voice_and_video.features.video_quality_premium",
message: "Up to 4K/60fps",
comment: "Compact UI label used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_HOP_IN_A_CALL_DESCRIPTOR = {
key: "app.voice_and_video.hop_in_a_call",
message: "Hop in a call with friends or share your screen to work together.",
comment: "Body copy used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const APP_VOICE_AND_VIDEO_TITLE_DESCRIPTOR = {
key: "app.voice_and_video.title",
message: "Voice and video",
comment: "Short UI label or heading used in the home-page voice and video feature card. Keep it concise, natural, and consistent with product terminology; preserve placeholders exactly.",
};
);
@@ -1,201 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const BLOG_ALL_POSTS_DESCRIPTOR = {
key: "blog.all_posts",
message: "All posts",
comment: "Short filter label on the blog index. It clears tag filters and shows every blog post.",
};
);
crate::marketing_message!(
pub const BLOG_ATOM_FEED_DESCRIPTOR = {
key: "blog.atom_feed",
message: "Atom feed",
comment: "Compact link label for the Atom feed on the blog index. Keep the feed protocol name in conventional form.",
};
);
crate::marketing_message!(
pub const BLOG_BACK_TO_BLOG_DESCRIPTOR = {
key: "blog.back_to_blog",
message: "Back to blog",
comment: "Back-link label on a blog article page. It returns readers to the blog index.",
};
);
crate::marketing_message!(
pub const BLOG_DESCRIPTION_DESCRIPTOR = {
key: "blog.description",
message: "Updates, roadmap notes, and engineering write-ups from the {product_name} team.",
comment: "Blog index meta description and intro copy. Keep it concise and editorial, not sales-oriented. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BLOG_FEEDS_DESCRIPTOR = {
key: "blog.feeds",
message: "Feeds",
comment: "Short heading for RSS and Atom links on the blog index. Keep it compact.",
};
);
crate::marketing_message!(
pub const BLOG_LINKED_ARTICLE_DESCRIPTOR = {
key: "blog.linked_article",
message: "Linked article",
comment: "Fallback source label inside a blog bookmark card when the linked page has no readable source or publisher. Keep it short and neutral.",
};
);
crate::marketing_message!(
pub const BLOG_FILTERED_BY_TAG_DESCRIPTOR = {
key: "blog.filtered_by_tag",
message: "Filtered by {tag}",
comment: "Blog index status text shown when a tag filter is active. Preserve {tag}; it is the visible tag name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BLOG_POST_HOW_I_BUILT_FLUXER_TITLE_DESCRIPTOR = {
key: "blog.post.how_i_built_fluxer.title",
message: "How I built {product_name}, a {discord}-like chat app",
comment: "Blog article title shown in cards, article pages, metadata, and feeds. Keep Fluxer and Discord recognizable as product names. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BLOG_POST_MOBILE_CLIENTS_AND_FLUXER_V2_DESCRIPTION_DESCRIPTOR = {
key: "blog.post.mobile_clients_and_fluxer_v2.description",
message: "{product_name} v2 is out, mobile clients are open source, self-hosting is improving, and public development is moving back to GitHub.",
comment: "Blog article summary shown in cards, article pages, metadata, and feeds. Keep Fluxer as a product name, v2 as the release name, and GitHub as the product name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BLOG_POST_MOBILE_CLIENTS_AND_FLUXER_V2_TITLE_DESCRIPTOR = {
key: "blog.post.mobile_clients_and_fluxer_v2.title",
message: "Mobile clients and {product_name} v2",
comment: "Blog article title shown in cards, article pages, metadata, and feeds. Keep Fluxer as a product name and v2 as the release name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BLOG_POST_ROADMAP_2026_DESCRIPTION_DESCRIPTOR = {
key: "blog.post.roadmap_2026.description",
message: "The current 2026 roadmap for {product_name}: canary, mobile, self-hosting, federation, voice and video, and the backend reliability work behind it.",
comment: "Blog article summary shown in cards, article pages, metadata, and feeds. Keep Fluxer as a product name and canary as the release-channel name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BLOG_POST_ROADMAP_2026_TITLE_DESCRIPTOR = {
key: "blog.post.roadmap_2026.title",
message: "Roadmap 2026",
comment: "Blog article title shown in cards, article pages, metadata, and feeds. Refers to Fluxer's 2026 product roadmap.",
};
);
crate::marketing_message!(
pub const BLOG_NO_RESULTS_DESCRIPTION_DESCRIPTOR = {
key: "blog.no_results.description",
message: "Try another search term or clear the current filters.",
comment: "Empty-state body on the blog index when search and tag filters find no posts.",
};
);
crate::marketing_message!(
pub const BLOG_NO_RESULTS_TITLE_DESCRIPTOR = {
key: "blog.no_results.title",
message: "No posts found",
comment: "Empty-state heading on the blog index when search and tag filters find no posts.",
};
);
crate::marketing_message!(
pub const BLOG_PUBLISHED_DESCRIPTOR = {
key: "blog.published",
message: "Published",
comment: "Short metadata label on a blog article page. It precedes the article publication date.",
};
);
crate::marketing_message!(
pub const BLOG_READ_ARTICLE_DESCRIPTOR = {
key: "blog.read_article",
message: "Read article",
comment: "Call-to-action label on blog post cards. Keep it concise and neutral.",
};
);
crate::marketing_message!(
pub const BLOG_RELATED_POSTS_DESCRIPTOR = {
key: "blog.related_posts",
message: "Related posts",
comment: "Section heading under a blog article showing other posts readers may open next.",
};
);
crate::marketing_message!(
pub const BLOG_RSS_FEED_DESCRIPTOR = {
key: "blog.rss_feed",
message: "RSS feed",
comment: "Compact link label for the RSS feed on the blog index. Keep the feed protocol name in conventional form.",
};
);
crate::marketing_message!(
pub const BLOG_SEARCH_BUTTON_DESCRIPTOR = {
key: "blog.search.button",
message: "Search",
comment: "Button label for the blog search form. Keep it short and action-oriented.",
};
);
crate::marketing_message!(
pub const BLOG_SEARCH_PLACEHOLDER_DESCRIPTOR = {
key: "blog.search.placeholder",
message: "Search blog posts…",
comment: "Placeholder text in the blog search input. Keep it concise and clearly scoped to blog posts.",
};
);
crate::marketing_message!(
pub const BLOG_SEARCH_RESULTS_DESCRIPTOR = {
key: "blog.search.results",
message: "Search results",
comment: "Section heading on the blog index when a search query or tag filter is active.",
};
);
crate::marketing_message!(
pub const BLOG_TAG_NEWS_DESCRIPTOR = {
key: "blog.tag.news",
message: "News",
comment: "Blog tag label for product updates and announcements. Keep it short because it appears in filter chips and metadata.",
};
);
crate::marketing_message!(
pub const BLOG_TAGS_DESCRIPTOR = {
key: "blog.tags",
message: "Tags",
comment: "Short label for the list of blog tags. Keep it compact.",
};
);
crate::marketing_message!(
pub const BLOG_TITLE_DESCRIPTOR = {
key: "blog.title",
message: "{product_name} Blog",
comment: "Blog index title and meta title. Keep the product name and blog label recognizable. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BLOG_UPDATED_DESCRIPTOR = {
key: "blog.updated",
message: "Updated",
comment: "Short metadata label on a blog article page. It precedes the article update date.",
};
);
@@ -1,305 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_BLOG_DESCRIPTOR = {
key: "company_and_resources.blog",
message: "Blog",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_ALL_OPEN_POSITIONS_DESCRIPTOR = {
key: "company_and_resources.careers.all_open_positions",
message: "Future roles",
comment: "Back-link label from an individual job page to the careers overview. Keep it short and do not imply active hiring.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_CAREERS_AT_FLUXER_DESCRIPTOR = {
key: "company_and_resources.careers.careers_at_fluxer",
message: "Careers at {product_name}",
comment: "Compact UI label on the careers and future roles pages. Use clear hiring language, avoid implying that roles are currently open unless the source says so, and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_HERO_DESCRIPTION_DESCRIPTOR = {
key: "company_and_resources.careers.hero_description",
message: "We are not hiring right now. These pages are notes on roles we may open later.",
comment: "Careers hero description. Keep it plain and informational; do not turn it into an application prompt or imply active hiring.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_LABEL_DESCRIPTOR = {
key: "company_and_resources.careers.label",
message: "Careers",
comment: "Short UI label or heading on the careers and future roles pages. Use clear hiring language, avoid implying that roles are currently open unless the source says so, and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_NOT_CURRENTLY_OPEN_NOTICE_DESCRIPTOR = {
key: "company_and_resources.careers.not_currently_open_notice",
message: "These are reference pages, not active job openings.",
comment: "Small notice shown above future-role listings and on job pages. Be explicit that the roles are not open; avoid application or recruiting language.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_OPEN_POSITIONS_DESCRIPTOR = {
key: "company_and_resources.careers.open_positions",
message: "Future roles",
comment: "Button or link label on the careers and future roles pages. Use clear hiring language, avoid implying that roles are currently open unless the source says so, and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_OPEN_POSITIONS_DESCRIPTION_DESCRIPTOR = {
key: "company_and_resources.careers.open_positions_description",
message: "A short list of work we may hire for later.",
comment: "Body copy under the careers page role-list heading. Keep it concise and informational; do not invite applications.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CAREERS_POSTED_DESCRIPTOR = {
key: "company_and_resources.careers.posted",
message: "Listed",
comment: "Short UI label or heading on the careers and future roles pages. Use clear hiring language, avoid implying that roles are currently open unless the source says so, and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_COMPANY_DESCRIPTOR = {
key: "company_and_resources.company",
message: "Company",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_COMPANY_INFO_DESCRIPTOR = {
key: "company_and_resources.company_info",
message: "Company info",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_CONNECT_DESCRIPTOR = {
key: "company_and_resources.connect",
message: "Connect",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_DOCS_DESCRIPTOR = {
key: "company_and_resources.docs",
message: "Docs",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_GET_HELP_DESCRIPTOR = {
key: "company_and_resources.help.get_help",
message: "Get help",
comment: "Button or link label for help-center links or descriptions in marketing navigation and resource sections. Keep it service-oriented and concise.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_HELP_CENTER_DESCRIPTOR = {
key: "company_and_resources.help.help_center",
message: "Help center",
comment: "Compact UI label for help-center links or descriptions in marketing navigation and resource sections. Keep it service-oriented and concise.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_HELP_CENTER_DESCRIPTION_DESCRIPTOR = {
key: "company_and_resources.help.help_center_description",
message: "Find answers to common questions about your account, privacy, and using {product_name}.",
comment: "Body copy for help-center links or descriptions in marketing navigation and resource sections. Preserve {product_name} exactly; keep it service-oriented and concise. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_LABEL_DESCRIPTOR = {
key: "company_and_resources.help.label",
message: "Help",
comment: "Short UI label or heading for help-center links or descriptions in marketing navigation and resource sections. Keep it service-oriented and concise.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_SEARCH_PLACEHOLDER_DESCRIPTOR = {
key: "company_and_resources.help.search_placeholder",
message: "Search help articles…",
comment: "Placeholder text for help-center links or descriptions in marketing navigation and resource sections. Keep it service-oriented and concise.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_ALL_ARTICLES_DESCRIPTOR = {
key: "company_and_resources.help.all_articles",
message: "All help articles",
comment: "Section heading on the help center home page. It introduces the complete list of imported support articles.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_ARTICLES_DESCRIPTOR = {
key: "company_and_resources.help.articles",
message: "Articles",
comment: "Short label on the help center for a list of support articles. Keep it concise.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_BACK_TO_HELP_CENTER_DESCRIPTOR = {
key: "company_and_resources.help.back_to_help_center",
message: "Back to help center",
comment: "Link label shown above help articles. It returns users to the help center home page.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_BROWSE_BY_TOPIC_DESCRIPTOR = {
key: "company_and_resources.help.browse_by_topic",
message: "Browse by topic",
comment: "Section heading on the help center home page. It groups support articles by topic.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_NO_SEARCH_RESULTS_DESCRIPTION_DESCRIPTOR = {
key: "company_and_resources.help.no_search_results_description",
message: "Try another search, or browse the topics below.",
comment: "Short empty-state description shown when a help-center search returns no articles.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_NO_SEARCH_RESULTS_TITLE_DESCRIPTOR = {
key: "company_and_resources.help.no_search_results_title",
message: "No matching articles",
comment: "Short empty-state heading shown when a help-center search returns no articles.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_RELATED_ARTICLES_DESCRIPTOR = {
key: "company_and_resources.help.related_articles",
message: "Related articles",
comment: "Section heading below help articles. It introduces links to nearby support articles.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_SEARCH_DESCRIPTOR = {
key: "company_and_resources.help.search",
message: "Search help articles",
comment: "Accessible label for the help-center search form.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_SEARCH_BUTTON_DESCRIPTOR = {
key: "company_and_resources.help.search_button",
message: "Search",
comment: "Submit button label for the help-center search form. Keep it short.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_HELP_SEARCH_RESULTS_DESCRIPTOR = {
key: "company_and_resources.help.search_results",
message: "Search results",
comment: "Section heading shown above help-center article results after a user searches.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_PRESS_LABEL_DESCRIPTOR = {
key: "company_and_resources.press.label",
message: "Press",
comment: "Short UI label or heading for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_PRESS_PRESS_CONTACT_DESCRIPTOR = {
key: "company_and_resources.press.press_contact",
message: "Press contact",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_PRODUCT_DESCRIPTOR = {
key: "company_and_resources.product",
message: "Product",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_RESOURCES_DESCRIPTOR = {
key: "company_and_resources.resources",
message: "Resources",
comment: "Compact UI label for company/resource navigation or footer groupings. Keep labels short and recognizable for a marketing website.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_SOURCE_AND_CONTRIBUTION_CODE_ISSUES_DOCS_REVIEWS_DESCRIPTOR = {
key: "company_and_resources.source_and_contribution.code_issues_docs_reviews",
message: "Code, issues, docs, and reviews",
comment: "Compact UI label in the open-source contribution/get-involved section. Keep the tone welcoming and concrete, and preserve email placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_SOURCE_AND_CONTRIBUTION_CONTRIBUTE_ON_GITHUB_DESCRIPTOR = {
key: "company_and_resources.source_and_contribution.contribute_on_github",
message: "Contribute on {github}",
comment: "Compact UI label in the open-source contribution/get-involved section. Keep the tone welcoming and concrete, and preserve email placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_SOURCE_AND_CONTRIBUTION_FLUXER_BUILT_IN_OPEN_DESCRIPTOR = {
key: "company_and_resources.source_and_contribution.fluxer_built_in_open",
message: "{product_name} is built in the open. Pick the path that fits how you like to help.",
comment: "Body copy in the open-source contribution/get-involved section. Keep the tone welcoming and concrete, and preserve email placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_SOURCE_AND_CONTRIBUTION_GET_INVOLVED_DESCRIPTOR = {
key: "company_and_resources.source_and_contribution.get_involved",
message: "Get involved",
comment: "Button or link label in the open-source contribution/get-involved section. Keep the tone welcoming and concrete, and preserve email placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_SOURCE_AND_CONTRIBUTION_SOURCE_CODE_DESCRIPTOR = {
key: "company_and_resources.source_and_contribution.source_code",
message: "Source code",
comment: "Compact UI label in the open-source contribution/get-involved section. Keep the tone welcoming and concrete, and preserve email placeholders exactly.",
};
);
crate::marketing_message!(
pub const COMPANY_AND_RESOURCES_SOURCE_AND_CONTRIBUTION_TRANSLATION_LLM_TRANSLATION_NOTE_DESCRIPTOR = {
key: "company_and_resources.source_and_contribution.translation.llm_translation_note",
message: "All translations are currently LLM-generated with minimal human revision. We'd love to get real people to help us localize {product_name} into your language. To do so, email {l10n_email} and we'll be happy to accept your contributions.",
comment: "Body copy in the open-source contribution/get-involved section. Keep the tone welcoming and concrete, and preserve email placeholders exactly.",
};
);
@@ -1,553 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const CONTENT_LABEL_COMMUNITY_DESCRIPTOR = {
key: "content.labels.community",
message: "Community",
comment: "Department or policy category label on content pages. Translate as a compact noun phrase, not as a sentence.",
};
);
crate::marketing_message!(
pub const CONTENT_LABEL_ENGINEERING_DESCRIPTOR = {
key: "content.labels.engineering",
message: "Engineering",
comment: "Department label for job listings. Translate as the software/product engineering function.",
};
);
crate::marketing_message!(
pub const CONTENT_LABEL_FULL_TIME_DESCRIPTOR = {
key: "content.labels.full_time",
message: "Full-time",
comment: "Employment type label shown on careers pages. Keep it short and conventional for job listings.",
};
);
crate::marketing_message!(
pub const CONTENT_LABEL_LEGAL_DESCRIPTOR = {
key: "content.labels.legal",
message: "Legal",
comment: "Department or policy category label on content pages. Translate as legal/compliance, not legality in general.",
};
);
crate::marketing_message!(
pub const CONTENT_LABEL_REMOTE_DESCRIPTOR = {
key: "content.labels.remote",
message: "Remote",
comment: "Workplace location label for job listings. Means the role can be performed remotely.",
};
);
crate::marketing_message!(
pub const CONTENT_LABEL_SECURITY_DESCRIPTOR = {
key: "content.labels.security",
message: "Security",
comment: "Policy category label for security and bug-bounty content. Translate as information security.",
};
);
crate::marketing_message!(
pub const CONTENT_LABEL_SUPPORT_DESCRIPTOR = {
key: "content.labels.support",
message: "Support",
comment: "Department label for customer support job listings. Translate as user/customer support.",
};
);
crate::marketing_message!(
pub const CONTENT_LABEL_TRUST_AND_SAFETY_DESCRIPTOR = {
key: "content.labels.trust_and_safety",
message: "Trust & Safety",
comment: "Department label for moderation/safety jobs. Keep the ampersand only if natural in the locale.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_COMMUNITY_LEAD_DESCRIPTION_DESCRIPTOR = {
key: "content.jobs.community_lead.description",
message: "Look after our public voice and the communities around {product_name}. Talk with people, share what's new, and bring what you hear back to the team.",
comment: "Short job-card summary for the Community Lead role. Keep it warm, concrete, and concise. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_COMMUNITY_LEAD_TITLE_DESCRIPTOR = {
key: "content.jobs.community_lead.title",
message: "Community Lead",
comment: "Job title shown on careers pages. Translate as a professional role title.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_PLATFORM_ENGINEER_DESCRIPTION_DESCRIPTOR = {
key: "content.jobs.platform_engineer.description",
message: "Look after the systems {product_name} runs on: pipelines, databases, dashboards, on-call, and the quiet machinery that keeps everything humming.",
comment: "Short job-card summary for the Platform Engineer role. Keep infrastructure terminology precise. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_PLATFORM_ENGINEER_TITLE_DESCRIPTOR = {
key: "content.jobs.platform_engineer.title",
message: "Platform Engineer",
comment: "Job title shown on careers pages. Translate as a professional infrastructure/platform engineering role title.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_PRIVACY_AND_LEGAL_COUNSEL_DESCRIPTION_DESCRIPTOR = {
key: "content.jobs.privacy_and_legal_counsel.description",
message: "Handle {product_name}'s legal and privacy side: GDPR, DSA, DMCA, law-enforcement requests, and the policies that go with them.",
comment: "Short job-card summary for the Privacy & Legal Counsel role. Keep legal acronyms unchanged. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_PRIVACY_AND_LEGAL_COUNSEL_TITLE_DESCRIPTOR = {
key: "content.jobs.privacy_and_legal_counsel.title",
message: "Privacy & Legal Counsel",
comment: "Job title shown on careers pages. Translate as a professional privacy/legal counsel role title.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_PRODUCT_ENGINEER_DESCRIPTION_DESCRIPTOR = {
key: "content.jobs.product_engineer.description",
message: "Build the bits people open {product_name} to use, top to bottom: web, mobile, desktop, and the services behind them.",
comment: "Short job-card summary for the Product Engineer role. Keep the approachable product-builder tone. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_PRODUCT_ENGINEER_TITLE_DESCRIPTOR = {
key: "content.jobs.product_engineer.title",
message: "Product Engineer",
comment: "Job title shown on careers pages. Translate as a professional product engineering role title.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_SUPPORT_SPECIALIST_DESCRIPTION_DESCRIPTOR = {
key: "content.jobs.support_specialist.description",
message: "Be the friendly first reply when someone hits a snag. Help users sort things out and bring the rough edges back to the team to fix.",
comment: "Short job-card summary for the Support Specialist role. Keep it friendly but professional.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_SUPPORT_SPECIALIST_TITLE_DESCRIPTOR = {
key: "content.jobs.support_specialist.title",
message: "Support Specialist",
comment: "Job title shown on careers pages. Translate as a professional customer/user support role title.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_TRUST_AND_SAFETY_SPECIALIST_DESCRIPTION_DESCRIPTOR = {
key: "content.jobs.trust_and_safety_specialist.description",
message: "Work through abuse reports, make fair calls, and help shape the policies and tooling that keep {product_name} somewhere people want to be.",
comment: "Short job-card summary for the Trust & Safety Specialist role. Keep moderation/safety wording fair and calm. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_JOBS_TRUST_AND_SAFETY_SPECIALIST_TITLE_DESCRIPTOR = {
key: "content.jobs.trust_and_safety_specialist.title",
message: "Trust & Safety Specialist",
comment: "Job title shown on careers pages. Translate as a professional moderation/safety operations role title.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_CHANGELOG_DESCRIPTION_DESCRIPTOR = {
key: "content.policies.changelog.description",
message: "A record of significant changes to our Terms of Service, Privacy Policy, and Community Guidelines.",
comment: "Policy page summary for the changelog. Keep policy names recognizable and legally precise.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_CHANGELOG_TITLE_DESCRIPTOR = {
key: "content.policies.changelog.title",
message: "Policy Changelog",
comment: "Policy page title. Translate as a record of changes to legal/community policies.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_COMPANY_INFORMATION_DESCRIPTION_DESCRIPTOR = {
key: "content.policies.company_information.description",
message: "Legal details for {product_name} Platform AB, including how we make money and how to contact us.",
comment: "Policy page summary for company information. Keep company name and legal framing precise. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_COMPANY_INFORMATION_TITLE_DESCRIPTOR = {
key: "content.policies.company_information.title",
message: "Company Information",
comment: "Policy page title for legal company details. Translate as formal company information.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_GUIDELINES_DESCRIPTION_DESCRIPTOR = {
key: "content.policies.guidelines.description",
message: "Clear standards for using {product_name}, protecting others, and keeping communities safe.",
comment: "Policy page summary for community guidelines. Keep safety wording direct and neutral. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_GUIDELINES_TITLE_DESCRIPTOR = {
key: "content.policies.guidelines.title",
message: "Community Guidelines",
comment: "Policy page title for community rules. Translate as formal community guidelines.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_PRIVACY_DESCRIPTION_DESCRIPTOR = {
key: "content.policies.privacy.description",
message: "How {product_name} handles personal data, what we do not do with it, and the controls and rights you have.",
comment: "Policy page summary for the Privacy Policy. Keep data-protection terms legally precise. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_PRIVACY_TITLE_DESCRIPTOR = {
key: "content.policies.privacy.title",
message: "Privacy Policy",
comment: "Policy page title. Translate as a formal privacy policy.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_SECURITY_DESCRIPTION_DESCRIPTOR = {
key: "content.policies.security.description",
message: "How to report security issues, what is in scope, and how safe harbor works.",
comment: "Policy page summary for security reporting and bug bounty rules. Keep security and safe-harbor terminology precise.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_SECURITY_TITLE_DESCRIPTOR = {
key: "content.policies.security.title",
message: "Security Bug Bounty",
comment: "Policy page title for security vulnerability reporting. Translate as a bug bounty/security reporting page.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_TERMS_DESCRIPTION_DESCRIPTOR = {
key: "content.policies.terms.description",
message: "The agreement between you and {product_name}, written to explain your rights and responsibilities clearly.",
comment: "Policy page summary for Terms of Service. Keep the legal agreement framing clear and neutral. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_POLICIES_TERMS_TITLE_DESCRIPTOR = {
key: "content.policies.terms.title",
message: "Terms of Service",
comment: "Policy page title. Translate as formal terms of service.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_PREMIUM_TITLE_DESCRIPTOR = {
key: "content.help.category.premium.title",
message: "{premium_tier_full_name}",
comment: "Help center category title for premium/Plutonium-related articles. Keep as a short noun phrase. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_PREMIUM_DESCRIPTION_DESCRIPTOR = {
key: "content.help.category.premium.description",
message: "Information about our {premium_tier_full_name} offering.",
comment: "Help center category summary shown under the Premium heading. Translate as a short descriptive sentence. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_FAQS_TITLE_DESCRIPTOR = {
key: "content.help.category.faqs.title",
message: "FAQs",
comment: "Help center category title for frequently asked questions. Use the locale's standard abbreviation or short phrase.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_FAQS_DESCRIPTION_DESCRIPTOR = {
key: "content.help.category.faqs.description",
message: "Answers to frequently asked questions.",
comment: "Help center category summary shown under the FAQs heading. Translate as a short descriptive sentence.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_ACCOUNT_TITLE_DESCRIPTOR = {
key: "content.help.category.account.title",
message: "Account",
comment: "Help center category title for account-management articles. Keep as a short noun phrase.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_ACCOUNT_DESCRIPTION_DESCRIPTOR = {
key: "content.help.category.account.description",
message: "Managing personal account details.",
comment: "Help center category summary shown under the Account heading. Translate as a short descriptive sentence.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_LEGAL_POLICY_TITLE_DESCRIPTOR = {
key: "content.help.category.legal_policy.title",
message: "Legal & Policy",
comment: "Help center category title for legal and policy articles. Translate as a compact heading; keep the ampersand only if natural.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_CATEGORY_LEGAL_POLICY_DESCRIPTION_DESCRIPTOR = {
key: "content.help.category.legal_policy.description",
message: "View important policies pertaining to {product_name}.",
comment: "Help center category summary shown under the Legal & Policy heading. Translate as a short descriptive sentence; the brand 'Fluxer' stays untranslated. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_PLUTONIUM_PROMOTION_TITLE_DESCRIPTOR = {
key: "content.help.article.plutonium_promotion_march_2026.title",
message: "March 2026 {premium_tier_name} promotion",
comment: "Help article title for a time-limited Plutonium promotion. Keep month/year and 'Plutonium' as proper names. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_PLUTONIUM_PROMOTION_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.plutonium_promotion_march_2026.description",
message: "A limited-time promotion offering a free 7-day {premium_tier_name} trial for new users and gift codes for existing {premium_tier_name} subscribers.",
comment: "Help article summary describing the Plutonium promotion. Keep 'Plutonium' as a proper name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_VISIONARY_TITLE_DESCRIPTOR = {
key: "content.help.article.visionary.title",
message: "What was {visionary_tier_full_name}?",
comment: "Help article title asking about the retired Fluxer Visionary tier. Keep 'Fluxer Visionary' as a proper name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_VISIONARY_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.visionary.description",
message: "{visionary_tier_full_name} was a limited lifetime {premium_tier_name} offering that sold out in February 2026. Learn about the numbered badge and what {visionary_tier_name} includes.",
comment: "Help article summary about the retired Fluxer Visionary lifetime tier. Keep 'Fluxer Visionary' and 'Plutonium' as proper names. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_COMMUNITY_PROGRAMMES_TITLE_DESCRIPTOR = {
key: "content.help.article.community_programmes.title",
message: "Partner, Verified, and Discovery programmes",
comment: "Help article title listing the three programmes that showcase a community on Fluxer. Keep 'Partner', 'Verified', and 'Discovery' as programme names.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_COMMUNITY_PROGRAMMES_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.community_programmes.description",
message: "The three ways to get your community showcased on {product_name}, what each programme requires, and how to apply.",
comment: "Help article summary about the Partner, Verified, and Discovery programmes. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_ATTACHMENT_EXPIRY_TITLE_DESCRIPTOR = {
key: "content.help.article.attachment_expiry.title",
message: "How attachment expiry works",
comment: "Help article title about how file attachments eventually expire. Keep it as an explanatory phrase.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_ATTACHMENT_EXPIRY_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.attachment_expiry.description",
message: "How we set attachment expiry, how access can extend it, and what to do before a file is removed.",
comment: "Help article summary describing attachment expiry behaviour.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_REPORT_BUG_TITLE_DESCRIPTOR = {
key: "content.help.article.report_bug.title",
message: "Reporting a bug",
comment: "Help article title about filing bug reports.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_REPORT_BUG_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.report_bug.description",
message: "How to file clear, high-quality bug reports for the {product_name} support team or our {github}.",
comment: "Help article summary about filing bug reports. Keep 'GitHub' untranslated. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_CHANGE_DATE_OF_BIRTH_TITLE_DESCRIPTOR = {
key: "content.help.article.change_date_of_birth.title",
message: "How to change your date of birth",
comment: "Help article title about updating a user's date of birth via support.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_CHANGE_DATE_OF_BIRTH_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.change_date_of_birth.description",
message: "How to update your date of birth on {product_name} by contacting our support team.",
comment: "Help article summary about updating date of birth via support. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DATA_DELETION_TITLE_DESCRIPTOR = {
key: "content.help.article.data_deletion.title",
message: "Requesting data deletion",
comment: "Help article title about requesting deletion of user data.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DATA_DELETION_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.data_deletion.description",
message: "How to delete your messages and other data from {product_name}.",
comment: "Help article summary about deleting user data. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DATA_EXPORT_TITLE_DESCRIPTOR = {
key: "content.help.article.data_export.title",
message: "Exporting your account data",
comment: "Help article title about exporting account data.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DATA_EXPORT_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.data_export.description",
message: "How to request and download a complete export of your {product_name} data.",
comment: "Help article summary about exporting Fluxer account data. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DELETE_ACCOUNT_TITLE_DESCRIPTOR = {
key: "content.help.article.delete_account.title",
message: "How to delete or disable your account",
comment: "Help article title about deleting or disabling an account.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DELETE_ACCOUNT_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.delete_account.description",
message: "How to permanently delete or temporarily disable your {product_name} account, and what happens to your data.",
comment: "Help article summary about deleting/disabling an account. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_MINIMUM_AGE_TITLE_DESCRIPTOR = {
key: "content.help.article.minimum_age.title",
message: "Minimum age requirements",
comment: "Help article title about country-specific minimum-age requirements for using the service.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_MINIMUM_AGE_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.minimum_age.description",
message: "The minimum age to use {product_name} by country, how age is determined, and what happens if an account is suspected of being underage.",
comment: "Help article summary about minimum age policies. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_COPYRIGHT_TITLE_DESCRIPTOR = {
key: "content.help.article.copyright.title",
message: "Copyright and intellectual property complaints policy",
comment: "Help article title about the copyright/IP complaint process. Keep legal-sounding phrasing.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_COPYRIGHT_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.copyright.description",
message: "How to report suspected copyright or intellectual property violations on {product_name}.",
comment: "Help article summary about copyright/IP complaint reporting. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DATA_RETENTION_TITLE_DESCRIPTOR = {
key: "content.help.article.data_retention.title",
message: "How long {product_name} keeps your information",
comment: "Help article title about data retention durations. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DATA_RETENTION_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.data_retention.description",
message: "How long {product_name} retains different types of information and why we keep it.",
comment: "Help article summary about retention durations and reasoning. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DSA_DISPUTE_RESOLUTION_TITLE_DESCRIPTOR = {
key: "content.help.article.dsa_dispute_resolution.title",
message: "EU DSA dispute resolution options",
comment: "Help article title about EU Digital Services Act dispute options. Keep 'EU DSA' as an acronym.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_DSA_DISPUTE_RESOLUTION_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.dsa_dispute_resolution.description",
message: "How EU users covered by the Digital Services Act can exercise their rights on {product_name}.",
comment: "Help article summary about EU DSA dispute options. Keep 'Fluxer' as a brand name and 'Digital Services Act' as a legal term. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_REGIONAL_RESTRICTIONS_TITLE_DESCRIPTOR = {
key: "content.help.article.regional_restrictions.title",
message: "Regional restrictions",
comment: "Help article title about region-specific access restrictions.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_ARTICLE_REGIONAL_RESTRICTIONS_DESCRIPTION_DESCRIPTOR = {
key: "content.help.article.regional_restrictions.description",
message: "How regional age verification laws affect your access to {product_name}, which regions are currently affected, and what restrictions apply.",
comment: "Help article summary about regional access restrictions. Keep 'Fluxer' as a brand name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const CONTENT_HELP_COPY_LINK_TO_SECTION_DESCRIPTOR = {
key: "content.help.copy_link_to_section",
message: "Copy link to section",
comment: "Accessible label on the small icon button next to help/policy article headings; clicking copies a deep link to that section.",
};
);
@@ -1,369 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const DONATIONS_BUSINESS_DESCRIPTOR = {
key: "donations.business",
message: "Business",
comment: "Compact UI label on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_DONATE_ACTION_DESCRIPTOR = {
key: "donations.donate.action",
message: "Donate",
comment: "Button or link label on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_DONATE_LABEL_DESCRIPTOR = {
key: "donations.donate.label",
message: "Donate to {product_name}",
comment: "Short UI label or heading on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_ERRORS_ACTIVE_SUBSCRIPTION_EXISTS_DESCRIPTOR = {
key: "donations.errors.active_subscription_exists",
message: "You already have an active recurring donation. To start a different recurring donation, please cancel your current one first using the customer portal. You can make a one-time donation instead.",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_ERRORS_GENERIC_DESCRIPTOR = {
key: "donations.errors.generic",
message: "Something went wrong. Please try again.",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_ERRORS_INVALID_AMOUNT_DESCRIPTOR = {
key: "donations.errors.invalid_amount",
message: "Amount must be between {minimum} and {maximum}",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_ERRORS_INVALID_EMAIL_DESCRIPTOR = {
key: "donations.errors.invalid_email",
message: "Please enter a valid email address",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_ERRORS_NETWORK_DESCRIPTOR = {
key: "donations.errors.network",
message: "Network error. Please try again.",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_AMOUNT_DESCRIPTOR = {
key: "donations.form.amount",
message: "Amount",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_AMOUNT_OTHER_DESCRIPTOR = {
key: "donations.form.amount_other",
message: "Other",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_AMOUNT_PLACEHOLDER_DESCRIPTOR = {
key: "donations.form.amount_placeholder",
message: "Enter amount ({minimum}-{maximum})",
comment: "Placeholder text in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_CURRENCY_DESCRIPTOR = {
key: "donations.form.currency",
message: "Currency",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_DONATION_TYPE_DESCRIPTOR = {
key: "donations.form.donation_type",
message: "Donation type",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_EMAIL_DESCRIPTOR = {
key: "donations.form.email",
message: "Email",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_EMAIL_PLACEHOLDER_DESCRIPTOR = {
key: "donations.form.email_placeholder",
message: "your@email.com",
comment: "Placeholder text in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_MONTHLY_DESCRIPTOR = {
key: "donations.form.monthly",
message: "Monthly",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_ONE_TIME_DESCRIPTOR = {
key: "donations.form.one_time",
message: "One-time",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_OR_LABEL_DESCRIPTOR = {
key: "donations.form.or_label",
message: "Or",
comment: "Short UI label or heading in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_PROCESSING_DESCRIPTOR = {
key: "donations.form.processing",
message: "Processing…",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_SEND_LINK_DESCRIPTOR = {
key: "donations.form.send_link",
message: "Send link",
comment: "Button or link label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_SENDING_DESCRIPTOR = {
key: "donations.form.sending",
message: "Sending…",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_FORM_YEARLY_DESCRIPTOR = {
key: "donations.form.yearly",
message: "Yearly",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_INDIVIDUAL_DESCRIPTOR = {
key: "donations.individual",
message: "Individual",
comment: "Compact UI label on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_MANAGE_DESCRIPTION_DESCRIPTOR = {
key: "donations.manage.description",
message: "Enter your email to receive a link to your donor portal, where you can manage subscriptions, download invoices, and view your donation history.",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_MANAGE_SUCCESS_DESCRIPTOR = {
key: "donations.manage.success",
message: "If an account exists for that email, you'll receive a management link shortly.",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_MANAGE_TITLE_DESCRIPTOR = {
key: "donations.manage.title",
message: "Manage your donations",
comment: "Short UI label or heading in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_MINIMUM_DONATION_DESCRIPTOR = {
key: "donations.minimum_donation",
message: "Minimum donation: {minimum} in the currency you select",
comment: "Body copy on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SEND_ONE_TIME_GIFT_DESCRIPTOR = {
key: "donations.send_one_time_gift",
message: "Send a one-time gift",
comment: "Compact UI label on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SUCCESS_BACK_TO_DONATE_DESCRIPTOR = {
key: "donations.success.back_to_donate",
message: "Back to donate",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SUCCESS_DESCRIPTION_DESCRIPTOR = {
key: "donations.success.description",
message: "Thank you for your support!",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SUCCESS_EMAIL_NOTICE_DESCRIPTOR = {
key: "donations.success.email_notice",
message: "You will receive a confirmation email shortly with details about your donation and a link to your donor portal.",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SUCCESS_MESSAGE_DESCRIPTOR = {
key: "donations.success.message",
message: "Thank you for your donation! We really appreciate your support.",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SUCCESS_TITLE_DESCRIPTOR = {
key: "donations.success.title",
message: "Thank you!",
comment: "Short UI label or heading in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SUPPORT_FUTURE_DEVELOPMENT_DESCRIPTOR = {
key: "donations.support_future_development",
message: "Support future development",
comment: "Compact UI label on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SUPPORT_MESSAGE_DESCRIPTOR = {
key: "donations.support_message",
message: "Help us build an independent communication platform. Your donation funds the platform's infrastructure and development.",
comment: "Body copy on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_INSTRUCTIONS_DESCRIPTOR = {
key: "donations.swish.instructions",
message: "Open the {swish} app on your phone and scan this QR code to donate.",
comment: "Instruction text in the desktop Swish donation modal. The user is on a computer and should scan a QR code with the Swish mobile app. Keep it clear and easy to follow. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_MOBILE_INSTRUCTIONS_DESCRIPTOR = {
key: "donations.swish.mobile_instructions",
message: "Tap the button below to open {swish} on this device and complete your donation.",
comment: "Instruction text in the mobile Swish donation modal. The user is already on a phone or tablet, so the flow opens the Swish app directly instead of showing a QR code. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_OPEN_ACTION_DESCRIPTOR = {
key: "donations.swish.open_action",
message: "Open {swish}",
comment: "Primary button label in the mobile Swish donation modal. Opens the Swish payment app directly. Keep it short. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_OPEN_TITLE_DESCRIPTOR = {
key: "donations.swish.open_title",
message: "Open {swish}",
comment: "Modal title shown on mobile devices in the Swish donation flow. The user can open the Swish app directly from this page. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_QR_ALT_DESCRIPTOR = {
key: "donations.swish.qr_alt",
message: "{swish} QR code",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_QR_FAILED_DESCRIPTOR = {
key: "donations.swish.qr_failed",
message: "Could not load QR code. Try again later.",
comment: "Body copy in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_QR_LOADING_DESCRIPTOR = {
key: "donations.swish.qr_loading",
message: "Loading…",
comment: "Compact UI label in the donation flow. Prioritize clarity, trust, and form usability; preserve amount, currency, email, and status placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_SCAN_TITLE_DESCRIPTOR = {
key: "donations.swish.scan_title",
message: "Scan with {swish}",
comment: "Modal title shown on desktop devices in the Swish donation flow. The user should scan the QR code using the Swish mobile app. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_UPDATE_AMOUNT_DESCRIPTOR = {
key: "donations.swish.update_amount",
message: "Update amount",
comment: "Button in the mobile Swish donation modal. Updates the amount used by the direct Swish payment link. Keep it short.",
};
);
crate::marketing_message!(
pub const DONATIONS_SWISH_UPDATE_QR_DESCRIPTOR = {
key: "donations.swish.update_qr",
message: "Update QR",
comment: "Button in the desktop Swish donation modal. Updates the QR code for the amount typed into the amount field. Keep it short.",
};
);
crate::marketing_message!(
pub const DONATIONS_WHY_SUPPORT_DESCRIPTOR = {
key: "donations.why_support",
message: "Help us build a different kind of communication platform: open source, community-funded, and built with care.",
comment: "Body copy on donation pages and donation support cards. Keep the tone appreciative without being pushy; preserve placeholders exactly.",
};
);
@@ -1,57 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const DOWNLOAD_DOWNLOAD_DESCRIPTOR = {
key: "download.download",
message: "Download",
comment: "Button or link label on the download page, download buttons, or install calls to action. Keep platform-download wording short, direct, and action-oriented; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DOWNLOAD_DOWNLOAD_APP_OR_OPEN_IN_BROWSER_DESCRIPTOR = {
key: "download.download_app_or_open_in_browser",
message: "Download the app or open {product_name} in your browser to start connecting with your communities.",
comment: "Final call-to-action body copy on the download page. Preserve {product_name} exactly; make clear that users can either install the app or use the browser version. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DOWNLOAD_DOWNLOAD_FLUXER_DESCRIPTOR = {
key: "download.download_fluxer",
message: "Download {product_name}",
comment: "Button or link label on the download page, download buttons, or install calls to action. Keep platform-download wording short, direct, and action-oriented; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DOWNLOAD_DOWNLOAD_FOR_PLATFORM_DESCRIPTOR = {
key: "download.download_for_platform",
message: "Download for {platform}",
comment: "Button or link label on the download page, download buttons, or install calls to action. Keep platform-download wording short, direct, and action-oriented; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DOWNLOAD_OTHER_DOWNLOADS_DESCRIPTOR = {
key: "download.other_downloads",
message: "Other downloads",
comment: "Inline label that precedes a short row of alternate desktop download links (other architecture, package formats, build variants) on the download page. Keep it short.",
};
);
crate::marketing_message!(
pub const DOWNLOAD_OPEN_IN_BROWSER_DESCRIPTOR = {
key: "download.open_in_browser",
message: "Open in browser",
comment: "Button or link label on the download page, download buttons, or install calls to action. Keep platform-download wording short, direct, and action-oriented; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const DOWNLOAD_SCREENSHOTS_COURTESY_OF_DESCRIPTOR = {
key: "download.screenshots_courtesy_of",
message: "Screenshots courtesy of ",
comment: "Button or link label on the download page, download buttons, or install calls to action. Keep platform-download wording short, direct, and action-oriented; preserve placeholders exactly.",
};
);
@@ -1,169 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const FOOTER_BLUESKY_SOCIAL_MEDIA_DESCRIPTOR = {
key: "footer.bluesky_social_media",
message: "{bluesky}",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_COMMUNITY_GUIDELINES_DESCRIPTOR = {
key: "footer.community_guidelines",
message: "Community guidelines",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_COMPANY_INFORMATION_DESCRIPTOR = {
key: "footer.company_information",
message: "Company information",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_CONNECT_DESCRIPTOR = {
key: "footer.connect",
message: "Connect",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_DONATE_DESCRIPTOR = {
key: "footer.donate",
message: "Donate",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_DOWNLOAD_DESCRIPTOR = {
key: "footer.download",
message: "Download",
comment: "Button or link label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_FLUXER_DESCRIPTOR = {
key: "footer.fluxer",
message: "{product_name}",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_FLUXER_PLATFORM_AB_SWEDISH_LIMITED_DESCRIPTOR = {
key: "footer.fluxer_platform_ab_swedish_limited",
message: "© {product_name} Platform AB (Swedish limited liability company: 559537-3993)",
comment: "Body copy in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_HELP_SUPPORT_AN_INDEPENDENT_COMMUNICATION_DESCRIPTOR = {
key: "footer.help_support_an_independent_communication",
message: "Support an independent communication platform. Your donation funds the platform's infrastructure and development.",
comment: "Body copy in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_PARTNERS_DESCRIPTOR = {
key: "footer.partners",
message: "Partners",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_PLUTONIUM_TIER_DESCRIPTOR = {
key: "footer.plutonium_tier",
message: "{premium_tier_name} tier",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_POLICIES_DESCRIPTOR = {
key: "footer.policies",
message: "Policies",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_PRESS_DESCRIPTOR = {
key: "footer.press",
message: "Press",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_PRIVACY_POLICY_DESCRIPTOR = {
key: "footer.privacy_policy",
message: "Privacy policy",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_REPORT_A_BUG_DESCRIPTOR = {
key: "footer.report_a_bug",
message: "Report a bug",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_ROADMAP_DESCRIPTOR = {
key: "footer.roadmap",
message: "Roadmap",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_RSS_FEED_DESCRIPTOR = {
key: "footer.rss_feed",
message: "RSS feed",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_SECURITY_BUG_BOUNTY_DESCRIPTOR = {
key: "footer.security_bug_bounty",
message: "Security bug bounty",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_SOURCE_CODE_DESCRIPTOR = {
key: "footer.source_code",
message: "Source code",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_STATUS_DESCRIPTOR = {
key: "footer.status",
message: "Status",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const FOOTER_TERMS_OF_SERVICE_DESCRIPTOR = {
key: "footer.terms_of_service",
message: "Terms of service",
comment: "Compact UI label in the global marketing footer. Keep navigation labels compact and use legally precise company wording where applicable; preserve placeholders exactly.",
};
);
@@ -1,305 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const LANGUAGES_CHANGE_LANGUAGE_DESCRIPTOR = {
key: "languages.change_language",
message: "Change language",
comment: "Compact UI label in the language picker. Keep the label clear for users changing the marketing-site language.",
};
);
crate::marketing_message!(
pub const LANGUAGES_CHOOSE_YOUR_LANGUAGE_DESCRIPTOR = {
key: "languages.choose_your_language",
message: "Choose your language",
comment: "Compact UI label in the language picker. Keep the label clear for users changing the marketing-site language.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LANGUAGE_LABEL_DESCRIPTOR = {
key: "languages.language_label",
message: "Language",
comment: "Short UI label or heading in the language picker. Keep the label clear for users changing the marketing-site language.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_ARABIC_DESCRIPTOR = {
key: "languages.list.arabic",
message: "Arabic",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_BULGARIAN_DESCRIPTOR = {
key: "languages.list.bulgarian",
message: "Bulgarian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_CHINESE_SIMPLIFIED_DESCRIPTOR = {
key: "languages.list.chinese_simplified",
message: "Chinese (Simplified)",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_CHINESE_TRADITIONAL_DESCRIPTOR = {
key: "languages.list.chinese_traditional",
message: "Chinese (Traditional)",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_CROATIAN_DESCRIPTOR = {
key: "languages.list.croatian",
message: "Croatian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_CZECH_DESCRIPTOR = {
key: "languages.list.czech",
message: "Czech",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_DANISH_DESCRIPTOR = {
key: "languages.list.danish",
message: "Danish",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_DUTCH_DESCRIPTOR = {
key: "languages.list.dutch",
message: "Dutch",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_ENGLISH_DESCRIPTOR = {
key: "languages.list.english",
message: "English",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_ENGLISH_UK_DESCRIPTOR = {
key: "languages.list.english_uk",
message: "English (United Kingdom)",
comment: "Language name shown in the language picker for British English. Use the target locale's natural name for this locale, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_ENGLISH_US_DESCRIPTOR = {
key: "languages.list.english_us",
message: "English (US)",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_FINNISH_DESCRIPTOR = {
key: "languages.list.finnish",
message: "Finnish",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_FRENCH_DESCRIPTOR = {
key: "languages.list.french",
message: "French",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_GERMAN_DESCRIPTOR = {
key: "languages.list.german",
message: "German",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_GREEK_DESCRIPTOR = {
key: "languages.list.greek",
message: "Greek",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_HEBREW_DESCRIPTOR = {
key: "languages.list.hebrew",
message: "Hebrew",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_HINDI_DESCRIPTOR = {
key: "languages.list.hindi",
message: "Hindi",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_HUNGARIAN_DESCRIPTOR = {
key: "languages.list.hungarian",
message: "Hungarian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_INDONESIAN_DESCRIPTOR = {
key: "languages.list.indonesian",
message: "Indonesian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_ITALIAN_DESCRIPTOR = {
key: "languages.list.italian",
message: "Italian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_JAPANESE_DESCRIPTOR = {
key: "languages.list.japanese",
message: "Japanese",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_KOREAN_DESCRIPTOR = {
key: "languages.list.korean",
message: "Korean",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_LITHUANIAN_DESCRIPTOR = {
key: "languages.list.lithuanian",
message: "Lithuanian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_NORWEGIAN_DESCRIPTOR = {
key: "languages.list.norwegian",
message: "Norwegian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_POLISH_DESCRIPTOR = {
key: "languages.list.polish",
message: "Polish",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_PORTUGUESE_BRAZIL_DESCRIPTOR = {
key: "languages.list.portuguese_brazil",
message: "Portuguese (Brazil)",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_ROMANIAN_DESCRIPTOR = {
key: "languages.list.romanian",
message: "Romanian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_RUSSIAN_DESCRIPTOR = {
key: "languages.list.russian",
message: "Russian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_SPANISH_LATIN_AMERICA_DESCRIPTOR = {
key: "languages.list.spanish_latin_america",
message: "Spanish (Latin America)",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_SPANISH_SPAIN_DESCRIPTOR = {
key: "languages.list.spanish_spain",
message: "Spanish (Spain)",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_SWEDISH_DESCRIPTOR = {
key: "languages.list.swedish",
message: "Swedish",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_THAI_DESCRIPTOR = {
key: "languages.list.thai",
message: "Thai",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_TURKISH_DESCRIPTOR = {
key: "languages.list.turkish",
message: "Turkish",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_UKRAINIAN_DESCRIPTOR = {
key: "languages.list.ukrainian",
message: "Ukrainian",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
crate::marketing_message!(
pub const LANGUAGES_LIST_VIETNAMESE_DESCRIPTOR = {
key: "languages.list.vietnamese",
message: "Vietnamese",
comment: "Language name shown in the language picker. Use the target locale's natural name for this language, not a sentence; keep it short.",
};
);
@@ -1,65 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const BETA_AND_ACCESS_BETA_LABEL_DESCRIPTOR = {
key: "beta_and_access.beta_label",
message: "Beta",
comment: "Short UI label or heading in Fluxer marketing UI. Translate naturally for the target locale and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BETA_AND_ACCESS_EARLY_ACCESS_BE_FIRST_TO_TRY_DESCRIPTOR = {
key: "beta_and_access.early_access.be_first_to_try",
message: "Be the first to try new features before they're released to everyone else.",
comment: "Body copy in Fluxer marketing UI. Translate naturally for the target locale and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BETA_AND_ACCESS_EARLY_ACCESS_LABEL_DESCRIPTOR = {
key: "beta_and_access.early_access.label",
message: "Early access",
comment: "Short UI label or heading in Fluxer marketing UI. Translate naturally for the target locale and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BETA_AND_ACCESS_FEATURED_BENEFIT_LINE_DESCRIPTOR = {
key: "beta_and_access.featured_benefit_line",
message: "All the basics you expect, plus a few things you don't.",
comment: "Body copy in Fluxer marketing UI. Translate naturally for the target locale and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BETA_AND_ACCESS_PUBLIC_BETA_DESCRIPTOR = {
key: "beta_and_access.public_beta",
message: "Public beta",
comment: "Compact UI label in Fluxer marketing UI. Translate naturally for the target locale and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const BETA_AND_ACCESS_TRY_WITHOUT_EMAIL_DESCRIPTOR = {
key: "beta_and_access.try_without_email",
message: "Try {product_name} without an email in 30 seconds",
comment: "Body copy in Fluxer marketing UI. Translate naturally for the target locale and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const LAUNCH_HEADING_DESCRIPTOR = {
key: "launch.heading",
message: "The story behind {product_name}",
comment: "Short UI label or heading in the hero launch/roadmap badges linking to blog content. Keep it short and inviting, and preserve the product-name placeholder exactly.",
};
);
crate::marketing_message!(
pub const LAUNCH_VIEW_FULL_ROADMAP_DESCRIPTOR = {
key: "launch.view_full_roadmap",
message: "View full roadmap",
comment: "Compact UI label in the hero launch/roadmap badges linking to blog content. Keep it short and inviting, and preserve the product-name placeholder exactly.",
};
);
@@ -1,89 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const NAVIGATION_CLOSE_DESCRIPTOR = {
key: "navigation.close",
message: "Close",
comment: "Short UI label or heading in site navigation, mobile drawer controls, or accessibility labels. Keep it brief and unambiguous.",
};
);
crate::marketing_message!(
pub const NAVIGATION_CLOSE_NAVIGATION_MENU_DESCRIPTOR = {
key: "navigation.close_navigation_menu",
message: "Close navigation menu",
comment: "Short UI label or heading in site navigation, mobile drawer controls, or accessibility labels. Keep it brief and unambiguous.",
};
);
crate::marketing_message!(
pub const NAVIGATION_OPEN_NAVIGATION_MENU_DESCRIPTOR = {
key: "navigation.open_navigation_menu",
message: "Open navigation menu",
comment: "Accessibility label for the mobile navigation menu toggle. Keep it brief and unambiguous.",
};
);
crate::marketing_message!(
pub const NAVIGATION_COPY_LINK_TO_SECTION_DESCRIPTOR = {
key: "navigation.copy_link_to_section",
message: "Copy link to section",
comment: "ARIA label for the small link icon shown beside policy and job content headings. It copies a direct URL to that section; keep it concise.",
};
);
crate::marketing_message!(
pub const NAVIGATION_GO_HOME_DESCRIPTOR = {
key: "navigation.go_home",
message: "Go home",
comment: "Compact UI label in site navigation, mobile drawer controls, or accessibility labels. Keep it brief and unambiguous.",
};
);
crate::marketing_message!(
pub const NAVIGATION_ON_THIS_PAGE_DESCRIPTOR = {
key: "navigation.on_this_page",
message: "On this page",
comment: "Compact UI label in site navigation, mobile drawer controls, or accessibility labels. Keep it brief and unambiguous.",
};
);
crate::marketing_message!(
pub const NAVIGATION_PAGE_NOT_FOUND_DESCRIPTION_DESCRIPTOR = {
key: "navigation.page_not_found.description",
message: "This page doesn't exist. But there's plenty more to explore.",
comment: "Body copy on the 404 page. Keep the tone helpful and concise while making clear the page was not found.",
};
);
crate::marketing_message!(
pub const NAVIGATION_PAGE_NOT_FOUND_TITLE_DESCRIPTOR = {
key: "navigation.page_not_found.title",
message: "Page not found",
comment: "Short UI label or heading on the 404 page. Keep the tone helpful and concise while making clear the page was not found.",
};
);
crate::marketing_message!(
pub const NAVIGATION_PRESS_DOWNLOAD_ASSETS_INTRO_DESCRIPTOR = {
key: "navigation.press.download_assets_intro",
message: "Download our logos, learn about our brand colors, and get in touch with our press team.",
comment: "Introductory body copy on the press/brand-assets page. Keep wording professional and clear for journalists, partners, and brand-asset users.",
};
);
crate::marketing_message!(
pub const NAVIGATION_PRESS_DOWNLOAD_FLUXER_ASSETS_DESCRIPTOR = {
key: "navigation.press.download_fluxer_assets",
message: "Download {product_name} logos, brand assets, and get in touch with our press team",
comment: "Press-page heading or summary line for brand asset downloads. Preserve {product_name} exactly; keep wording professional for journalists and partners. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const NAVIGATION_PRESS_PRESS_AND_BRAND_ASSETS_DESCRIPTOR = {
key: "navigation.press.press_and_brand_assets",
message: "Press and brand assets",
comment: "Compact UI label on the press/brand-assets page. Keep wording professional and clear for journalists or partners.",
};
);
@@ -1,209 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const PARTNER_PROGRAM_APPLY_GUIDE_PROMPT_DESCRIPTOR = {
key: "partner_program.apply.guide_prompt",
message: "Our help center guide covers what we look for, what each program includes, and how to send in your application.",
comment: "Body copy in the partner application section, pointing readers to the help center guide. Keep the tone professional, creator-friendly, and clear about application expectations.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_BECOME_PARTNER_CALL_TO_ACTION_DESCRIPTOR = {
key: "partner_program.become_partner.call_to_action",
message: "Become a partner",
comment: "Button or link label on the partner program page. Keep the tone professional, creator-friendly, and clear about application expectations.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_BECOME_PARTNER_HEADING_DESCRIPTOR = {
key: "partner_program.become_partner.heading",
message: "Become a {product_name} partner",
comment: "Hero heading on the partner program page. Preserve {product_name} exactly; keep the tone professional and creator-friendly. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_BECOME_PARTNER_READY_PROMPT_DESCRIPTOR = {
key: "partner_program.become_partner.ready_prompt",
message: "Ready to become a partner?",
comment: "Compact UI label on the partner program page. Keep the tone professional, creator-friendly, and clear about application expectations.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.description",
message: "Exclusive perks and benefits for content creators and large community owners.",
comment: "Body copy on the partner program page. Keep the tone professional, creator-friendly, and clear about application expectations.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_LABEL_DESCRIPTOR = {
key: "partner_program.label",
message: "Partners",
comment: "Short UI label or heading on the partner program page. Keep the tone professional, creator-friendly, and clear about application expectations.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_CREATOR_MONETIZATION_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.creator_monetization.description",
message: "Early access to creator monetization features with lower platform fees than non-partners.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_CREATOR_MONETIZATION_LABEL_DESCRIPTOR = {
key: "partner_program.perks.creator_monetization.label",
message: "Creator monetization",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_CUSTOM_VANITY_URL_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.custom_vanity_url.description",
message: "Get an exclusive custom vanity URL like fluxer.gg/yourcommunity.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_CUSTOM_VANITY_URL_LABEL_DESCRIPTOR = {
key: "partner_program.perks.custom_vanity_url.label",
message: "Custom vanity URL",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_DIRECT_TEAM_ACCESS_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.direct_team_access.description",
message: "Join the exclusive partners community with direct access to the {product_name} team.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_DIRECT_TEAM_ACCESS_LABEL_DESCRIPTOR = {
key: "partner_program.perks.direct_team_access.label",
message: "Direct team access",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_DISCOVERY_VISIBILITY_DESCRIPTOR = {
key: "partner_program.perks.discovery_visibility",
message: "Get increased visibility by being featured in community discovery.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_EXCLUSIVE_MERCH_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.exclusive_merch.description",
message: "Get exclusive {product_name} partner-only merchandise and swag.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_EXCLUSIVE_MERCH_LABEL_DESCRIPTOR = {
key: "partner_program.perks.exclusive_merch.label",
message: "Exclusive merch",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_FREE_PLUTONIUM_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.free_plutonium.description",
message: "Get free {premium_tier_name} for your account to enjoy all premium features.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_FREE_PLUTONIUM_LABEL_DESCRIPTOR = {
key: "partner_program.perks.free_plutonium.label",
message: "Free {premium_tier_name}",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_HEADING_DESCRIPTOR = {
key: "partner_program.perks.heading",
message: "Partner perks",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_INCREASED_LIMITS_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.increased_limits.description",
message: "Your community receives increased limits when you need them.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_INCREASED_LIMITS_LABEL_DESCRIPTOR = {
key: "partner_program.perks.increased_limits.label",
message: "Increased limits",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_PARTNER_BADGE_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.partner_badge.description",
message: "Display an exclusive partner badge on your profile to stand out.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_PARTNER_BADGE_LABEL_DESCRIPTOR = {
key: "partner_program.perks.partner_badge.label",
message: "Partner badge",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_SEE_PERKS_DESCRIPTOR = {
key: "partner_program.perks.see_perks",
message: "See perks",
comment: "Compact UI label on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_VIP_VOICE_SERVERS_DESCRIPTION_DESCRIPTOR = {
key: "partner_program.perks.vip_voice_servers.description",
message: "Access to VIP voice servers reserved exclusively for partnered communities.",
comment: "Body copy on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_PERKS_VIP_VOICE_SERVERS_LABEL_DESCRIPTOR = {
key: "partner_program.perks.vip_voice_servers.label",
message: "VIP voice channels",
comment: "Short UI label or heading on the partner program perks section. Keep benefits concrete and appealing to creators/community owners; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PARTNER_PROGRAM_WHO_ITS_FOR_DESCRIPTOR = {
key: "partner_program.who_its_for",
message: "For creators and community owners: free {premium_tier_name}, a partner badge, a custom vanity URL, and more.",
comment: "Body copy under the partner program hero. Preserve {premium_tier_name} exactly; explain the target audience and benefits without sounding like a paid ad. Preserve placeholders exactly.",
};
);
@@ -1,393 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const PLATFORM_SUPPORT_AVAILABILITY_META_DESCRIPTION_DESCRIPTOR = {
key: "platform_support.availability.meta_description",
message: "Get {product_name} for your web browser, {windows}, {linux}, and {macos}. {ios} and {android} are in public testing.",
comment: "Download-page meta description. Preserve {product_name} exactly; keep platform names conventional and make desktop, browser, and public mobile testing availability clear. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_AVAILABILITY_SUMMARY_DESCRIPTOR = {
key: "platform_support.availability.summary",
message: "Available in your web browser and on {windows}, {linux}, and {macos}, with {ios} and {android} in public testing.",
comment: "Intro copy below the download-page heading. Keep platform names conventional and make browser, desktop, and public mobile testing availability clear. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_DESKTOP_INTERFACE_LABEL_DESCRIPTOR = {
key: "platform_support.desktop.interface_label",
message: "{product_name} desktop interface",
comment: "Alt text for a desktop product screenshot. Preserve {product_name} exactly; describe the image as the desktop interface, not as a download action. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_DESKTOP_LABEL_DESCRIPTOR = {
key: "platform_support.desktop.label",
message: "Desktop",
comment: "Short UI label or heading in platform availability and download support copy. Keep wording clear about desktop, web, and mobile status.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_DESKTOP_USE_DESKTOP_CLIENT_MOBILE_SOON_DESCRIPTOR = {
key: "platform_support.desktop.use_desktop_client_mobile_soon",
message: "Use the desktop client (mobile coming soon)",
comment: "Body copy in platform availability and download support copy. Keep wording clear about desktop, web, and mobile status.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_DESKTOP_FLATPAK_OUTDATED_DESCRIPTOR = {
key: "platform_support.desktop.flatpak_outdated",
message: "The {flatpak} package is currently behind the other {linux} downloads.",
comment: "Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_DONE_DESKTOP_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.done_desktop",
message: "Done! You can now open {product_name} as if it were a regular program.",
comment: "Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_DONE_MOBILE_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.done_mobile",
message: "Done! You can now open {product_name} from your home screen.",
comment: "Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_IN_CHROME_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.in_chrome",
message: " in {chrome}",
comment: "Compact UI label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_IN_CHROME_OR_ANOTHER_BROWSER_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.in_chrome_or_another_browser",
message: " in {chrome} or another browser with PWA support",
comment: "Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_IN_SAFARI_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.in_safari",
message: " in Safari",
comment: "Compact UI label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_PWA_INSTALLATION_GUIDE_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.pwa_installation_guide",
message: "PWA installation guide for {name}",
comment: "Compact UI label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_STEPS_PRESS_ADD_TO_HOME_SCREEN_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.steps.press_add_to_home_screen",
message: "Press \"Add to home screen\"",
comment: "Compact UI label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_STEPS_PRESS_ADD_UPPER_RIGHT_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.steps.press_add_upper_right",
message: "Press \"Add\" in the upper-right corner",
comment: "Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_STEPS_PRESS_INSTALL_APP_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.steps.press_install_app",
message: "Press \"Install app\"",
comment: "Compact UI label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_STEPS_PRESS_INSTALL_BUTTON_ADDRESS_BAR_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.steps.press_install_button_address_bar",
message: "Press the install button (downward-pointing arrow on monitor) in the address bar",
comment: "Button or link label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_STEPS_PRESS_INSTALL_IN_POPUP_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.steps.press_install_in_popup",
message: "Press \"Install\" in the popup that appears",
comment: "Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_STEPS_PRESS_MORE_MENU_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.steps.press_more_menu",
message: "Press the \"More\" (⋮) button in the top-right corner",
comment: "Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_STEPS_PRESS_SHARE_BUTTON_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.guides.steps.press_share_button",
message: "Press the share button (rectangle with upward-pointing arrow)",
comment: "Button or link label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_INSTALL_FLUXER_AS_APP_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.install_fluxer_as_app",
message: "Install {product_name} as an app",
comment: "Compact UI label for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_LINK_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.link",
message: "Installing as an app",
comment: "Secondary text-link label on the download page that opens the PWA installation guide. Keep it short and action-oriented.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_TITLE_DESCRIPTOR = {
key: "platform_support.mobile.install_as_app.title",
message: "How to install as an app",
comment: "Short UI label or heading for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_INTERFACE_LABEL_DESCRIPTOR = {
key: "platform_support.mobile.interface_label",
message: "{product_name} mobile interface",
comment: "Short UI label or heading for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_ANDROID_APK_DESCRIPTOR = {
key: "platform_support.platforms.android.apk",
message: "APK",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_ANDROID_MIN_VERSION_DESCRIPTOR = {
key: "platform_support.platforms.android.min_version",
message: "{android} 8+",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_ANDROID_NAME_DESCRIPTOR = {
key: "platform_support.platforms.android.name",
message: "{android}",
comment: "Short UI label or heading naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_IOS_IOS_IPADOS_DESCRIPTOR = {
key: "platform_support.platforms.ios.ios_ipados",
message: "{ios} and {ipados}",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_IOS_MIN_VERSION_DESCRIPTOR = {
key: "platform_support.platforms.ios.min_version",
message: "{ios} 15+",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_IOS_NAME_DESCRIPTOR = {
key: "platform_support.platforms.ios.name",
message: "{ios}",
comment: "Short UI label or heading naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_IOS_TESTFLIGHT_DESCRIPTOR = {
key: "platform_support.platforms.ios.testflight",
message: "{testflight}",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_LINUX_CHOOSE_DISTRIBUTION_DESCRIPTOR = {
key: "platform_support.platforms.linux.choose_distribution",
message: "Choose {linux} distribution",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_LINUX_NAME_DESCRIPTOR = {
key: "platform_support.platforms.linux.name",
message: "{linux}",
comment: "Short UI label or heading naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_LINUX_RECOMMENDED_DESCRIPTOR = {
key: "platform_support.platforms.linux.recommended",
message: "recommended",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_MACOS_APPLE_SILICON_DESCRIPTOR = {
key: "platform_support.platforms.macos.apple_silicon",
message: "{apple_silicon}",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_MACOS_DOWNLOAD_LABEL_DESCRIPTOR = {
key: "platform_support.platforms.macos.download_label",
message: "Download for {macos}",
comment: "Button or link label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_MACOS_INTEL_DESCRIPTOR = {
key: "platform_support.platforms.macos.intel",
message: "Intel",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_MACOS_MIN_VERSION_DESCRIPTOR = {
key: "platform_support.platforms.macos.min_version",
message: "{macos} 10.15+",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_MACOS_NAME_DESCRIPTOR = {
key: "platform_support.platforms.macos.name",
message: "{macos}",
comment: "Short UI label or heading naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_WINDOWS_DOWNLOAD_LABEL_DESCRIPTOR = {
key: "platform_support.platforms.windows.download_label",
message: "Download for {windows}",
comment: "Button or link label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_WINDOWS_MIN_VERSION_DESCRIPTOR = {
key: "platform_support.platforms.windows.min_version",
message: "{windows} 10+",
comment: "Compact UI label naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_WINDOWS_NAME_DESCRIPTOR = {
key: "platform_support.platforms.windows.name",
message: "{windows}",
comment: "Short UI label or heading naming a platform, installer, architecture, or minimum version in download UI. Keep platform names conventional and labels compact. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_PLATFORMS_PORTABLE_DESCRIPTOR = {
key: "platform_support.platforms.portable",
message: "Portable",
comment: "Compact UI label for a portable (no-install) desktop build that stores all data next to the executable. Keep it short.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_WEB_APP_TITLE_DESCRIPTOR = {
key: "platform_support.mobile.web_app.title",
message: "Web app",
comment: "Card title for the mobile web app (PWA) option on the download page. Keep it short.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_WEB_APP_BODY_DESCRIPTOR = {
key: "platform_support.mobile.web_app.body",
message: "{product_name} runs in any desktop or mobile web browser, and installs to your home screen or desktop like a Progressive Web App. It is the most complete way to use {product_name} on a phone today.",
comment: "Body copy for the web app row on the download page. Preserve {product_name} exactly; mention desktop and mobile browsers and that it installs like a Progressive Web App. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_IOS_TITLE_DESCRIPTOR = {
key: "platform_support.mobile.ios.title",
message: "{ios} app",
comment: "Card title for the iOS app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_IOS_BODY_DESCRIPTOR = {
key: "platform_support.mobile.ios.body",
message: "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen.",
comment: "Body copy for the iOS app row on the download page. Make clear that TestFlight access is currently limited to Fluxer Plutonium members, that public access is coming soon, and that the web app remains available in Safari. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_ANDROID_TITLE_DESCRIPTOR = {
key: "platform_support.mobile.android.title",
message: "{android} app",
comment: "Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_ANDROID_BODY_DESCRIPTOR = {
key: "platform_support.mobile.android.body",
message: "Install the {android} APK straight from our open source repository on {github}.",
comment: "Body copy for the Android app card on the download page. Keep APK and GitHub as proper names; make clear the install file lives in the open source repository. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PLATFORM_SUPPORT_MOBILE_ANDROID_CTA_DESCRIPTOR = {
key: "platform_support.mobile.android.cta",
message: "Download the APK",
comment: "Button or link label on the Android app card that opens the GitHub repository where the APK is published. Keep APK as a proper name; keep it short.",
};
);
@@ -1,193 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_BRAND_COLORS_HEADING_DESCRIPTOR = {
key: "press_branding.assets.brand_colors_heading",
message: "Brand colors",
comment: "Short UI label or heading in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_FULL_COLOR_DESCRIPTOR = {
key: "press_branding.assets.full_color",
message: "Full color version",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_LABEL_DESCRIPTOR = {
key: "press_branding.assets.label",
message: "Logo",
comment: "Short UI label or heading in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_LOGO_VARIANTS_BLACK_LOGO_DESCRIPTOR = {
key: "press_branding.assets.logo_variants.black_logo",
message: "Black logo",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_LOGO_VARIANTS_COLOR_LOGO_DESCRIPTOR = {
key: "press_branding.assets.logo_variants.color_logo",
message: "Color logo",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_LOGO_VARIANTS_WHITE_LOGO_DESCRIPTOR = {
key: "press_branding.assets.logo_variants.white_logo",
message: "White logo",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_PALETTE_DESCRIPTION_DESCRIPTOR = {
key: "press_branding.assets.palette_description",
message: "Our carefully selected color palette that represents the {product_name} brand.",
comment: "Body copy in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_PREMIUM_QUALITY_DESCRIPTOR = {
key: "press_branding.assets.premium_quality",
message: "Premium quality",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_PRIMARY_BRAND_COLOR_DESCRIPTION_DESCRIPTOR = {
key: "press_branding.assets.primary_brand_color_description",
message: "Our primary brand color. Use this for key brand elements and accents.",
comment: "Body copy in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_SYMBOL_VARIANTS_BLACK_SYMBOL_DESCRIPTOR = {
key: "press_branding.assets.symbol_variants.black_symbol",
message: "Black symbol",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_SYMBOL_VARIANTS_COLOR_SYMBOL_DESCRIPTOR = {
key: "press_branding.assets.symbol_variants.color_symbol",
message: "Color symbol",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_SYMBOL_VARIANTS_LABEL_DESCRIPTOR = {
key: "press_branding.assets.symbol_variants.label",
message: "Symbol",
comment: "Short UI label or heading in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_SYMBOL_VARIANTS_WHITE_SYMBOL_DESCRIPTOR = {
key: "press_branding.assets.symbol_variants.white_symbol",
message: "White symbol",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_USAGE_GUIDANCE_DARK_SURFACE_GUIDANCE_DESCRIPTOR = {
key: "press_branding.assets.usage_guidance.dark_surface_guidance",
message: "For backgrounds, text on dark surfaces, and creating contrast.",
comment: "Body copy in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_USAGE_GUIDANCE_FOR_DARK_BACKGROUNDS_DESCRIPTOR = {
key: "press_branding.assets.usage_guidance.for_dark_backgrounds",
message: "For dark backgrounds",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_USAGE_GUIDANCE_FOR_LIGHT_BACKGROUNDS_DESCRIPTOR = {
key: "press_branding.assets.usage_guidance.for_light_backgrounds",
message: "For light backgrounds",
comment: "Compact UI label in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_USAGE_GUIDANCE_FULL_LOGO_DESCRIPTION_DESCRIPTOR = {
key: "press_branding.assets.usage_guidance.full_logo_description",
message: "Our full logo including the wordmark. Use this as the primary representation of our brand.",
comment: "Body copy in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_USAGE_GUIDANCE_LIGHT_SURFACE_GUIDANCE_DESCRIPTOR = {
key: "press_branding.assets.usage_guidance.light_surface_guidance",
message: "For text, icons on light surfaces, and creating depth.",
comment: "Body copy in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_ASSETS_USAGE_GUIDANCE_SYMBOL_DESCRIPTION_DESCRIPTOR = {
key: "press_branding.assets.usage_guidance.symbol_description",
message: "Our standalone symbol. Use this only when our brand is clearly visible or well-established elsewhere in the context.",
comment: "Body copy in the press kit and brand asset download area. Keep terminology professional for brand/logo usage guidance; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_COLORS_BLACK_DESCRIPTOR = {
key: "press_branding.colors.black",
message: "Black",
comment: "Brand color name in the press kit. Keep the color label recognizable and do not over-translate proper names unless natural in the locale.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_COLORS_BLUE_DA_BA_DEE_DESCRIPTOR = {
key: "press_branding.colors.blue_da_ba_dee",
message: "{blue_da_ba_dee}",
comment: "Brand color name in the press kit. Keep the color label recognizable and do not over-translate proper names unless natural in the locale. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_COLORS_WHITE_DESCRIPTOR = {
key: "press_branding.colors.white",
message: "White",
comment: "Brand color name in the press kit. Keep the color label recognizable and do not over-translate proper names unless natural in the locale.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_CONTACT_RESPONSE_TIME_DESCRIPTOR = {
key: "press_branding.contact.response_time",
message: "We typically respond within 24 hours.",
comment: "Short press contact reassurance. Keep it professional and concrete; this is about reply timing, not support availability.",
};
);
crate::marketing_message!(
pub const PRESS_BRANDING_CONTACT_STORY_PROMPT_MESSAGE_DESCRIPTOR = {
key: "press_branding.contact.story_prompt_message",
message: "Have a story about {product_name}? We'd love to hear from you at {email}.",
comment: "Press contact prompt. Here \"story\" means a media story, article angle, or news lead about Fluxer, not a personal history. Keep the tone professional and media-friendly; preserve placeholders exactly.",
};
);
@@ -1,265 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const PRICING_AND_TIERS_BILLING_FOREVER_DESCRIPTOR = {
key: "pricing_and_tiers.billing.forever",
message: "Forever",
comment: "Compact UI label in pricing and billing UI. Keep it short, conventional for subscriptions, and preserve price/currency placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_BILLING_MOST_POPULAR_DESCRIPTOR = {
key: "pricing_and_tiers.billing.most_popular",
message: "Most popular",
comment: "Compact UI label in pricing and billing UI. Keep it short, conventional for subscriptions, and preserve price/currency placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_BILLING_PER_FOREVER_DESCRIPTOR = {
key: "pricing_and_tiers.billing.per_forever",
message: "/forever",
comment: "Compact UI label in pricing and billing UI. Keep it short, conventional for subscriptions, and preserve price/currency placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_BILLING_PER_MONTH_DESCRIPTOR = {
key: "pricing_and_tiers.billing.per_month",
message: "/mo",
comment: "Compact UI label in pricing and billing UI. Keep it short, conventional for subscriptions, and preserve price/currency placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_BILLING_PER_YEAR_FULL_DESCRIPTOR = {
key: "pricing_and_tiers.billing.per_year_full",
message: "/year",
comment: "Compact UI label in pricing and billing UI. Keep it short, conventional for subscriptions, and preserve price/currency placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_BILLING_PER_YEAR_SHORT_DESCRIPTOR = {
key: "pricing_and_tiers.billing.per_year_short",
message: "/yr",
comment: "Compact UI label in pricing and billing UI. Keep it short, conventional for subscriptions, and preserve price/currency placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_BILLING_SAVE_PERCENT_DESCRIPTOR = {
key: "pricing_and_tiers.billing.save_percent",
message: "Save 17%",
comment: "Compact UI label in pricing and billing UI. Keep it short, conventional for subscriptions, and preserve price/currency placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_FREE_COMPARISON_LABEL_DESCRIPTOR = {
key: "pricing_and_tiers.free.comparison_label",
message: "Free vs {premium_tier_name}",
comment: "Short UI label or heading in pricing comparison UI. Keep labels clear and commercially neutral; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_FREE_EVERYTHING_IN_FREE_PLUS_DESCRIPTOR = {
key: "pricing_and_tiers.free.everything_in_free_plus",
message: "Everything in Free, plus:",
comment: "Compact UI label in pricing comparison UI. Keep labels clear and commercially neutral; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_FREE_FULL_ACCESS_TO_ALL_FEATURES_DESCRIPTOR = {
key: "pricing_and_tiers.free.full_access_to_all_features",
message: "Full access to all features",
comment: "Compact UI label in pricing comparison UI. Keep labels clear and commercially neutral; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_FREE_LABEL_DESCRIPTOR = {
key: "pricing_and_tiers.free.label",
message: "Free",
comment: "Short UI label or heading in pricing comparison UI. Keep labels clear and commercially neutral; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_BENEFITS_NOTE_OFFICIAL_INSTANCE_ONLY_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.benefits_note_official_instance_only",
message: "Note: {premium_tier_name} and {visionary_tier_name} benefits only apply to the official {product_name}.app instance, not third-party or self-hosted instances.",
comment: "Body copy describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURE_HIGHLIGHTS_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.feature_highlights",
message: "500 MB uploads, 4,000-character messages, 300 bookmarks, 50 emoji packs, and much more.",
comment: "Body copy describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_CHOOSE_CUSTOM_4_DIGIT_TAG_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag",
message: "Choose any available 4-digit tag from #0001 to #9999 to make your username truly unique.",
comment: "Body copy describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_CUSTOM_4_DIGIT_USERNAME_TAG_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.custom_4_digit_username_tag",
message: "Custom 4-digit username tag",
comment: "Short UI label or heading describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_CUSTOM_USERNAME_TAG_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.custom_username_tag",
message: "Custom username tag",
comment: "Short UI label or heading describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_CUSTOM_VIDEO_BACKGROUNDS_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.custom_video_backgrounds",
message: "Video backgrounds",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_EMOJI_STICKER_PACKS_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.emoji_sticker_packs",
message: "Emoji and sticker packs",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_FILE_UPLOAD_SIZE_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.file_upload_size",
message: "File upload size",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_GLOBAL_EMOJI_STICKER_ACCESS_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.global_emoji_sticker_access",
message: "Global emoji and sticker access",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_MESSAGE_CHARACTER_LIMIT_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.message_character_limit",
message: "Message character limit",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_MESSAGE_SCHEDULING_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.message_scheduling",
message: "Message scheduling",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_PER_COMMUNITY_PROFILES_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.per_community_profiles",
message: "Per-community profiles",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_SAVED_MEDIA_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.saved_media",
message: "Saved media",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_FEATURES_WEBHOOKS_AND_BOT_SUPPORT_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.features.webhooks_and_bot_support",
message: "Webhooks and bot support",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_GET_MORE_WITH_PLUTONIUM_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.get_more_with_plutonium",
message: "Get more with {product_name} {premium_tier_name}",
comment: "Button or link label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_GET_PLUTONIUM_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.get_plutonium",
message: "Get {premium_tier_name}",
comment: "Button or link label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_HIGHER_LIMITS_AND_EARLY_ACCESS_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.higher_limits_and_early_access",
message: "Higher limits, exclusive features, and early access to new updates",
comment: "Body copy describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_HIGHER_LIMITS_EVERYWHERE_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.higher_limits_everywhere",
message: "Higher limits everywhere",
comment: "Compact UI label describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_SHOW_OFF_STATUS_BADGE_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.show_off_status_badge",
message: "Show off your {premium_tier_name} status with an exclusive badge on your profile.",
comment: "Body copy describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_STANDARD_PRICING_AVAILABLE_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.standard_pricing_available",
message: "Prefer paying in standard {currency}? Also available at checkout for {monthly_price} or {yearly_price}.",
comment: "Body copy describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_PLUTONIUM_TIER_NAME_DESCRIPTOR = {
key: "pricing_and_tiers.plutonium.tier_name",
message: "{premium_tier_name}",
comment: "Short UI label or heading describing the Plutonium paid tier. Keep the tier name unchanged unless the locale normally transliterates product tier names; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRICING_AND_TIERS_VISIONARY_ONE_TIME_PURCHASE_LABEL_DESCRIPTOR = {
key: "pricing_and_tiers.visionary.one_time_purchase.label",
message: "One-time purchase",
comment: "Short UI label or heading in pricing comparison UI. Keep labels clear and commercially neutral; preserve placeholders exactly.",
};
);
@@ -1,105 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const PRODUCT_POSITIONING_FREE_AND_OPEN_SOURCE_DESCRIPTOR = {
key: "product_positioning.free_and_open_source",
message: "{product_name} is free and open source. Anyone can self-host for free.",
comment: "Short product-positioning body copy used in footer and self-hosting sections. Preserve {product_name} exactly; keep the free/open-source and self-hosting meaning clear. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_INTRO_DESCRIPTOR = {
key: "product_positioning.intro",
message: "{product_name} is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.",
comment: "Primary product-positioning body copy used in page metadata and hero sections. Preserve {product_name} exactly; keep the free/open-source, instant messaging, VoIP, and community meanings clear. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_OPEN_SOURCE_FULLY_OPEN_SOURCE_AGPLV3_DESCRIPTOR = {
key: "product_positioning.open_source.fully_open_source_agplv3",
message: "Fully open source (AGPLv3)",
comment: "Button or link label explaining Fluxer open-source licensing. Keep legal/license tokens such as AGPLv3 unchanged.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_OPEN_SOURCE_LICENSE_DESCRIPTOR = {
key: "product_positioning.open_source.license",
message: "Open source (AGPL-3.0)",
comment: "Button or link label explaining Fluxer open-source licensing. Keep legal/license tokens such as AGPLv3 unchanged.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_CONNECT_FROM_ANY_CLIENT_DESCRIPTOR = {
key: "product_positioning.self_hosting.connect_from_any_client",
message: "Connect from any client",
comment: "Compact UI label explaining self-hosting and Operator Pass expectations. Keep technical wording accurate and preserve product-name placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_FREE_SELF_HOSTING_DESCRIPTOR = {
key: "product_positioning.self_hosting.free_self_hosting",
message: "Free self-hosting",
comment: "Compact UI label explaining self-hosting and Operator Pass expectations. Keep technical wording accurate and preserve product-name placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_HOST_YOUR_OWN_INSTANCE_DESCRIPTOR = {
key: "product_positioning.self_hosting.host_your_own_instance",
message: "Host your own instance",
comment: "Compact UI label explaining self-hosting and Operator Pass expectations. Keep technical wording accurate and preserve product-name placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_LABEL_DESCRIPTOR = {
key: "product_positioning.self_hosting.label",
message: "Self-hosting",
comment: "Short UI label or heading explaining self-hosting and Operator Pass expectations. Keep technical wording accurate and preserve product-name placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_OPERATOR_PASS_COMMUNITY_FEEDBACK_DESCRIPTOR = {
key: "product_positioning.self_hosting.operator_pass.community_feedback",
message: "Smaller {operators} community with team access",
comment: "Benefit label for the Operator Pass. Keep this warm and concrete: a smaller community where self-hosters can get help from each other and the Fluxer team. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_OPERATOR_PASS_LABEL_DESCRIPTOR = {
key: "product_positioning.self_hosting.operator_pass.label",
message: "{operator_pass}",
comment: "Short UI label or heading explaining self-hosting and Operator Pass expectations. Keep technical wording accurate and preserve product-name placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_OPERATOR_PASS_EXPECTATIONS_DESCRIPTOR = {
key: "product_positioning.self_hosting.operator_pass.donating_with_perks_smaller_space",
message: "Think of the optional {operator_pass} like donating with perks: you help keep {product_name} going and get the {operators} community, a smaller place where self-hosters and the {product_name} team can help when you get stuck, hear your ideas, and discuss feedback before it gets buried on {github}.",
comment: "Body copy explaining Operator Pass expectations. Keep it personal and warm: like donating with perks, with a smaller community where self-hosters can get help from each other and the Fluxer team. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_RUN_BACKEND_ON_YOUR_HARDWARE_DESCRIPTOR = {
key: "product_positioning.self_hosting.run_backend_on_your_hardware",
message: "Run the {product_name} backend on your own hardware and connect with our apps.",
comment: "Body copy explaining self-hosting and Operator Pass expectations. Keep technical wording accurate and preserve product-name placeholders exactly.",
};
);
crate::marketing_message!(
pub const PRODUCT_POSITIONING_SELF_HOSTING_SWITCH_BETWEEN_INSTANCES_DESCRIPTOR = {
key: "product_positioning.self_hosting.switch_between_instances",
message: "Switch between multiple instances",
comment: "Compact UI label explaining self-hosting and Operator Pass expectations. Keep technical wording accurate and preserve product-name placeholders exactly.",
};
);
@@ -1,33 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const SECURITY_FOUND_SECURITY_ISSUE_DESCRIPTOR = {
key: "security.found_security_issue",
message: "Found a security issue?",
comment: "Compact UI label in security, bug bounty, or responsible disclosure sections. Keep wording precise, calm, and trustworthy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const SECURITY_RESPONSIBLE_DISCLOSURE_NOTE_DESCRIPTOR = {
key: "security.responsible_disclosure_note",
message: "We appreciate responsible disclosure via our security bug bounty page. We offer {premium_tier_name} codes and {bug_hunter} badges based on severity.",
comment: "Body copy in security, bug bounty, or responsible disclosure sections. Keep wording precise, calm, and trustworthy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const SECURITY_SECURITY_BUG_BOUNTY_DESCRIPTOR = {
key: "security.security_bug_bounty",
message: "Security bug bounty",
comment: "Compact UI label in security, bug bounty, or responsible disclosure sections. Keep wording precise, calm, and trustworthy; preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const SECURITY_TESTERS_ACCESS_FROM_REPORTS_DESCRIPTOR = {
key: "security.testers_access_from_reports",
message: "Found a bug? Check out our bug report guide to learn how to file clear, high-quality reports.",
comment: "Body copy in security, bug bounty, or responsible disclosure sections. Keep wording precise, calm, and trustworthy; preserve placeholders exactly.",
};
);
@@ -1,177 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const GENERAL_COMING_SOON_LABEL_DESCRIPTOR = {
key: "general.coming_soon.label",
message: "Coming soon",
comment: "Short UI label or heading shared across marketing pages. Keep it broadly reusable, concise, and consistent with the Fluxer brand voice.",
};
);
crate::marketing_message!(
pub const GENERAL_COMING_SOON_WHATS_AVAILABLE_TODAY_DESCRIPTOR = {
key: "general.coming_soon.whats_available_today",
message: "What's available today",
comment: "Compact UI label shared across marketing pages. Keep it broadly reusable, concise, and consistent with the Fluxer brand voice.",
};
);
crate::marketing_message!(
pub const GENERAL_FLUXER_TEAM_DESCRIPTOR = {
key: "general.fluxer_team",
message: "{product_name} Team",
comment: "Default author metadata label for Fluxer-authored marketing pages when no individual author is shown. Keep Fluxer as a product name. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const GENERAL_LAST_UPDATED_DESCRIPTOR = {
key: "general.last_updated",
message: "Last updated",
comment: "Compact UI label shared across marketing pages. Keep it broadly reusable, concise, and consistent with the Fluxer brand voice.",
};
);
crate::marketing_message!(
pub const GENERAL_MADE_IN_SWEDEN_DESCRIPTOR = {
key: "general.made_in_sweden",
message: "Made in Sweden",
comment: "Compact UI label shared across marketing pages. Keep it broadly reusable, concise, and consistent with the Fluxer brand voice.",
};
);
crate::marketing_message!(
pub const GENERAL_NOT_AVAILABLE_DESCRIPTOR = {
key: "general.not_available",
message: "Not available",
comment: "Compact UI label shared across marketing pages. Keep it broadly reusable, concise, and consistent with the Fluxer brand voice.",
};
);
crate::marketing_message!(
pub const GENERAL_OR_DESCRIPTOR = {
key: "general.or",
message: "or",
comment: "Compact UI label shared across marketing pages. Keep it broadly reusable, concise, and consistent with the Fluxer brand voice.",
};
);
crate::marketing_message!(
pub const GENERAL_TAGLINE_DESCRIPTOR = {
key: "general.tagline",
message: "A chat app that puts you first",
comment: "Compact UI label shared across marketing pages. Keep it broadly reusable, concise, and consistent with the Fluxer brand voice.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_DONATING_WITH_PERKS_DESCRIPTOR = {
key: "misc_labels.donating_with_perks",
message: "Donating with perks",
comment: "Short Operator Pass benefit label. Keep this warm and concrete: supporting Fluxer with community access as a perk.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_EXCLUSIVE_FEATURES_DESCRIPTOR = {
key: "misc_labels.exclusive_features",
message: "Exclusive features",
comment: "Short UI label or heading reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_FEATURE_DESCRIPTOR = {
key: "misc_labels.feature",
message: "Feature",
comment: "Short UI label or heading reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_GET_UPDATES_DESCRIPTOR = {
key: "misc_labels.get_updates",
message: "Get updates, see upcoming features, discuss suggestions, and chat with the team.",
comment: "Button or link label reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_JOIN_FLUXER_HQ_DESCRIPTOR = {
key: "misc_labels.join_fluxer_hq",
message: "Join {product_name} HQ",
comment: "Button/link label for joining the official Fluxer HQ community. Preserve {product_name} exactly; translate as a short standalone call to action. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_JOIN_MULTIPLE_DEVICES_DESCRIPTOR = {
key: "misc_labels.join_multiple_devices",
message: "Join from multiple devices at once",
comment: "Button or link label reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_JOIN_TEAM_BEHIND_FLUXER_DESCRIPTOR = {
key: "misc_labels.join_team_behind_fluxer",
message: "Join the team behind {product_name}",
comment: "Careers-page hero heading. Preserve {product_name} exactly; this refers to employment or future roles, not joining a user community. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_LEARN_MORE_DESCRIPTOR = {
key: "misc_labels.learn_more",
message: "Learn more",
comment: "Button or link label reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_PROFILE_BADGE_DESCRIPTOR = {
key: "misc_labels.profile_badge",
message: "Profile badge",
comment: "Short UI label or heading reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_READ_THE_GUIDE_DESCRIPTOR = {
key: "misc_labels.read_the_guide",
message: "Read the guide",
comment: "Button or link label reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_READY_TO_GET_STARTED_DESCRIPTOR = {
key: "misc_labels.ready_to_get_started",
message: "Ready to get started?",
comment: "Button or link label reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_RELATED_POLICIES_DESCRIPTOR = {
key: "misc_labels.related_policies",
message: "Related policies",
comment: "Short UI label or heading reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_REPORT_BUGS_DESCRIPTOR = {
key: "misc_labels.report_bugs",
message: "Report bugs",
comment: "Button or link label reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
crate::marketing_message!(
pub const MISC_LABELS_UNLIMITED_USERS_DESCRIPTOR = {
key: "misc_labels.unlimited_users",
message: "Unlimited users",
comment: "Short UI label or heading reused across marketing cards, buttons, and section headings. Translate as a standalone UI phrase that can fit multiple surfaces.",
};
);
@@ -1,73 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_BLUESKY_FOLLOW_US_DESCRIPTOR = {
key: "social_and_feeds.bluesky.follow_us",
message: "Follow us on {bluesky}",
comment: "Compact UI label in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_BLUESKY_LABEL_DESCRIPTOR = {
key: "social_and_feeds.bluesky.label",
message: "{bluesky}",
comment: "Short UI label or heading in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_BLUESKY_RSS_FEED_DESCRIPTOR = {
key: "social_and_feeds.bluesky.rss_feed",
message: "{bluesky} RSS feed",
comment: "Compact UI label in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_FOLLOW_FLUXER_DESCRIPTOR = {
key: "social_and_feeds.follow_fluxer",
message: "Follow @{social_handle}",
comment: "Compact UI label in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_GITHUB_DESCRIPTOR = {
key: "social_and_feeds.github",
message: "{github}",
comment: "Compact UI label in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_RSS_BLOG_RSS_FEED_DESCRIPTOR = {
key: "social_and_feeds.rss.blog_rss_feed",
message: "Blog RSS feed",
comment: "Compact UI label in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_RSS_FLUXER_BLOG_RSS_DESCRIPTOR = {
key: "social_and_feeds.rss.fluxer_blog_rss",
message: "{product_name} blog RSS",
comment: "Compact UI label in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_RSS_LABEL_DESCRIPTOR = {
key: "social_and_feeds.rss.label",
message: "RSS feed",
comment: "Short UI label or heading in social, Bluesky, GitHub, or RSS follow sections. Keep labels recognizable, avoid dangling sentence fragments, and preserve handles/placeholders exactly.",
};
);
crate::marketing_message!(
pub const SOCIAL_AND_FEEDS_STAY_UPDATED_CTA_DESCRIPTOR = {
key: "social_and_feeds.stay_updated_cta",
message: "Stay updated on news, service status, and what's happening. You can also subscribe to our",
comment: "Body copy in the social/follow card. It is followed by separate RSS feed links in the UI, so translate as an unfinished lead-in only if that grammar works in the target locale.",
};
);
@@ -1,41 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
crate::marketing_message!(
pub const VOICE_REGIONS_MAP_HEADING_DESCRIPTOR = {
key: "voice_regions.map_heading",
message: "Voice regions",
comment: "Section heading above the world map of voice regions. Plain, sentence case.",
};
);
crate::marketing_message!(
pub const VOICE_REGIONS_MAP_INTRO_DESCRIPTOR = {
key: "voice_regions.map_intro",
message: "Sixteen voice regions across six continents. Voice and video calls connect through the region closest to you.",
comment: "Paragraph under the map heading. State facts; no marketing language. Sentence case.",
};
);
crate::marketing_message!(
pub const VOICE_REGIONS_MAP_LEGEND_DESCRIPTOR = {
key: "voice_regions.map_legend",
message: "Each dot is a {product_name} voice region.",
comment: "Accessible legend / alt-style sentence next to the map. Sentence case, short. Preserve placeholders exactly.",
};
);
crate::marketing_message!(
pub const VOICE_REGIONS_LANGUAGES_HEADING_DESCRIPTOR = {
key: "voice_regions.languages_heading",
message: "Languages",
comment: "Section heading above the list of supported languages. Sentence case; one short noun.",
};
);
crate::marketing_message!(
pub const VOICE_REGIONS_LANGUAGES_INTRO_DESCRIPTOR = {
key: "voice_regions.languages_intro",
message: "{product_name} is available in over thirty languages. To help translate {product_name} into your native language, write to {email}.",
comment: "Paragraph under the languages heading. Includes an {email} placeholder for the localization contact. Plain and factual.",
};
);
-236
View File
@@ -1,236 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use anyhow::Context;
use gettext::Catalog;
use std::collections::BTreeMap;
use std::io::Cursor;
use crate::invariant_text::{BRAND_PLACEHOLDERS, PRODUCT_NAME};
include!(concat!(env!("OUT_DIR"), "/i18n/generated.rs"));
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MarketingMessageDescriptor {
key: &'static str,
message: &'static str,
comment: &'static str,
}
impl MarketingMessageDescriptor {
pub const fn new(key: &'static str, message: &'static str, comment: &'static str) -> Self {
Self {
key,
message,
comment,
}
}
pub const fn key(self) -> &'static str {
self.key
}
pub const fn message(self) -> &'static str {
self.message
}
pub const fn comment(self) -> &'static str {
self.comment
}
}
#[macro_export]
macro_rules! marketing_message {
(
pub const $name:ident = {
key: $key:literal,
message: $message:expr,
comment: $comment:literal $(,)?
};
) => {
pub const $name: $crate::i18n::MarketingMessageDescriptor =
$crate::i18n::MarketingMessageDescriptor::new($key, $message, $comment);
};
}
pub mod descriptors;
#[derive(Clone)]
pub struct MarketingI18n {
catalogs: BTreeMap<Locale, Catalog>,
defaults: MarketingDefaults,
}
#[derive(Clone)]
pub struct MarketingDefaults {
pub l10n_email: &'static str,
pub partners_email: &'static str,
pub premium_tier_name: &'static str,
pub product_name: &'static str,
pub social_handle: &'static str,
}
impl Default for MarketingDefaults {
fn default() -> Self {
Self {
l10n_email: "i18n@fluxer.app",
partners_email: "partners@fluxer.app",
premium_tier_name: "Plutonium",
product_name: PRODUCT_NAME,
social_handle: "fluxer.app",
}
}
}
impl MarketingI18n {
pub fn new() -> anyhow::Result<Self> {
let mut catalogs = BTreeMap::new();
for locale in Locale::ALL {
let catalog =
Catalog::parse(Cursor::new(locale.catalog_bytes())).with_context(|| {
format!("failed to parse embedded gettext catalog {}", locale.code())
})?;
catalogs.insert(*locale, catalog);
}
Ok(Self {
catalogs,
defaults: MarketingDefaults::default(),
})
}
pub fn text(&self, locale: Locale, descriptor: MarketingMessageDescriptor) -> String {
self.text_with(locale, descriptor, &[])
}
pub fn text_with(
&self,
locale: Locale,
descriptor: MarketingMessageDescriptor,
vars: &[(&str, &str)],
) -> String {
let template = self.localized_template(locale, descriptor);
self.interpolate(descriptor, template, vars)
}
pub fn template(&self, locale: Locale, descriptor: MarketingMessageDescriptor) -> String {
self.interpolate_defaults(self.localized_template(locale, descriptor))
}
pub fn locale_from_code(&self, code: &str) -> Option<Locale> {
normalize_locale_code(code).and_then(|normalized| {
Locale::ALL.iter().copied().find(|locale| {
normalize_locale_code(locale.code()).as_deref() == Some(normalized.as_str())
})
})
}
pub fn preferred_locale_for_language(&self, language: &str) -> Option<Locale> {
match normalize_locale_code(language).as_deref()? {
"en" => Some(Locale::EnUs),
"es" => Some(Locale::EsEs),
"pt" => Some(Locale::PtBr),
"zh" => Some(Locale::ZhCn),
"sv" => Some(Locale::SvSe),
normalized => Locale::ALL.iter().copied().find(|locale| {
normalize_locale_code(locale.code())
.as_deref()
.map(|code| code.split('-').next().unwrap_or(code) == normalized)
.unwrap_or(false)
}),
}
}
fn interpolate(
&self,
descriptor: MarketingMessageDescriptor,
template: &str,
vars: &[(&str, &str)],
) -> String {
let mut output = self.interpolate_defaults(template);
for (name, value) in vars {
output = output.replace(&format!("{{{name}}}"), value);
}
for placeholder in extract_placeholders(&output) {
tracing::warn!(
key = descriptor.key,
%placeholder,
"marketing translation rendered with an unresolved placeholder",
);
}
output
}
fn localized_template(&self, locale: Locale, descriptor: MarketingMessageDescriptor) -> &str {
let source = descriptor.message;
if locale == Locale::EnUs {
source
} else {
self.catalogs
.get(&locale)
.map(|catalog| catalog.pgettext(descriptor.key, source))
.unwrap_or(source)
}
}
fn interpolate_defaults(&self, template: &str) -> String {
let mut output = template.to_owned();
for (name, value) in self.default_pairs() {
output = output.replace(&format!("{{{name}}}"), value);
}
output
}
fn default_pairs(&self) -> Vec<(&'static str, &'static str)> {
let mut pairs = vec![
("l10n_email", self.defaults.l10n_email),
("partners_email", self.defaults.partners_email),
("premium_tier_name", self.defaults.premium_tier_name),
("product_name", self.defaults.product_name),
("social_handle", self.defaults.social_handle),
];
pairs.extend_from_slice(BRAND_PLACEHOLDERS);
pairs
}
}
pub fn normalize_locale_code(code: &str) -> Option<String> {
let trimmed = code.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.replace('_', "-").to_ascii_lowercase())
}
fn extract_placeholders(input: &str) -> Vec<String> {
let mut result = Vec::new();
let bytes = input.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'{' {
index += 1;
continue;
}
let start = index + 1;
let Some(end_offset) = input[start..].find('}') else {
index += 1;
continue;
};
let end = start + end_offset;
let candidate = &input[start..end];
if is_placeholder_name(candidate) {
result.push(candidate.to_owned());
}
index = end + 1;
}
result
}
fn is_placeholder_name(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return false;
}
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
-33
View File
@@ -1,33 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const PRODUCT_NAME: &str = "Fluxer";
pub const SWISH_BRAND_NAME: &str = "Swish";
pub const SWISH_PAYMENT_MESSAGE: &str = "Fluxer Donation";
pub const BRAND_PLACEHOLDERS: &[(&str, &str)] = &[
("premium_tier_full_name", "Fluxer Plutonium"),
("visionary_tier_name", "Visionary"),
("visionary_tier_full_name", "Fluxer Visionary"),
("bluesky", "Bluesky"),
("github", "GitHub"),
("discord", "Discord"),
("youtube", "YouTube"),
("twitch", "Twitch"),
("swish", SWISH_BRAND_NAME),
("windows", "Windows"),
("macos", "macOS"),
("linux", "Linux"),
("flatpak", "Flatpak"),
("android", "Android"),
("ios", "iOS"),
("ipados", "iPadOS"),
("microsoft", "Microsoft"),
("testflight", "TestFlight"),
("apple_silicon", "Apple Silicon"),
("chrome", "Chrome"),
("operator_pass", "Operator Pass"),
("operators", "Operators"),
("bug_hunter", "Bug Hunter"),
("delorean", "DeLorean"),
("blue_da_ba_dee", "Blue Da Ba Dee"),
];
-17
View File
@@ -1,17 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod config;
pub mod content;
pub mod downloads;
pub mod fonts;
pub mod geoip;
pub mod i18n;
pub mod invariant_text;
pub mod pricing;
pub mod rate_limit;
pub mod request_context;
pub mod routes;
pub mod swish;
pub mod templates;
pub use routes::build_router;
-61
View File
@@ -1,61 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use anyhow::Context;
use fluxer_marketing::{build_router, config::MarketingConfig};
use tokio::{net::TcpListener, runtime::Builder};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let config = MarketingConfig::from_env();
let addr = format!("{}:{}", config.host, config.port);
let router = build_router(config);
let runtime = Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to create Fluxer marketing async runtime")?;
runtime.block_on(async move {
let listener = TcpListener::bind(&addr)
.await
.with_context(|| format!("failed to bind Fluxer marketing service on {addr}"))?;
tracing::info!(%addr, "starting Fluxer marketing service");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await
.context("marketing server exited unexpectedly")
})?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
-135
View File
@@ -1,135 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Currency {
Usd,
Eur,
Brl,
Inr,
Pln,
Try,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PricingTier {
Monthly,
Yearly,
Operator,
}
const EEA_COUNTRIES: &[&str] = &[
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV",
"LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO",
];
impl Currency {
pub const fn code(self) -> &'static str {
match self {
Self::Usd => "USD",
Self::Eur => "EUR",
Self::Brl => "BRL",
Self::Inr => "INR",
Self::Pln => "PLN",
Self::Try => "TRY",
}
}
pub const fn donation_code(self) -> &'static str {
match self {
Self::Usd => "usd",
Self::Eur => "eur",
Self::Brl => "brl",
Self::Inr => "inr",
Self::Pln => "pln",
Self::Try => "try",
}
}
pub const fn symbol(self) -> &'static str {
match self {
Self::Usd => "$",
Self::Eur => "",
Self::Brl => "R$",
Self::Inr => "",
Self::Pln => "",
Self::Try => "",
}
}
}
pub fn get_currency(country_code: &str) -> Currency {
match country_code.to_ascii_uppercase().as_str() {
"BR" => Currency::Brl,
"IN" => Currency::Inr,
"PL" => Currency::Pln,
"TR" => Currency::Try,
code if is_eea_country(code) => Currency::Eur,
_ => Currency::Usd,
}
}
pub fn get_base_currency(country_code: &str) -> Currency {
if is_eea_country(country_code) {
Currency::Eur
} else {
Currency::Usd
}
}
pub fn has_localized_pricing_choice(country_code: &str) -> bool {
matches!(
country_code.to_ascii_uppercase().as_str(),
"BR" | "IN" | "PL" | "TR"
)
}
pub fn is_eea_country(country_code: &str) -> bool {
let upper = country_code.to_ascii_uppercase();
EEA_COUNTRIES.contains(&upper.as_str())
}
pub fn get_price_minor(tier: PricingTier, currency: Currency) -> u32 {
match (tier, currency) {
(PricingTier::Monthly, Currency::Usd) => 499,
(PricingTier::Monthly, Currency::Eur) => 499,
(PricingTier::Monthly, Currency::Brl) => 2499,
(PricingTier::Monthly, Currency::Inr) => 49_900,
(PricingTier::Monthly, Currency::Pln) => 1799,
(PricingTier::Monthly, Currency::Try) => 22_999,
(PricingTier::Yearly, Currency::Usd) => 4999,
(PricingTier::Yearly, Currency::Eur) => 4999,
(PricingTier::Yearly, Currency::Brl) => 24_999,
(PricingTier::Yearly, Currency::Inr) => 499_900,
(PricingTier::Yearly, Currency::Pln) => 17_999,
(PricingTier::Yearly, Currency::Try) => 229_999,
(PricingTier::Operator, Currency::Usd) => 19_900,
(PricingTier::Operator, Currency::Eur) => 19_900,
(PricingTier::Operator, Currency::Brl) => 99_999,
(PricingTier::Operator, Currency::Inr) => 1_899_900,
(PricingTier::Operator, Currency::Pln) => 71_999,
(PricingTier::Operator, Currency::Try) => 899_999,
}
}
pub fn format_major_amount(amount: u32, currency: Currency) -> String {
match currency {
Currency::Usd | Currency::Eur | Currency::Inr | Currency::Try => {
format!("{}{}", currency.symbol(), amount)
}
Currency::Brl | Currency::Pln => format!("{} {}", currency.symbol(), amount),
}
}
pub fn format_price_minor(price_minor: u32, currency: Currency) -> String {
let major = price_minor as f64 / 100.0;
if price_minor.is_multiple_of(100) {
format_major_amount(price_minor / 100, currency)
} else {
match currency {
Currency::Usd | Currency::Eur | Currency::Inr | Currency::Try => {
format!("{}{major:.2}", currency.symbol())
}
Currency::Brl | Currency::Pln => format!("{} {major:.2}", currency.symbol()),
}
}
}
-55
View File
@@ -1,55 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use moka::future::Cache;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
#[derive(Clone)]
pub struct RateLimiter {
buckets: Cache<String, Arc<AtomicU32>>,
max_requests: u32,
}
impl RateLimiter {
pub fn new(max_requests: u32, window: Duration) -> Self {
Self {
buckets: Cache::builder()
.max_capacity(16_384)
.time_to_live(window)
.build(),
max_requests,
}
}
pub async fn check(&self, key: &str) -> bool {
let counter = self
.buckets
.get_with(key.to_owned(), async { Arc::new(AtomicU32::new(0)) })
.await;
let count = counter.fetch_add(1, Ordering::Relaxed).saturating_add(1);
count <= self.max_requests
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn allows_up_to_the_limit_then_blocks() {
let limiter = RateLimiter::new(3, Duration::from_secs(60));
assert!(limiter.check("1.2.3.4").await);
assert!(limiter.check("1.2.3.4").await);
assert!(limiter.check("1.2.3.4").await);
assert!(!limiter.check("1.2.3.4").await);
}
#[tokio::test]
async fn tracks_keys_independently() {
let limiter = RateLimiter::new(1, Duration::from_secs(60));
assert!(limiter.check("a").await);
assert!(!limiter.check("a").await);
assert!(limiter.check("b").await);
}
}

Some files were not shown because too many files have changed in this diff Show More