mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 11:42:32 +09:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002502a7fa | ||
|
|
1997850d55 | ||
|
|
d346eb0e88 | ||
|
|
7c5fa2180a |
@@ -50,6 +50,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ jobs:
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
FLUXER_SELF_HOSTED=true
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||
|
||||
@@ -200,6 +200,7 @@ jobs:
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||
|
||||
@@ -103,11 +103,150 @@ jobs:
|
||||
--step set_matrix
|
||||
--skip-targets "${{ inputs.skip_targets }}"
|
||||
|
||||
shared_assets:
|
||||
name: Build shared renderer assets
|
||||
needs:
|
||||
- meta
|
||||
runs-on: ubuntu-24.04
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.channel }}
|
||||
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout CI helpers
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: _ci
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: source
|
||||
|
||||
- name: Set up Rust toolchain (renderer wasm)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Set workdir (Unix)
|
||||
env:
|
||||
SUBST_TARGET: ${{ github.workspace }}/source
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_workdir_unix
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Set up pnpm
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step setup_pnpm
|
||||
|
||||
- name: Resolve pnpm store path (Unix)
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step resolve_pnpm_store_unix
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-pnpm-store-
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-cargo-registry-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step install_dependencies
|
||||
|
||||
- name: Update version
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step update_version
|
||||
|
||||
- name: Set build channel
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_build_channel
|
||||
|
||||
- name: Build shared renderer assets
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_shared_assets
|
||||
|
||||
- name: Prepare shared renderer artifact
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_shared_assets
|
||||
|
||||
- name: Upload shared renderer artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: source/desktop-shared-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
- name: Split renderer into desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step split_modules
|
||||
|
||||
- name: Pack desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step pack_modules
|
||||
|
||||
- name: Upload desktop module packages
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: |
|
||||
source/desktop-modules/classification.json
|
||||
source/desktop-modules/*/module.json
|
||||
source/desktop-modules/*/package.br
|
||||
source/desktop-modules/*/package.br.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||
needs:
|
||||
- meta
|
||||
- matrix
|
||||
- shared_assets
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
@@ -130,6 +269,7 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
FLUXER_MODULES: "1"
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
ELECTRON_ARCH: ${{ matrix.electron_arch }}
|
||||
@@ -276,6 +416,17 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_build_channel
|
||||
|
||||
- name: Download shared renderer artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: source/desktop-shared-assets
|
||||
|
||||
- name: Restore shared renderer assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step restore_shared_assets
|
||||
|
||||
- name: Build Electron main process
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
@@ -505,11 +656,13 @@ jobs:
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- shared_assets
|
||||
- build
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
@@ -547,6 +700,17 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- name: Download desktop module packages
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: desktop-modules
|
||||
|
||||
- name: Build desktop module manifest
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_module_manifest
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
Generated
+33
-1
@@ -1710,6 +1710,16 @@ 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.12"
|
||||
@@ -1741,6 +1751,7 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"base64 0.23.1",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -1750,6 +1761,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
@@ -2042,7 +2054,6 @@ dependencies = [
|
||||
"hex",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
@@ -4790,6 +4801,17 @@ 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"
|
||||
@@ -5818,6 +5840,16 @@ 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"
|
||||
|
||||
@@ -671,6 +671,7 @@ services:
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
|
||||
@@ -229,10 +229,11 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_channel_threads" => match build_channel_threads_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_channel_threads" => instance_config_result(
|
||||
client
|
||||
.update_instance_config(&build_channel_threads_update(&form))
|
||||
.await,
|
||||
),
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -676,51 +677,26 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
|
||||
})
|
||||
}
|
||||
|
||||
fn build_channel_threads_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("channel_threads_enabled")),
|
||||
guild_basis_points: parse_form_number(
|
||||
form,
|
||||
"channel_threads_guild_basis_points",
|
||||
"Guild rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?,
|
||||
enabled_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_enabled_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Enabled guild IDs",
|
||||
)?),
|
||||
disabled_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_disabled_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Disabled guild IDs",
|
||||
)?),
|
||||
user_basis_points: parse_form_number(
|
||||
form,
|
||||
"channel_threads_user_basis_points",
|
||||
"User rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
user_salt: parse_experiment_rollout_salt(form, "channel_threads_user_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
fn build_channel_threads_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let channel_threads = if form.bool_value("channel_threads_everyone") {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
disabled_guild_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(channel_threads),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
@@ -1489,108 +1465,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_reads_both_rollout_dimensions() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"channel_threads_enabled=true&channel_threads_guild_basis_points=%2010%20&channel_threads_guild_salt=%20channel-threads-guild-v2%20&channel_threads_enabled_guild_ids=1600000000000000001%0A1600000000000000002%0A1600000000000000001&channel_threads_disabled_guild_ids=1600000000000000003&channel_threads_user_basis_points=10000&channel_threads_user_salt=channel-threads-user-v2&channel_threads_included_user_ids=1500000000000000001&channel_threads_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_channel_threads_update(&form)
|
||||
.expect("valid form")
|
||||
.channel_threads
|
||||
.expect("channel threads update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.guild_basis_points, Some(10));
|
||||
fn build_channel_threads_update_turns_threads_on_for_everyone() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true");
|
||||
assert_eq!(
|
||||
update.guild_salt,
|
||||
Some("channel-threads-guild-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_guild_ids,
|
||||
Some(vec![
|
||||
"1600000000000000001".to_owned(),
|
||||
"1600000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.disabled_guild_ids,
|
||||
Some(vec!["1600000000000000003".to_owned()])
|
||||
);
|
||||
assert_eq!(update.user_basis_points, Some(10000));
|
||||
assert_eq!(update.user_salt, Some("channel-threads-user-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_leaves_the_experiment_off_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_channel_threads_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {
|
||||
"enabled": false,
|
||||
"enabled_guild_ids": [],
|
||||
"enabled": true,
|
||||
"guild_basis_points": 10000,
|
||||
"user_basis_points": 10000,
|
||||
"disabled_guild_ids": [],
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_rejects_invalid_targeting() {
|
||||
let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("%2C");
|
||||
for (form, message) in [
|
||||
(
|
||||
"channel_threads_guild_basis_points=10001".to_owned(),
|
||||
"Guild rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"channel_threads_user_basis_points=-1".to_owned(),
|
||||
"User rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"channel_threads_guild_salt=%20%20".to_owned(),
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"channel_threads_user_salt=caf%C3%A9".to_owned(),
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"channel_threads_enabled_guild_ids=123%2Cinvalid".to_owned(),
|
||||
"Enabled guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"channel_threads_disabled_guild_ids=123456789012345678901".to_owned(),
|
||||
"Disabled guild IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"channel_threads_excluded_user_ids=abc".to_owned(),
|
||||
"Excluded user IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
format!("channel_threads_enabled_guild_ids={too_many_guilds}"),
|
||||
"Enabled guild IDs must contain at most 1000 unique IDs",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_channel_threads_update(&form).expect_err("invalid targeting"),
|
||||
message
|
||||
);
|
||||
}
|
||||
fn build_channel_threads_update_only_turns_threads_off_when_unchecked() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
assert_eq!(
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {"enabled": false}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
use crate::{
|
||||
api::types::{
|
||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT,
|
||||
CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
||||
ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
@@ -17,7 +16,6 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
alert::alert_warning,
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
@@ -192,9 +190,13 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
@if !instance_config.self_hosted {
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
}
|
||||
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
|
||||
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
|
||||
@if !instance_config.self_hosted {
|
||||
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
|
||||
}
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1487,182 +1489,41 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
|
||||
)
|
||||
}
|
||||
|
||||
fn channel_threads_available_to_everyone(channel_threads: &ChannelThreadsConfigResponse) -> bool {
|
||||
channel_threads.enabled
|
||||
&& channel_threads.guild_basis_points >= 10_000
|
||||
&& channel_threads.user_basis_points >= 10_000
|
||||
&& channel_threads.disabled_guild_ids.is_empty()
|
||||
&& channel_threads.excluded_user_ids.is_empty()
|
||||
}
|
||||
|
||||
fn channel_threads_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
channel_threads: &ChannelThreadsConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if channel_threads.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let enabled_guild_ids = channel_threads.enabled_guild_ids.join("\n");
|
||||
let disabled_guild_ids = channel_threads.disabled_guild_ids.join("\n");
|
||||
let included_user_ids = channel_threads.included_user_ids.join("\n");
|
||||
let excluded_user_ids = channel_threads.excluded_user_ids.join("\n");
|
||||
let everyone = channel_threads_available_to_everyone(channel_threads);
|
||||
section_card_with_description(
|
||||
"Channel threads",
|
||||
"Threads, forum channels and media channels. A guild gets the feature only when the guild \
|
||||
is selected, and a member sees it only when they are also selected and use a client \
|
||||
that supports threads. Bots follow the guild selection.",
|
||||
"Threads and forums",
|
||||
"Threads, forum channels and media channels in every community.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
(alert_warning("Before enabling", html! {
|
||||
p class="text-sm" {
|
||||
"Enable only after every gateway role, the api, the workers and the \
|
||||
messages service run the gate build and the bit 34-38 overwrite audit \
|
||||
is clean."
|
||||
}
|
||||
}))
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
@if channel_threads.ever_enabled {
|
||||
(badge("Ever enabled", BadgeVariant::Warning))
|
||||
}
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (channel_threads.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"channel_threads_enabled",
|
||||
"channel_threads_everyone",
|
||||
"true",
|
||||
"Turn on threads for the selected guilds and users",
|
||||
channel_threads.enabled,
|
||||
"Available to everyone",
|
||||
everyone,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked no guild \
|
||||
has threads, and existing threads and forums stay stored but hidden \
|
||||
until it is turned back on."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Guilds" }
|
||||
(number_field(
|
||||
"channel_threads_guild_basis_points",
|
||||
"Guild rollout (basis points)",
|
||||
&channel_threads.guild_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of guilds bucketed into the experiment, in basis points: 0 is nobody, 100 is 1%, 10000 is every guild."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"channel_threads_guild_salt",
|
||||
"Guild rollout salt",
|
||||
&channel_threads.guild_salt,
|
||||
CHANNEL_THREADS_DEFAULT_GUILD_SALT,
|
||||
))
|
||||
@if channel_threads.enabled && !everyone {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the guild bucketing hash. Printable ASCII only. Changing it \
|
||||
reshuffles which guilds fall inside the percentage above."
|
||||
"Currently on for part of this instance. Saving applies the setting above to everyone."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_enabled_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1600000000000000001\n1600000000000000002",
|
||||
&enabled_guild_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.enabled_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These guilds are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_disabled_guild_ids",
|
||||
"Never-on Guild IDs",
|
||||
"1600000000000000003\n1600000000000000004",
|
||||
&disabled_guild_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.disabled_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage. This is the per-guild kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Users" }
|
||||
(number_field(
|
||||
"channel_threads_user_basis_points",
|
||||
"User rollout (basis points)",
|
||||
&channel_threads.user_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the experiment, in basis points. Set 10000 before enrolling any guild outside staff, so every member of that guild, moderators included, sees its threads."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"channel_threads_user_salt",
|
||||
"User rollout salt",
|
||||
&channel_threads.user_salt,
|
||||
CHANNEL_THREADS_DEFAULT_USER_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the user bucketing hash. Printable ASCII only. Changing it \
|
||||
reshuffles which users fall inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format as the guild lists. These users are targeted regardless \
|
||||
of the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, and it also applies to bots."
|
||||
}
|
||||
}
|
||||
(alert_warning("Excluded bots", html! {
|
||||
p class="text-sm" {
|
||||
"Excluded bots are blind to threads, including moderation bots."
|
||||
}
|
||||
}))
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save channel threads configuration"))
|
||||
(submit_button("Save"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -2414,49 +2275,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_threads_section_shows_both_dimensions_and_the_warnings() {
|
||||
let channel_threads = ChannelThreadsConfigResponse {
|
||||
fn channel_threads_section_is_a_single_everyone_toggle() {
|
||||
let everyone = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
config_version: 7,
|
||||
ever_enabled: true,
|
||||
guild_basis_points: 25,
|
||||
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
guild_basis_points: 10_000,
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string();
|
||||
assert!(markup.contains("action=update_channel_threads"));
|
||||
for name in [
|
||||
"channel_threads_enabled",
|
||||
"channel_threads_guild_basis_points",
|
||||
"channel_threads_guild_salt",
|
||||
"channel_threads_enabled_guild_ids",
|
||||
"channel_threads_disabled_guild_ids",
|
||||
"channel_threads_user_basis_points",
|
||||
"channel_threads_user_salt",
|
||||
"channel_threads_included_user_ids",
|
||||
"channel_threads_excluded_user_ids",
|
||||
] {
|
||||
assert!(markup.contains(&format!("name=\"{name}\"")), "{name}");
|
||||
}
|
||||
assert!(markup.contains("value=\"25\""));
|
||||
assert!(markup.contains("Config version 7"));
|
||||
assert!(markup.contains("Ever enabled"));
|
||||
assert!(markup.contains("bit 34-38 overwrite audit is clean"));
|
||||
assert!(markup.contains("Excluded bots are blind to threads, including moderation bots."));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
let on = channel_threads_section("/admin", "csrf", &everyone).into_string();
|
||||
assert!(on.contains("action=update_channel_threads"));
|
||||
assert!(on.contains("name=\"channel_threads_everyone\""));
|
||||
assert!(on.contains("checked"));
|
||||
assert!(!on.contains("basis_points"));
|
||||
assert!(!on.contains("part of this instance"));
|
||||
|
||||
let partial = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let partial = channel_threads_section("/admin", "csrf", &partial).into_string();
|
||||
assert!(!partial.contains("checked"));
|
||||
assert!(partial.contains("part of this instance"));
|
||||
|
||||
let off =
|
||||
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
|
||||
.into_string();
|
||||
assert!(off.contains("Inert"));
|
||||
assert!(!off.contains("Ever enabled"));
|
||||
assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT));
|
||||
assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT));
|
||||
assert!(!off.contains("checked"));
|
||||
assert!(!off.contains("part of this instance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -491,13 +491,14 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
|
||||
let app = setup().await;
|
||||
let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await;
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Channel threads"), "{body}");
|
||||
assert!(body.contains("Config version 3"), "{body}");
|
||||
assert!(body.contains("Ever enabled"), "{body}");
|
||||
assert!(body.contains("1600000000000000001"), "{body}");
|
||||
assert!(body.contains("1500000000000000009"), "{body}");
|
||||
assert!(body.contains("Threads and forums"), "{body}");
|
||||
assert!(body.contains("Available to everyone"), "{body}");
|
||||
assert!(
|
||||
body.contains("Excluded bots are blind to threads, including moderation bots."),
|
||||
body.contains(r#"name="channel_threads_everyone""#),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("channel_threads_guild_basis_points"),
|
||||
"{body}"
|
||||
);
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
@@ -509,17 +510,9 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
|
||||
("Cookie", cookie.as_str()),
|
||||
];
|
||||
|
||||
for (form, expected) in [
|
||||
(
|
||||
format!(
|
||||
"_csrf={csrf_token}&channel_threads_enabled=true&channel_threads_guild_basis_points=0&channel_threads_enabled_guild_ids=1600000000000000001&channel_threads_user_basis_points=10000"
|
||||
),
|
||||
"Instance config updated",
|
||||
),
|
||||
(
|
||||
format!("_csrf={csrf_token}&channel_threads_enabled_guild_ids=not-a-guild"),
|
||||
"Enabled guild IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
for form in [
|
||||
format!("_csrf={csrf_token}&channel_threads_everyone=true"),
|
||||
format!("_csrf={csrf_token}"),
|
||||
] {
|
||||
let (status, response_headers, response_body) = post_form_with_headers(
|
||||
&app,
|
||||
@@ -533,7 +526,7 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_else(|| panic!("missing toast header\n{response_body}"));
|
||||
assert!(toast.contains(expected), "{toast}");
|
||||
assert!(toast.contains("Instance config updated"), "{toast}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,17 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
|
||||
allowedHeaders: [
|
||||
HttpHeaders.CONTENT_TYPE,
|
||||
HttpHeaders.AUTHORIZATION,
|
||||
'X-Requested-With',
|
||||
'Accept-Language',
|
||||
HttpHeaders.X_REQUESTED_WITH,
|
||||
HttpHeaders.ACCEPT_LANGUAGE,
|
||||
HttpHeaders.X_REQUEST_ID,
|
||||
HttpHeaders.IF_NONE_MATCH,
|
||||
HttpHeaders.X_AUDIT_LOG_REASON,
|
||||
HttpHeaders.X_CAPTCHA_ID,
|
||||
HttpHeaders.X_CAPTCHA_TOKEN,
|
||||
HttpHeaders.X_FLUXER_CLIENT_INSTALLATION_ID,
|
||||
HttpHeaders.X_FLUXER_FEATURES,
|
||||
HttpHeaders.X_FLUXER_PLATFORM,
|
||||
HttpHeaders.X_FLUXER_SUDO_MODE_JWT,
|
||||
],
|
||||
exposedHeaders: [HttpHeaders.X_FLUXER_VERSION, HttpHeaders.ETAG],
|
||||
},
|
||||
|
||||
@@ -67,6 +67,10 @@ const EXPECTED_SITES: Readonly<Record<string, {count: number; decision: string}>
|
||||
count: 2,
|
||||
decision: UNCHANGED,
|
||||
},
|
||||
'fluxer_app/src/features/gateway/snapshot/SnapshotChannelReducer.ts TEXT_BASED_CHANNEL_TYPES': {
|
||||
count: 1,
|
||||
decision: THREAD_TYPES_SEPARATE,
|
||||
},
|
||||
'fluxer_app/src/features/guild/components/modals/guild_tabs/GuildWebhooksTab.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': {
|
||||
count: 1,
|
||||
decision: WEBHOOK_TARGETS,
|
||||
|
||||
@@ -65,6 +65,7 @@ function buildDiscoveryStaticInput(
|
||||
api_public: apiPublicEndpoint,
|
||||
gateway: Config.endpoints.gateway,
|
||||
media: Config.endpoints.media,
|
||||
upload_relay: Config.mediaProxy.uploadRelay.endpoint,
|
||||
static_cdn: Config.endpoints.staticCdn,
|
||||
marketing: Config.endpoints.marketing,
|
||||
admin: Config.endpoints.admin,
|
||||
|
||||
@@ -33000,6 +33000,7 @@
|
||||
"WellKnownFluxerResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codename": {"description": "Protocol generation spoken by this instance", "type": "string"},
|
||||
"api_code_version": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
@@ -33104,6 +33105,7 @@
|
||||
"description": "Public application configuration for client-side features",
|
||||
"$ref": "#/components/schemas/InstanceAppPublicSchema"
|
||||
},
|
||||
"age_policy": {"$ref": "#/components/schemas/InstanceAgePolicySchema"},
|
||||
"domain_migration": {
|
||||
"description": "Web domain migration switch and anonymous rollout, only acted on by official instance clients",
|
||||
"$ref": "#/components/schemas/DomainMigrationDiscoveryResponse"
|
||||
@@ -33150,6 +33152,19 @@
|
||||
"required": ["enabled", "anonymous_rollout_basis_points", "rollout_salt", "standalone_forwarding"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"InstanceAgePolicySchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"geos": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/InstanceAgePolicyGeoSchema"},
|
||||
"description": "Regions with an age policy"
|
||||
}
|
||||
},
|
||||
"required": ["geos"],
|
||||
"additionalProperties": false,
|
||||
"description": "Regional age policy for this instance"
|
||||
},
|
||||
"InstanceAppPublicSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -33253,7 +33268,23 @@
|
||||
"InstancePushSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"public_vapid_key": {"description": "VAPID public key for web push notifications", "type": ["string", "null"]}
|
||||
"public_vapid_key": {
|
||||
"description": "VAPID public key for web push notifications",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"delivery_mode": {
|
||||
"description": "How push notifications reach devices",
|
||||
"$ref": "#/components/schemas/InstancePushDeliveryModeSchema"
|
||||
},
|
||||
"event_resolution_path": {
|
||||
"description": "API path clients call to resolve the payload of a push event",
|
||||
"type": "string"
|
||||
},
|
||||
"external_relay_url": {"description": "URL of the external push relay, if any", "type": ["string", "null"]},
|
||||
"relay_signing_key_hashes": {
|
||||
"description": "Hashes of the keys the external push relay signs with",
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]
|
||||
}
|
||||
},
|
||||
"required": ["public_vapid_key"],
|
||||
"additionalProperties": false,
|
||||
@@ -33327,7 +33358,8 @@
|
||||
"enabled": {"type": "boolean", "description": "Whether SSO is enabled for this instance"},
|
||||
"enforced": {"type": "boolean", "description": "Whether SSO is required for all users"},
|
||||
"display_name": {"description": "Display name of the SSO provider", "type": ["string", "null"]},
|
||||
"redirect_uri": {"type": "string", "description": "OAuth redirect URI for SSO"}
|
||||
"redirect_uri": {"type": "string", "description": "OAuth redirect URI for SSO"},
|
||||
"available": {"description": "Whether an SSO provider is configured for this instance", "type": "boolean"}
|
||||
},
|
||||
"required": ["enabled", "enforced", "display_name", "redirect_uri"],
|
||||
"additionalProperties": false,
|
||||
@@ -33373,6 +33405,22 @@
|
||||
"description": "Whether the instance sends emails (verification, password reset, etc.)"
|
||||
},
|
||||
"phone_verification_enabled": {"type": "boolean", "description": "Deprecated. Always false."},
|
||||
"desktop_modules_enabled": {
|
||||
"description": "Whether desktop clients may load downloadable modules",
|
||||
"type": "boolean"
|
||||
},
|
||||
"account_deletion_grace_period_hours": {
|
||||
"description": "Hours between a deletion request and the permanent deletion of the account",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 8760
|
||||
},
|
||||
"max_background_gateway_connections": {
|
||||
"description": "Maximum gateway connections a client may keep open for background accounts",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 64
|
||||
},
|
||||
"account_identity": {
|
||||
"description": "How people sign in on this instance. Clients treat a missing value as email",
|
||||
"$ref": "#/components/schemas/AccountIdentityModeSchema"
|
||||
@@ -33415,8 +33463,10 @@
|
||||
"api_public": {"type": "string", "description": "Base URL for public API requests"},
|
||||
"gateway": {"type": "string", "description": "WebSocket URL for the gateway"},
|
||||
"media": {"type": "string", "description": "Base URL for the media proxy"},
|
||||
"upload_relay": {"description": "Base URL for proxied attachment and preview uploads", "type": "string"},
|
||||
"static_cdn": {"type": "string", "description": "Base URL for static assets (avatars, emojis, etc.)"},
|
||||
"marketing": {"type": "string", "description": "Base URL for the marketing website"},
|
||||
"docs": {"description": "Base URL for the documentation website", "type": "string"},
|
||||
"admin": {"type": "string", "description": "Base URL for the admin panel"},
|
||||
"invite": {"type": "string", "description": "Base URL for invite links"},
|
||||
"gift": {"type": "string", "description": "Base URL for gift links"},
|
||||
@@ -33467,6 +33517,29 @@
|
||||
"enum": ["open", "approval", "closed"],
|
||||
"type": "string"
|
||||
},
|
||||
"InstancePushDeliveryModeSchema": {"type": "string", "enum": ["direct", "external_relay", "hybrid"]},
|
||||
"InstanceAgePolicyGeoSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"country_code": {"type": "string", "description": "ISO 3166-1 alpha-2 country code"},
|
||||
"region_code": {
|
||||
"description": "ISO 3166-2 subdivision code, or null for the whole country",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"action": {
|
||||
"description": "Whether the region restricts or blocks access",
|
||||
"$ref": "#/components/schemas/InstanceAgePolicyActionSchema"
|
||||
},
|
||||
"card_verification_available": {
|
||||
"type": "boolean",
|
||||
"description": "Whether card age verification is available in the region"
|
||||
}
|
||||
},
|
||||
"required": ["country_code", "region_code", "action", "card_verification_available"],
|
||||
"additionalProperties": false,
|
||||
"description": "Age policy for one region"
|
||||
},
|
||||
"InstanceAgePolicyActionSchema": {"type": "string", "enum": ["restrict", "block"]},
|
||||
"BotFlags": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
|
||||
+292
-4
@@ -22,13 +22,301 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="display" content="standalone">
|
||||
<link rel="license" href="/assets/fonts-NOTICE.txt">
|
||||
<!--{{FLUXER_BOOTSTRAP}}-->
|
||||
<script>(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script>(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script>(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0||window.location.pathname.indexOf('/oauth2/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||
<link rel="expect" href="#fluxer-boot-end" blocking="render">
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var z=localStorage.getItem('fluxer:accessibility:zoom-preboot');if(z){var p=parseInt(z,10);if(p>=50&&p<=200){document.documentElement.style.setProperty('--custom-zoom',String(p))}}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(window.electron||typeof WebSocket!=='function'||typeof fetch!=='function')return;var P=location.pathname;if(P==='/'||P.indexOf('/migrate/')===0||P.indexOf('/oauth2/')===0)return;var LS=localStorage;if(LS.getItem('fluxer:gateway:preboot:session')!=='1')return;var h=JSON.parse(LS.getItem('fluxer:preboot:network')||'null');if(!h||h.v!==1||typeof h.a!=='string'||h.a!==LS.getItem('fluxer:auth:active-account-key'))return;var X={startedAt:Date.now(),discovery:null,gateway:null};window.__FLUXER_PREBOOT_NETWORK__=X;if(typeof h.d==='string'&&/^https?:\/\//.test(h.d)){var r=fetch(h.d,{method:'GET',headers:{Accept:'application/json'},redirect:'error',credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer'});r.catch(function(){});X.discovery={url:h.d,response:r}}if(typeof h.g==='string'&&/^wss?:\/\//.test(h.g)){var u=new URL(h.g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var ws=new WebSocket(u.toString());ws.binaryType='arraybuffer';var g={url:u.toString(),socket:ws,open:false,messages:[]};ws.onopen=function(){g.open=true};ws.onmessage=function(e){g.messages.push(e)};ws.onclose=ws.onerror=function(){if(X.gateway===g)X.gateway=null};X.gateway=g;setTimeout(function(){if(X.gateway===g){X.gateway=null;try{ws.close(1000,'Preboot gateway unclaimed')}catch(e){}}},30000)}}catch(e){}})()</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{
|
||||
var LS=localStorage,root=document.getElementById('root');if(!root)return;
|
||||
var P=location.pathname,now=Date.now(),UAM=/iPad|iPhone|iPod|Android/i.test(navigator.userAgent||''),MO=UAM||window.innerWidth<640;
|
||||
var rem=parseFloat(getComputedStyle(document.documentElement).fontSize)/16;if(!(rem>0))rem=1;
|
||||
function n(v,a,b){var x=typeof v==='number'&&isFinite(v)?v:a;return x<a?a:x>b?b:x}
|
||||
function AR(v,m){return Array.isArray(v)?v.slice(0,m):null}
|
||||
function RX(p){return Math.round(p/16*1e5)/1e5+'rem'}
|
||||
function JS(k){var v=LS.getItem(k);if(!v)return null;try{return JSON.parse(v)}catch(e){return null}}
|
||||
function FIT(){try{return window.matchMedia('(min-width: 1024px)').matches}catch(e){return window.innerWidth>=1024}}
|
||||
function PWA(){try{return window.matchMedia('(display-mode: standalone)').matches||navigator.standalone===true}catch(e){return false}}
|
||||
function DEF(p,acct){
|
||||
var ch0=p.indexOf('/channels/')===0,sp=p==='/bookmarks'||p==='/mentions';
|
||||
if(!ch0&&!sp)return null;if(p.indexOf('/channels/@discover')===0||p.indexOf('/channels/@premium')===0)return null;
|
||||
var seg=p.split('/'),dmr=p.indexOf('/channels/@me')===0,fav=p.indexOf('/channels/@favorites')===0,r3=ch0&&seg.length===3;
|
||||
var ck='chat',cb=0;
|
||||
if(sp||p==='/channels/@favorites')ck='other';else if(p==='/channels/@me'){ck='other';cb=1}else if(!dmr&&!fav&&/\/members$/.test(p))ck='other';
|
||||
var cid=ch0?(seg[3]||''):'';if(cid==='members')cid='';cid=cid.slice(0,32);
|
||||
var kind=dmr?2:0,dl=!window.electron&&!PWA(),ri=[],i;for(i=0;i<6;i++)ri.push([0,0]);
|
||||
return {v:4,t:now,p:p,a:acct,mo:MO?1:0,ua:UAM?1:0,fs:0,sw:0,sv:(dmr||sp)?'dm':'guild',ck:ck,cb:cb,rf:1,rv:1,ru:[],rti:(dmr||sp)?0:fav?1:-1,ro:0,ri:ri,rgi:-1,rbm:11|(dl?4:0),rbi:-1,rs:0,gb:0,ga:250,gn:0,gd:0,gm:0,sr:0,sam:MO?2:7,ss:1,cid:cid,ch:kind,ct:0,cnw:0,ctw:0,cda:-1,cma:-1,cfv:1,cst:0,cuv:0,ml:(!MO&&FIT()&&cid!==''&&kind===0)?1:0,co:3,com:2,cdv:0,pc:0,pg:16,pf:16,ps:16,pa:0,pt:56,pv:Math.min(20000,Math.max(0,Math.round(window.innerHeight/rem))),nr:[],vh:0,vc:0,mr:(MO&&(p==='/channels/@me'||r3))?1:0,ms:(MO&&(p==='/channels/@me'||r3))?1:0,mn:(MO&&(p==='/channels/@me'||p==='/channels/@favorites'||(r3&&!dmr&&!fav)))?1:0}}
|
||||
function HINT(){
|
||||
var acct=LS.getItem('fluxer:auth:active-account-key')||'',s=JS('fluxer:ui:shell-hint'),k,i;
|
||||
if(!s||s.v!==4||s.a!==acct||typeof s.t!=='number'||!(s.t>now-2592000000)||!(s.t<now+300000)||(s.sv!=='dm'&&s.sv!=='guild')||(s.ck!=='chat'&&s.ck!=='other')||typeof s.p!=='string'||(s.mo===1)!==(s.ua===1||window.innerWidth<640))s=null;
|
||||
var p=P;if(s&&p==='/channels/@me')p=s.p;
|
||||
if(s&&s.p===p){s.src='hint';return s}
|
||||
var d=DEF(p,acct);if(!d)return null;d.src='default';if(!s)return d;
|
||||
d.src='derived';
|
||||
var G=['sw','fs','rf','rv','ru','ro','ri','rbm','rs','vh','vc','nr','co','com','cdv','pc','pg','pf','ps','pa','pt','pv','cfv','cst','cuv','fa','fn'];
|
||||
for(i=0;i<G.length;i++){k=G[i];if(s[k]!==undefined)d[k]=s[k]}
|
||||
var sp=p==='/bookmarks'||p==='/mentions',dmr=p.indexOf('/channels/@me')===0,fav=p.indexOf('/channels/@favorites')===0;
|
||||
d.rti=(dmr||sp)?(d.rf===1?0:-1):fav?(d.rv===1?(d.rf===1?1:0):-1):-1;
|
||||
if(d.sv==='dm'&&s.sv==='dm'){d.sr=s.sr;d.sam=s.sam;d.ss=s.ss;if(s.sb!==undefined)d.sb=s.sb}
|
||||
var gid=p.split('/')[2]||'';
|
||||
if(d.sv==='guild'&&s.sv==='guild'&&gid!==''&&s.p.split('/')[2]===gid){G=['gb','ga','gn','gd','gm','gl','rgi'];for(i=0;i<G.length;i++){k=G[i];if(s[k]!==undefined)d[k]=s[k]}}
|
||||
if(d.ck==='chat'&&s.ck==='chat'&&d.cid!==''&&s.cid===d.cid){G=['ch','ct','cnw','ctw','cda','cma','ml','mem'];for(i=0;i<G.length;i++){k=G[i];if(s[k]!==undefined)d[k]=s[k]}}
|
||||
return d}
|
||||
function ACAT(p){
|
||||
if(p==='/register')return 'register';
|
||||
if(p==='/'||p==='/app'||p==='/login'||p.indexOf('/channels/')===0||p==='/bookmarks'||p==='/mentions'||p==='/notifications'||p==='/you')return 'login';
|
||||
var m=/^\/(invite|gift|theme)\/[^/]+(\/login)?\/?$/.exec(p);if(m)return m[1]+(m[2]?'-login':'');
|
||||
m=/^\/(forgot|reset|verify|authorize-ip|wasntme)(\/|$)/.exec(p);return m?m[1]:null}
|
||||
function AUTH(){
|
||||
var c=ACAT(P);if(c==null)return null;
|
||||
var a=JS('fluxer:ui:auth-shell-hint'),e=a&&a.v===1&&a.c&&typeof a.c==='object'?a.c[c]:null;
|
||||
if(e&&(e.mo===1)===MO&&(e.k==='card'||e.k==='full')&&typeof e.h==='number'&&e.h>0)return {c:c,k:e.k,h:n(e.h,48,4000),w:n(e.w,0,1200),r:-1,src:'hint'};
|
||||
if(c==='login'&&!MO&&document.documentElement.classList.contains('platform-native'))return {c:c,k:'full',h:0,w:0,r:-1,src:'default'};
|
||||
var host=location.hostname||'',pk=c==='login'&&typeof window.PublicKeyCredential==='function'&&host.split('.').length>=2&&!/^[\d.]+$/.test(host)&&host.indexOf(':')<0;
|
||||
if(c==='login')return {c:c,k:'card',h:(MO?132:214)+(pk?46:0),w:0,r:pk?3:2,src:'default'};
|
||||
if(c==='register')return {c:c,k:'card',h:MO?592:674,w:0,r:-1,src:'default'};
|
||||
return {c:c,k:'card',h:MO?320:402,w:0,r:-1,src:'default'}}
|
||||
function WW(){
|
||||
var t={'ar':'مرحبا','bg':'Добре дошли','cs':'Vítejte','da':'Velkommen','de':'Willkommen','el':'Καλώς ήρθατε','en-GB':'Welcome','en-US':'Welcome','es-ES':'Bienvenido','es-419':'Bienvenido','fi':'Tervetuloa','fr':'Bienvenue','he':'ברוכים הבאים','hi':'स्वागत है','hr':'Dobrodošli','hu':'Üdvözöljük','id':'Selamat datang','it':'Benvenuto','ja':'ようこそ','ko':'환영합니다','lt':'Sveiki','nl':'Welkom','no':'Velkommen','pl':'Witaj','pt-BR':'Boas-vindas','ro':'Bine ați venit','ru':'Добро пожаловать','sv-SE':'Välkommen','th':'ยินดีต้อนรับ','tr':'Hoş geldiniz','uk':'Ласкаво просимо','vi':'Chào mừng','zh-CN':'欢迎','zh-TW':'歡迎'};
|
||||
var l=[LS.getItem('locale')].concat(navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language]),i,c,k;
|
||||
for(i=0;i<l.length;i++){c=String(l[i]||'').replace(/"/g,'').replace('_','-').toLowerCase();if(!c)continue;
|
||||
for(k in t)if(k.toLowerCase()===c)return t[k];c=c.split('-')[0];for(k in t)if(k.split('-')[0].toLowerCase()===c)return t[k]}
|
||||
return t['en-US']}
|
||||
function AUTHM(au){
|
||||
var BC='fluxer-boot-',E='</div>',B=function(s,c){return '<div class="'+BC+'block'+(c||'')+'" style="'+s+'"></div>'},i,body='';
|
||||
var head='<div id="fluxer-boot-shell" class="'+BC+'auth" aria-hidden="true" data-auth="'+au.k+'" data-mobile="'+(MO?1:0)+'" data-hint="'+au.src+'" data-category="'+au.c+'" data-height="'+au.h+'">';
|
||||
if(au.k==='full'){
|
||||
return head+'<div class="'+BC+'auth-full"><div class="'+BC+'auth-word"><div class="'+BC+'auth-wtext">'+WW()+E+E+'<div class="'+BC+'auth-foot">'+B('width:10rem;height:2.25rem;',' '+BC+'rm')+E+E+E}
|
||||
var pad=MO?0:Math.max(32*rem,Math.min(0.04*window.innerWidth,44*rem))*2/rem+2,rows=au.r>0?au.r:Math.max(1,Math.min(12,Math.floor((au.h-pad-40)/46)));head=head.slice(0,-1)+' data-rows="'+rows+'">';
|
||||
body+='<div class="'+BC+'auth-title">'+B('width:'+(au.c==='register'?'11rem':'8.5rem')+';height:1.125rem;',' '+BC+'rp '+BC+'s')+E+'<div class="'+BC+'auth-list">';
|
||||
for(i=0;i<rows;i++)body+=B('width:100%;height:2.25rem;',' '+BC+'rm'+(i===0?' '+BC+'auth-primary':' '+BC+'m'));
|
||||
body+=E;
|
||||
var hs='height:'+RX(au.h)+';';
|
||||
if(MO)return head+'<div class="'+BC+'auth-mobile"><div class="'+BC+'auth-mmain" style="'+hs+'">'+body+E+E+E;
|
||||
return head+'<div class="'+BC+'auth-center"><div class="'+BC+'auth-cont"><div class="'+BC+'auth-card" style="'+hs+(au.w>0?'max-width:'+RX(au.w)+';':'')+'"><div class="'+BC+'auth-form">'+body+E+E+E+E+E}
|
||||
var h=null,au=null;
|
||||
var BS=document.documentElement.getAttribute('data-boot-session');
|
||||
var SM=LS.getItem('fluxer:gateway:preboot:session');
|
||||
function SV(k){var v=LS.getItem(k);return !!v&&v!=='undefined'&&v!=='null'}
|
||||
if(BS!=='0'&&(BS==='1'||SM==='1'||(SM===null&&SV('token')&&SV('userId'))))h=HINT();else au=AUTH();
|
||||
if(h==null&&au==null)return;
|
||||
function APP(h){
|
||||
var mo=h.mo===1;
|
||||
function SEED(k){var s=2166136261,i;for(i=0;i<k.length;i++){s^=k.charCodeAt(i);s=Math.imul(s,16777619)}s=s>>>0;var t=s||2654435769;return function(){t^=t<<13;t^=t>>>17;t^=t<<5;return(t>>>0)/4294967296}}
|
||||
var E='</div>',BC='fluxer-boot-';
|
||||
function B(s,c){return '<div class="'+BC+'block'+(c||'')+'" style="'+s+'"></div>'}
|
||||
function V(c,i,s){return '<div class="'+BC+c+'"'+(s?' style="'+s+'"':'')+'>'+i+E}
|
||||
function PZ(w,g){return 'width:'+w+';height:'+g+';'}
|
||||
var S=' '+BC+'s',M=' '+BC+'m',R0=' '+BC+'r0',RS=' '+BC+'rs',RM=' '+BC+'rm',RL=' '+BC+'rl',RP=' '+BC+'rp',RC=' '+BC+'rc',CI=' '+BC+'circle';
|
||||
var GI={
|
||||
c:'M232,128A104,104,0,0,1,79.12,219.82L45.07,231.17a16,16,0,0,1-20.24-20.24l11.35-34.05A104,104,0,1,1,232,128Z',
|
||||
s:'M234.29,114.85l-45,38.83L203,211.75a16.4,16.4,0,0,1-24.5,17.82L128,198.49,77.47,229.57A16.4,16.4,0,0,1,53,211.75l13.76-58.07-45-38.83A16.46,16.46,0,0,1,31.08,86l59-4.76,22.76-55.08a16.36,16.36,0,0,1,30.27,0l22.75,55.08,59,4.76a16.46,16.46,0,0,1,9.37,28.86Z',
|
||||
o:'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm51.58,57.79-32,64a4.08,4.08,0,0,1-1.79,1.79l-64,32a4,4,0,0,1-5.37-5.37l32-64a4.08,4.08,0,0,1,1.79-1.79l64-32A4,4,0,0,1,179.58,81.79Z',
|
||||
p:'M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z',
|
||||
d:'M228,144v64a12,12,0,0,1-12,12H40a12,12,0,0,1-12-12V144a12,12,0,0,1,24,0v52H204V144a12,12,0,0,1,24,0Zm-108.49,8.49a12,12,0,0,0,17,0l40-40a12,12,0,0,0-17-17L140,115V32a12,12,0,0,0-24,0v83L96.49,95.51a12,12,0,0,0-17,17Z',
|
||||
q:'M196,96c0,29.47-24.21,54.05-56,59.06V156a12,12,0,0,1-24,0V144a12,12,0,0,1,12-12c24.26,0,44-16.15,44-36s-19.74-36-44-36S84,76.15,84,96a12,12,0,0,1-24,0c0-33.08,30.5-60,68-60S196,62.92,196,96Zm-68,92a20,20,0,1,0,20,20A20,20,0,0,0,128,188Z'};
|
||||
function SVG(k,z){return '<svg class="'+BC+'glyph-'+z+'" viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="'+GI[k]+'"/></svg>'}
|
||||
var NT=[['#4e5058','#ffffff'],['#4285f4','#ffffff'],['#1d4ed8','#ffffff'],['#9a3412','#ffffff'],['#166534','#ffffff'],['var(--brand-primary)','var(--text-on-brand-primary)'],['var(--status-danger)','var(--text-on-brand-primary)'],['#ea580c','#ffffff'],['#f97316','#ffffff'],['#4338ca','#ffffff'],['#15803d','#ffffff'],['#b00000','#ffffff'],['#17231d','#72e6a2'],['#5865f2','#ffffff'],['#b45309','#ffffff']];
|
||||
var rail=!mo||h.mr===1,ua=!mo&&h.fs!==1,bn=mo&&h.mn===1,sd=!mo||h.ms===1,cc=!(mo&&h.ms===1),gv=h.sv==='guild';
|
||||
var canFit=FIT();
|
||||
var sw=n(h.sw,0,4096),vh=n(h.vh,0,512),vp=vh+'px';
|
||||
var sty='';if(!mo&&sw>=200&&sw<=480)sty+='--layout-sidebar-width:'+RX(sw)+';';if(ua)sty+='--layout-voice-connection-height:'+vp+';';
|
||||
var i,j,m='';
|
||||
function IND(sel){return V('indicator','<div class="'+BC+'indicator-bar" style="height:'+(sel?'2.5rem':'0.5rem')+';opacity:1"></div>')}
|
||||
function selAttr(k){return k?' data-selected="1"':''}
|
||||
if(rail){
|
||||
var rf=h.rf===1,rv=h.rv===1,ru=AR(h.ru,16)||[],idm=ru.length,rti=n(h.rti,-1,99);
|
||||
var ro=h.ro===1,ri=AR(h.ri,24)||[],rbm=n(h.rbm,0,15);
|
||||
var bD=(rbm&1)!==0,bA=(rbm&2)!==0,bW=(rbm&4)!==0,bH=(rbm&8)!==0;
|
||||
var rgi=n(h.rgi,-1,99),rbi=n(h.rbi,-1,99);
|
||||
var topRows=(rf?1:0)+(rv?1:0)+idm,botRows=(bD?1:0)+(bA?1:0)+(bW?1:0)+(bH?1:0);
|
||||
var hasItems=ro||ri.length>0,hasBottom=bD||bA||bW||bH;
|
||||
var gapAfterTop=rf&&!rv&&idm===0,flush=ro&&ri.length===0&&hasBottom;
|
||||
var gapAfterGuilds=bH?false:(bD||bA||bW)?true:ri.length>0;
|
||||
var vrows=topRows+(ro?1:0)+botRows,ci=0;
|
||||
for(i=0;i<ri.length;i++){vrows+=(ri[i]&&ri[i][0]===2)?n(ri[i][2],0,23)+1:1}
|
||||
var rs=n(h.rs,0,20000)*rem,cap=(vrows*3.375+0.8125)*rem-document.documentElement.clientHeight;
|
||||
rs=rs>0?Math.min(rs,Math.max(0,cap)):0;
|
||||
function ITEM(sel,inner){return '<div class="'+BC+'item"'+selAttr(sel)+'>'+(sel?IND(true):'')+inner+E}
|
||||
var top='';
|
||||
if(rf)top+=ITEM(rti===0,V('fluxer-icon',SVG('c','lg')));
|
||||
if(rv)top+=ITEM(rti===(rf?1:0),V('fluxer-icon',SVG('s','lg')));
|
||||
for(i=0;i<idm;i++){var di=(rf?1:0)+(rv?1:0)+i,dsel=rti===di,dun=ru[i]===1;
|
||||
top+='<div class="'+BC+'item"'+selAttr(dsel)+'>'+((dsel||dun)?IND(dsel):'')+B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+CI)+E}
|
||||
var items='';
|
||||
if(ro)items+=V('slot '+BC+'slot-outage',B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+M+CI));
|
||||
for(i=0;i<ri.length;i++){var it=ri[i]||[],kd=n(it[0],0,2),ind=n(it[1],0,2),sel=i===rgi,show=sel||ind!==0;
|
||||
if(kd===0){items+='<div class="'+BC+'slot"'+selAttr(sel)+'>'+(show?IND(sel):'')+B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+CI)+E}
|
||||
else if(kd===1){var cn=n(it[2],0,4),mini='';
|
||||
if(it[3]===1)mini=B(PZ('1.5rem','1.5rem'),RM);
|
||||
else{for(j=0;j<cn;j++)mini+=B(PZ('100%','100%'),R0+' '+BC+'folder-mini');mini=V('folder-grid',mini)}
|
||||
items+='<div class="'+BC+'slot '+BC+'slot-folder"'+selAttr(sel)+'>'+(show?IND(sel):'')+V('folder',B(PZ('var(--guild-list-item-box-size)','var(--guild-list-item-box-size)'),R0+M+' '+BC+'folder-bg')+mini)+E}
|
||||
else{var cn2=n(it[2],0,23),sc=n(it[3],-1,23),kids='';
|
||||
for(j=0;j<cn2;j++){var ki=n(it[4+j],0,2),ks=j===sc;kids+='<div class="'+BC+'folder-guild"'+selAttr(ks)+'>'+((ks||ki!==0)?IND(ks):'')+B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+CI)+E}
|
||||
items+=V('folder-slot',V('folder-open',B('width:calc(var(--guild-folder-expanded-surface-size) + 0.25rem);height:0.75rem;',R0+M+' '+BC+'folder-open-bg')+V('folder-head',B(PZ('var(--guild-list-item-box-size)','var(--guild-list-item-box-size)'),R0+' '+BC+'folder-head-surface'))+V('folder-guilds',kids)))}}
|
||||
function ACT(k,sel){return '<div class="'+BC+'item"'+selAttr(sel)+'>'+V('action-icon',SVG(k,'sm'))+E}
|
||||
var bot='';
|
||||
if(hasItems)bot+='<div class="'+BC+'rail-items'+(flush?' '+BC+'rail-items-flush':'')+'">'+items+E;
|
||||
if(bD)bot+=ACT('o',rbi===0);if(bA)bot+=ACT('p',false);if(bW)bot+=ACT('d',false);if(bH)bot+=ACT('q',false);
|
||||
m+=V('rail',V('rail-scroller',V('rail-content',
|
||||
'<div class="'+BC+'rail-section'+(gapAfterTop?' '+BC+'rail-section-gap':'')+'">'+top+E+
|
||||
'<div class="'+BC+'rail-divider-slot"'+(hasItems?' data-extended="1"':'')+'>'+V('rail-divider','')+E+
|
||||
'<div class="'+BC+'rail-section'+(gapAfterGuilds?' '+BC+'rail-section-gap':'')+'">'+bot+E,
|
||||
rs>0?'margin-top:-'+rs+'px':null)))}
|
||||
var body='';
|
||||
if(sd){
|
||||
if(gv){
|
||||
var gb=n(h.gb,0,2),ab='aspect-ratio:'+n(h.ga,250,8000)/1000,gn=n(h.gn,0,4096),gd=h.gd===1;
|
||||
var hdr=(gd?B(PZ('1.25rem','1.25rem'),RS+M):'')+B(PZ(gn>0?RX(gn):'55%','0.75rem'),RP+S)+B(PZ(mo?'1.5rem':'1rem',mo?'1.5rem':'1rem'),RS+M+' '+BC+'gsb-caret');
|
||||
var frame='<div class="'+BC+'gsb-frame"'+(gb===1?' data-banner="1" style="'+ab+'"':'')+'>'+(gb===1?B('width:100%;height:100%;',R0+M+' '+BC+'gsb-banner'):'')+V('gsb-header',hdr)+E;
|
||||
var gl=AR(h.gl,16),cats=[],rnd=SEED('guild-sidebar-skeleton-channels');
|
||||
if(gl==null){var FB=[3,5,4,6];for(i=0;i<4;i++){var chs=[],cw=(28+rnd()*24)+'%';for(j=0;j<FB[i];j++)chs.push((34+rnd()*40)+'%');cats.push([true,cw,chs])}}
|
||||
else{for(i=0;i<gl.length;i++){var g=gl[i]||[],gw=(28+rnd()*24)+'%',gc=AR(g[2],64)||[],chs2=[];
|
||||
for(j=0;j<gc.length;j++){var fw=(34+rnd()*40)+'%',mw=n(gc[j],0,4096);chs2.push(mw>0?RX(mw):fw)}
|
||||
var cmw=n(g[1],0,4096);cats.push([g[0]===1,cmw>0?RX(cmw):gw,chs2])}}
|
||||
var nav='';
|
||||
if(gb===2)nav+=V('detached-banner',B('width:100%;height:100%;',R0+M),ab);
|
||||
nav+=V('top-spacer','');
|
||||
if(h.gm===1)nav+=V('members-section',V('grow '+BC+'grow-ch '+BC+'grow-mem',B(PZ('1.25rem','1.25rem'),RS+M+' '+BC+'grow-icon')+B(PZ('4rem','0.75rem'),RP)))+V('members-sep','');
|
||||
var groups='';
|
||||
for(i=0;i<cats.length;i++){var ct=cats[i],grp='';
|
||||
if(ct[0])grp+=V('grow '+BC+'grow-cat',B(PZ(ct[1],'0.625rem'),RP+M)+B(PZ('0.75rem','0.75rem'),RS+M+' '+BC+'grow-caret'));
|
||||
for(j=0;j<ct[2].length;j++)grp+=V('grow '+BC+'grow-ch',B(PZ('1.25rem','1.25rem'),RS+M+' '+BC+'grow-icon')+B(PZ(ct[2][j],'0.75rem'),RP));
|
||||
groups+=V('group',grp)}
|
||||
nav+=V('groups',groups);
|
||||
if(cats.length>0)nav+=V('drop-zone','');
|
||||
body+=V('gsb',frame+V('gsb-content',V('nav',nav)+V('sb-bottom-spacer','')))}
|
||||
else{
|
||||
var sr=n(h.sr,0,40),sb=AR(h.sb,40),sam=n(h.sam,0,7),drnd=SEED('dm-sidebar-skeleton-channels'),chans=[];
|
||||
for(i=0;i<sr;i++){var nw=(42+drnd()*42)+'%',hs=drnd()<0.45;if(sb!=null)hs=sb[i]===1;chans.push([nw,hs?(32+drnd()*38)+'%':null])}
|
||||
var acts=[];if((sam&1)!==0)acts.push('4.5rem');if((sam&2)!==0)acts.push('7.75rem');if((sam&4)!==0)acts.push('5.75rem');
|
||||
function AROW(w){return V(mo?'dmrow':'drow',V('drow-content',B(PZ(mo?'2.5rem':'2rem',mo?'2.5rem':'2rem'),RC+M)+B(PZ(w,'0.75rem'),RP)))}
|
||||
function CROW(c){return V(mo?'dmrow':'drow',V('drow-content',B(PZ(mo?'2.5rem':'2rem',mo?'2.5rem':'2rem'),RC)+V('chinfo',V('chname',B(PZ(c[0],'0.75rem'),RP))+(c[1]?V('chsub',B(PZ(c[1],'0.5rem'),RP+M)):''))+(mo?B(PZ('1.5rem','0.5rem'),RP+M):'')))}
|
||||
var rows='';
|
||||
if(mo){
|
||||
for(i=0;i<acts.length;i++)rows+=AROW(acts[i]);
|
||||
for(i=0;i<chans.length;i++)rows+=CROW(chans[i]);
|
||||
rows+=V('spacer','');
|
||||
body+=V('dsb',V('dsb-mheader',B(PZ('5.5rem','0.875rem'),RP+S)+V('dsb-mactions',B(PZ('2rem','2rem'),RC+M)+B(PZ('7.5rem','2.25rem'),RP+M)))+V('scroll',V('dsb-mcontent',rows))+B(PZ('3.5rem','3.5rem'),RC+S+' '+BC+'fab'))}
|
||||
else{
|
||||
rows+=V('spacer','');
|
||||
for(i=0;i<acts.length;i++)rows+=AROW(acts[i]);
|
||||
if(h.ss===1)rows+=V('dsb-sep','')+V('dsb-section',B(PZ('6rem','0.625rem'),RP+M)+B(PZ('1rem','1rem'),RS+M));
|
||||
var cl='';for(i=0;i<chans.length;i++)cl+=CROW(chans[i]);
|
||||
rows+=V('chlist',cl)+V('sb-bottom-spacer','');
|
||||
body+=V('dsb',V('dsb-header',B(PZ('100%','1.75rem'),RM+M))+V('scroll',V('dsb-content',rows)))}}}
|
||||
var content='';
|
||||
if(cc&&h.ck!=='chat'&&h.cb===1){
|
||||
var FT=['4.5rem','2.75rem','5rem','6.25rem'],tabs='';
|
||||
for(i=0;i<4;i++)tabs+=B(PZ(FT[i],'2rem'),RM);
|
||||
var fl=(mo?'':V('fr-title',B(PZ('1.5rem','1.5rem'),RS+S)+B(PZ('5.5rem','0.875rem'),RP+S))+V('fr-div',''))+V('fr-tabs',V('fr-tabsi',tabs));
|
||||
var fh=V('chat-header',V('identity',B(PZ('2rem','2rem'),RC+M+' '+BC+'identity-back'+(mo?'':' '+BC+'identity-back-desktop'))+V('fr-left',fl))+V('header-actions',mo?'':B(PZ('2rem','2rem'),RC+M)));
|
||||
var frn=Math.min(24,Math.max(1,Math.ceil(window.innerHeight/60))),frr=SEED('friends-skeleton-rows'),frows='';
|
||||
for(i=0;i<frn;i++)frows+=V('fr-row',V('fr-rinfo',B(PZ('2.25rem','2.25rem'),RC)+V('fr-rdet',V('fr-rname',B(PZ((28+frr()*34)+'%','0.75rem'),RP+S))+V('fr-rsub',B(PZ((18+frr()*26)+'%','0.625rem'),RP+M))))+V('fr-ract',B(PZ('2.25rem','2.25rem'),RC+M)+B(PZ('2.25rem','2.25rem'),RC+M)));
|
||||
var fa=h.fa!==0,fn=Array.isArray(h.fn)?h.fn.slice(0,24):[],anr=SEED('friends-skeleton-active-now'),side='';
|
||||
if(fa){var scn='';
|
||||
for(i=0;i<fn.length;i++){var fc=fn[i],pn=Array.isArray(fc)?n(fc[0],0,99):0,st=Array.isArray(fc)&&fc[1]===1,pcol=Math.min(pn,5)+(pn>5?1:0);
|
||||
scn+=V('fr-card',V('fr-cg',V('fr-cst',B(PZ(st?'4.75rem':'3.75rem','0.625rem'),RP+S))+V('fr-cctx',B(PZ((6+anr()*5)+'rem','0.625rem'),RP+M)))+(pn>0?V('fr-cp',B(PZ(RX(21+Math.max(0,pcol-1)*13),'1.25rem'),RP+M)):'')+V('fr-ca',B(PZ('1rem','1rem'),RS+M)+B(PZ('4.75rem','0.625rem'),RP+M)))}
|
||||
side=V('fr-side',V('fr-shd',B(PZ('6.5rem','1.125rem'),RP+S))+(fn.length?V('fr-scn',scn):V('fr-empty',B(PZ('3rem','3rem'),RM+M)+V('fr-etitle',B(PZ('9rem','0.8125rem'),RP+S))+V('fr-edesc',V('fr-eline',B(PZ('100%','0.6875rem'),RP+M))+V('fr-eline',B(PZ('68%','0.6875rem'),RP+M))))))}
|
||||
content=V('friends',V('fr-main',fh+V('fr-body',V('fr-srch',B(PZ('100%','2.75rem'),RL+M))+V('fr-tbody',V('fr-list',V('fr-sect',B(PZ('7rem','0.625rem'),RP+M))+V('fr-rows',frows)))))+side)}
|
||||
if(cc&&h.ck==='chat'){
|
||||
var ch=n(h.ch,0,4),isDM=ch===2,isGDM=ch===3,isNotes=ch===4;
|
||||
var cnw=n(h.cnw,0,4096),ctw=n(h.ctw,0,4096),topic=h.ct===1;
|
||||
var fav=h.cfv===1?1:0,dflt=isNotes?[0,0,false]:isDM?[4+fav,2+fav,true]:isGDM?[5+fav,2+fav,true]:[3+fav,1+fav,true];
|
||||
var lead=n(h.cda,-1,32),mob=n(h.cma,-1,32);
|
||||
if(lead<0)lead=dflt[0];if(mob<0)mob=dflt[1];
|
||||
var persist=(h.cst===1?1:0)+(h.cuv===1?1:0);
|
||||
var idn=(isDM||isGDM)?B(PZ('2rem','2rem'),RC):B(PZ('var(--channel-header-icon-size)','var(--channel-header-icon-size)'),RS+M);
|
||||
var tail='';
|
||||
if(mo)tail=B(PZ('var(--channel-header-caret-size)','var(--channel-header-caret-size)'),RS+M+' '+BC+'identity-caret');
|
||||
else if(topic)tail=B(PZ('0.25rem','0.25rem'),RC+M+' '+BC+'topic-divider')+B(PZ(ctw>0?RX(ctw):'18rem','0.6875rem'),RP+M);
|
||||
var ident=V('identity-back'+(mo?'':' '+BC+'identity-back-desktop'),B(PZ('var(--channel-header-back-icon-size)','var(--channel-header-back-icon-size)'),RS+M))+idn+B(PZ(cnw>0?RX(cnw):'7.5rem','0.875rem'),RP+S+' '+BC+'identity-name'+((!mo&&isGDM)?' '+BC+'identity-name-gdm':''))+tail;
|
||||
var acts2='';
|
||||
if(mo){for(i=0;i<mob;i++)acts2+=B(PZ('var(--channel-header-action-size-mobile)','var(--channel-header-action-size-mobile)'),RC+M)}
|
||||
else{
|
||||
function HSLOT(){return V('header-action',B(PZ('var(--channel-header-action-icon-size)','var(--channel-header-action-icon-size)'),RS+M))}
|
||||
for(i=0;i<lead;i++)acts2+=HSLOT();
|
||||
if(dflt[2])acts2+=V('header-search',B(PZ('var(--message-search-bar-icon-size)','var(--message-search-bar-icon-size)'),RS+M+' '+BC+'header-search-icon')+B(PZ('7rem','0.6875rem'),RP+M));
|
||||
for(i=0;i<persist;i++)acts2+=HSLOT();
|
||||
acts2+=HSLOT()}
|
||||
var chead=V('chat-header',V('identity',ident)+V('header-actions',acts2));
|
||||
var cbody='';
|
||||
{
|
||||
var pc=h.pc===1,pg=n(h.pg,0,256),pf=n(h.pf,8,64),ps=n(h.ps,0,256),pa=h.pa===1,pt=n(h.pt,0,256),pv=n(h.pv,0,20000);
|
||||
var seed=typeof h.cid==='string'&&h.cid?h.cid:'message-list-placeholder';
|
||||
var mr2=SEED(seed+'|'+(pc?'1':'0')+'|'+ps);
|
||||
var avg=pc?26*2.5:48+26*1.5,rws=Math.ceil(Math.max(pv,400)/avg);
|
||||
var gc2=Math.min(30,Math.max(10,Math.ceil(1.05*rws)));if(!pc)gc2=Math.round(0.87*gc2);
|
||||
var att=Math.min(gc2,Math.max(1,Math.round(gc2/30*8)));
|
||||
var gs=[],th=28;
|
||||
for(i=0;i<gc2;i++){var lc=Math.floor(mr2()*4)+1,lw=[];
|
||||
for(j=0;j<lc;j++)lw.push(75+mr2()*18);
|
||||
var gh=pc?26*lc:48+26*(lc-1);
|
||||
gs.push({w:lw,u:48+mr2()*36,s:8+mr2()*12,a:null,h:gh});
|
||||
if(i>0)th+=ps;th+=gh}
|
||||
var avail=[];for(i=0;i<gs.length;i++)avail.push(i);
|
||||
for(i=0;i<att&&avail.length>0;i++){var gx=avail.splice(Math.floor(mr2()*avail.length),1)[0];
|
||||
var aw=Math.floor(mr2()*261)+140,ah=Math.floor(mr2()*221)+100;
|
||||
gs[gx].a=[aw,ah];gs[gx].h+=ah+8;th+=ah+8}
|
||||
var fill='';
|
||||
for(i=0;i<gs.length;i++){var G2=gs[i],mb=i===gs.length-1?'0':RX(ps),inner='';
|
||||
if(pc){var tw=Math.min(78,55+G2.s)+'%',uw=Math.min(32,G2.u*0.45)+'%';
|
||||
var uline=B(PZ(uw,'0.75rem'),RP+S+' '+BC+'cuser');
|
||||
var pre=pa?V('cauthor',B(PZ('var(--message-avatar-size-compact)','var(--message-avatar-size-compact)'),RC+' '+BC+'cavatar')+uline):uline;
|
||||
var cm2='';
|
||||
for(j=0;j<G2.w.length;j++)cm2+=V('crow',V('cts',B(PZ(tw,'0.625rem'),RP+M))+V('cbody',(j===0?pre:'')+B(PZ(G2.w[j]+'%','0.75rem'),RP+' '+BC+'cline')));
|
||||
inner=V('cmsgs',cm2)+(G2.a?V('cattach',B(PZ(RX(G2.a[0]),RX(G2.a[1])),RL+M)):'')}
|
||||
else{var cont='';
|
||||
for(j=1;j<G2.w.length;j++)cont+=V('mcont',B(PZ(G2.w[j]+'%','0.75rem'),RP+' '+BC+'mcont-line'));
|
||||
inner=V('mrow',B(PZ('var(--message-avatar-size)','var(--message-avatar-size)'),RC+' '+BC+'mavatar')+V('mbody',V('mhead',B(PZ(G2.u+'%','0.75rem'),RP+S)+B(PZ(G2.s+'%','0.625rem'),RP+M))+V('mlead',B(PZ(G2.w[0]+'%','0.75rem'),RP))))+cont+(G2.a?V('mattach',B(PZ(RX(G2.a[0]),RX(G2.a[1])),RL+M)):'')}
|
||||
fill+=V('mgroup',inner,'margin-bottom:'+mb)}
|
||||
var mlist=V('messages','<div class="'+BC+'fill'+(pc?' '+BC+'fill-compact':'')+'" style="height:'+RX(th)+'">'+fill+E);
|
||||
var cnt=mo?n(h.com,0,8):n(h.co,0,8),dvi=(!mo&&h.cdv===1)?cnt-1:-1,cbtn='';
|
||||
for(i=0;i<cnt;i++){if(i===dvi)cbtn+=V('composer-divider','');
|
||||
cbtn+=V('composer-action',B(PZ('var(--textarea-button-icon-size)','var(--textarea-button-icon-size)'),RM+M))}
|
||||
var comp='<div class="'+BC+'composer'+(mo?' '+BC+'composer-mobile':' '+BC+'composer-row')+'">'+V('composer-section',V('composer-grid',
|
||||
V('composer-upload',V('composer-action',B(PZ('var(--textarea-button-icon-size)','var(--textarea-button-icon-size)'),RM+M)))+
|
||||
V('composer-input',V('composer-line',B(PZ('11rem','0.875rem'),RP+M)))+
|
||||
V('composer-buttons',cbtn)))+E;
|
||||
var showML=h.ml===1&&!mo&&canFit;
|
||||
cbody=(showML?V('member-divider',''):'')+V('chat-area',V('chat-column',mlist+comp));
|
||||
if(showML){
|
||||
var mem=AR(h.mem,8),gdm=isGDM,defs=gdm?[['4.5rem',5],['4rem',5]]:[['5rem',7],['6.25rem',9],['4.5rem',8]],reqs=[];
|
||||
if(mem==null)reqs=defs;
|
||||
else{for(i=0;i<mem.length;i++){var mg=mem[i]||[],hw=n(mg[1],0,4096);reqs.push([hw>0?RX(hw):defs[i%defs.length][0],n(mg[0],0,50),mg.slice(2)])}}
|
||||
var mlh='',ridx=0;
|
||||
for(i=0;i<reqs.length;i++){var rq=reqs[i],rr='';
|
||||
for(j=0;j<rq[1];j++){var rr2=SEED('member-list-skeleton|'+(gdm?'group-dm':'guild')+'|'+ridx),nw2=40+rr2()*40,sw2=30+rr2()*50;ridx++;
|
||||
var subv=gdm?(rq[2]&&rq[2][j]===1):true;
|
||||
rr+=V('mlrow'+(gdm?' '+BC+'mlrow-gdm':''),V('mlrc',B(PZ('2rem','2rem'),RC)+V('mlinfo',V('mlname',B(PZ(nw2+'%','0.625rem'),RP))+(subv?V('mlstatus',B(PZ(sw2+'%','0.5rem'),RP+M)):''))))}
|
||||
mlh+=V('mlgroup'+(gdm?' '+BC+'mlgroup-gdm':''),V('mlhead',B(PZ(rq[0],'0.625rem'),RP+M))+V('mlrows'+(gdm?' '+BC+'mlrows-gdm':''),rr))}
|
||||
cbody+=V('member-list'+(gdm?' '+BC+'member-list-gdm':''),V('member-scroller',V('mlgroups',mlh,'--member-list-item-height:2.75rem;--member-list-item-height-group-dm:2.5rem;--member-list-group-header-height:2rem')))}
|
||||
content=V('chat',chead+V('chat-body',cbody),'--chat-horizontal-padding:'+RX(pg)+';--font-size:'+RX(pf)+';--message-group-spacing:'+RX(ps)+';--message-compact-timestamp-width:'+RX(pt))}}
|
||||
var nr=AR(h.nr,3)||[],nag='';
|
||||
for(i=0;i<nr.length;i++){var nrow=nr[i]||[],tn=NT[n(nrow[0],0,14)],hact=nrow[1]===1,dis=nrow[2]===1;
|
||||
nag+='<div class="'+BC+'nagbar"'+(dis?' data-dismissible="1"':'')+(hact?' data-actions="1"':'')+' style="background-color:'+tn[0]+';color:'+tn[1]+'">'+
|
||||
B(PZ(mo?'min(12rem, 72%)':'min(18rem, 60%)','0.75rem'),RP+M+' '+BC+'tinted')+
|
||||
((mo&&hact)?B(PZ('6rem','2rem'),RM+M+' '+BC+'tinted'):'')+E}
|
||||
m+=V('content',V('frame',(nag?V('nagbars',nag):'')+V('frame-body',(mo?'':V('sidebar-divider',''))+V('frame-inner',V('main',(mo?'':body)+V('column',mo?(body||content):content))))));
|
||||
if(ua)m+=V('user-area',V('user-panel',(vh>0?V('voice-panel','','height:'+vp):'')+V('user-row',V('user-identity',B(PZ('2rem','2rem'),RC+S)+V('user-text',V('user-name',B(PZ('6.5rem','0.75rem'),RP))+V('user-status',B(PZ('4.25rem','0.5rem'),RP+M))))+V('user-controls',B(PZ('2rem','2rem'),RM+M)+B(PZ('2rem','2rem'),RM+M)+B(PZ('2rem','2rem'),RM+M)))));
|
||||
if(bn){var nav2=[['b','2.25rem']];if(h.vc===1)nav2.push(['b','2.5rem']);nav2.push(['b','4rem'],['a','1.5rem']);var nvi='';
|
||||
for(i=0;i<nav2.length;i++)nvi+=V('nav-item',B(PZ('1.5rem','1.5rem'),nav2[i][0]==='a'?RC:RS)+V('nav-label',B(PZ(nav2[i][1],'0.5rem'),RP+M)));
|
||||
m+=V('bottom-nav',nvi)}
|
||||
return '<div id="fluxer-boot-shell" aria-hidden="true" data-mobile="'+(mo?1:0)+'" data-rail="'+(rail?1:0)+'" data-user-area="'+(ua?1:0)+'" data-bottom-nav="'+(bn?1:0)+'" data-hint="'+h.src+'"><div class="'+BC+'app" style="'+sty+'">'+m+E+E}
|
||||
var tpl=document.createElement('template');tpl.innerHTML=h!=null?APP(h):AUTHM(au);root.appendChild(tpl.content);
|
||||
var shell=root.firstChild,timer=0,obs=null,parsed=false;
|
||||
var stop=function(){window.removeEventListener('error',onError,true);document.removeEventListener('DOMContentLoaded',onParsed,true);if(timer){clearTimeout(timer);timer=0}if(obs){obs.disconnect();obs=null}};
|
||||
var drop=function(){var el=document.getElementById('fluxer-boot-shell');if(el&&el.parentNode)el.parentNode.removeChild(el);stop()};
|
||||
var onParsed=function(){parsed=true};
|
||||
var onError=function(ev){var t=ev&&ev.target;if(parsed||!t)return;if(t.tagName!=='SCRIPT'&&!(t.tagName==='LINK'&&t.rel==='stylesheet'))return;drop()};
|
||||
window.addEventListener('error',onError,true);document.addEventListener('DOMContentLoaded',onParsed,true);timer=setTimeout(drop,60000);
|
||||
if(window.MutationObserver){obs=new MutationObserver(function(){if(!root.contains(shell))stop()});obs.observe(root,{childList:true})}
|
||||
if(window.electron&&window.electron.notifyFirstContentPainted)window.electron.notifyFirstContentPainted();
|
||||
}catch(e){}})();</script>
|
||||
<div id="fluxer-boot-end" hidden></div>
|
||||
<noscript>JavaScript is required to use this application.</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"build": "pnpm codegen && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"build:desktop": "pnpm codegen && pnpm build:renderer",
|
||||
"build:renderer": "FLUXER_DESKTOP_RENDERER=true rspack build --mode production",
|
||||
"codegen": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile",
|
||||
"dev": "TOKIO_WORKER_THREADS=4 RAYON_NUM_THREADS=4 ../tools/ci/run.sh app-dev-server",
|
||||
"test": "vitest run",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts",
|
||||
@@ -177,6 +180,7 @@
|
||||
"@floating-ui/react-dom": "catalog:",
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/date_utils": "workspace:*",
|
||||
"@fluxer/desktop_ipc": "workspace:*",
|
||||
"@fluxer/geo_utils": "workspace:*",
|
||||
"@fluxer/instance_bootstrap": "workspace:*",
|
||||
"@fluxer/limits": "workspace:*",
|
||||
@@ -204,7 +208,6 @@
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"codemirror": "^6.0.2",
|
||||
"colorjs.io": "catalog:",
|
||||
"combokeys": "catalog:",
|
||||
"eventemitter3": "catalog:",
|
||||
"fast-png": "^8.0.0",
|
||||
@@ -269,6 +272,7 @@
|
||||
"@webgpu/types": "catalog:",
|
||||
"autoprefixer": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"fake-indexeddb": "catalog:",
|
||||
"happy-dom": "20.14.5",
|
||||
"postcss": "catalog:",
|
||||
"postcss-discard-comments": "catalog:",
|
||||
|
||||
+321
-26
@@ -4,11 +4,13 @@ import {existsSync, mkdirSync, readdirSync, writeFileSync} from 'node:fs';
|
||||
import path, {dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {
|
||||
Compilation,
|
||||
CopyRspackPlugin,
|
||||
DefinePlugin,
|
||||
HtmlRspackPlugin,
|
||||
LightningCssMinimizerRspackPlugin,
|
||||
SwcJsMinimizerRspackPlugin,
|
||||
sources,
|
||||
} from '@rspack/core';
|
||||
import {createPoFileRule, getLinguiSwcPluginConfig} from './scripts/build/rspack/lingui.mjs';
|
||||
import {staticFilesPlugin} from './scripts/build/rspack/static-files.mjs';
|
||||
@@ -23,6 +25,35 @@ const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
|
||||
const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
|
||||
const BROWSER_ASSERT_STRICT_MODULE = path.join(SRC_DIR, 'features', 'platform', 'utils', 'BrowserAssertStrict.ts');
|
||||
const DEFAULT_DEV_SERVER_PORT = 3000;
|
||||
const BOOT_PRELOAD_CHUNK_GROUPS = ['app-bootstrap', 'boot-app'];
|
||||
const BOOT_PRELOAD_FONT_FILES = [
|
||||
'FluxerSans/FluxerSans-Regular.woff2',
|
||||
'FluxerSans/FluxerSans-SemiBold.woff2',
|
||||
'FluxerSans/FluxerSans-Bold.woff2',
|
||||
];
|
||||
const BOOT_LOW_PRIORITY_FONT_FILES = ['FluxerSans/FluxerSans-Medium.woff2'];
|
||||
const AUTH_ENTRY_DOCUMENT = 'auth-index.html';
|
||||
const ENTRY_STYLESHEET_LINK_PATTERN = /<link href="([^"]+\.css)" rel="stylesheet">/gu;
|
||||
const CSS_URL_PATTERN = /url\(\s*['"]?([^'")]+)/gu;
|
||||
const DESKTOP_MODULE_ASSET_QUERY = /[?&]m=([a-z][a-z0-9_]{0,63})(?:&|$)/u;
|
||||
const RESERVED_DESKTOP_MODULE_NAMES = new Set(['assets', 'fluxer_renderer']);
|
||||
|
||||
class UnusableDesktopModuleQueryError extends Error {
|
||||
constructor(resource) {
|
||||
super(
|
||||
`${resource} carries an ?m= desktop module query that is not a usable module name. It must match [a-z][a-z0-9_]{0,63} and cannot be assets or fluxer_renderer.`,
|
||||
);
|
||||
this.name = 'UnusableDesktopModuleQueryError';
|
||||
}
|
||||
}
|
||||
|
||||
function desktopModuleAssetName(resource) {
|
||||
const match = DESKTOP_MODULE_ASSET_QUERY.exec(resource);
|
||||
if (match == null || RESERVED_DESKTOP_MODULE_NAMES.has(match[1])) {
|
||||
throw new UnusableDesktopModuleQueryError(resource);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function resolveMode() {
|
||||
const modeIndex = process.argv.indexOf('--mode');
|
||||
@@ -52,6 +83,164 @@ function isMainRuntimeChunk(chunk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function mirrorCacheGroups(groups) {
|
||||
const mirrored = {};
|
||||
for (const [key, group] of Object.entries(groups)) {
|
||||
if (group.chunks != null) {
|
||||
mirrored[key] = group;
|
||||
continue;
|
||||
}
|
||||
mirrored[key] = {...group, chunks: (chunk) => isMainRuntimeChunk(chunk) && chunk.canBeInitial()};
|
||||
mirrored[`${key}Async`] = {
|
||||
...group,
|
||||
name: `${group.name}-async`,
|
||||
chunks: (chunk) => isMainRuntimeChunk(chunk) && !chunk.canBeInitial(),
|
||||
};
|
||||
}
|
||||
return mirrored;
|
||||
}
|
||||
|
||||
function bootPreloadPlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('BootPreloadPlugin', (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{name: 'BootPreloadPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_REPORT},
|
||||
() => {
|
||||
const asset = compilation.getAsset('index.html');
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
const html = String(asset.source.source());
|
||||
if (!html.includes('</body>')) {
|
||||
return;
|
||||
}
|
||||
const configuredPublicPath = compilation.outputOptions.publicPath;
|
||||
const base =
|
||||
typeof configuredPublicPath === 'string' && configuredPublicPath !== 'auto' ? configuredPublicPath : '/';
|
||||
const files = new Set();
|
||||
for (const name of BOOT_PRELOAD_CHUNK_GROUPS) {
|
||||
for (const group of compilation.chunkGroups) {
|
||||
if (group.name !== name) {
|
||||
continue;
|
||||
}
|
||||
for (const file of group.getFiles()) {
|
||||
if (!html.includes(file)) {
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const ordered = [...files];
|
||||
const links = [
|
||||
...ordered
|
||||
.filter((file) => file.endsWith('.css'))
|
||||
.map((file) => `<link rel="preload" as="style" fetchpriority="low" href="${base}${file}">`),
|
||||
...ordered
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.map((file) => `<link rel="preload" as="script" fetchpriority="low" href="${base}${file}">`),
|
||||
].join('');
|
||||
if (links === '') {
|
||||
return;
|
||||
}
|
||||
compilation.updateAsset('index.html', new sources.RawSource(html.replace('</body>', `${links}</body>`)));
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class UninlinableEntryStylesheetError extends Error {
|
||||
constructor(reason) {
|
||||
super(`Cannot inline the entry stylesheet into ${AUTH_ENTRY_DOCUMENT}: ${reason}`);
|
||||
this.name = 'UninlinableEntryStylesheetError';
|
||||
}
|
||||
}
|
||||
|
||||
function inlineEntryStylesheet(css, href) {
|
||||
const content = css.replace(/\/\*# sourceMappingURL=[^*]*\*\/\s*$/u, '');
|
||||
if (content.includes('</style')) {
|
||||
throw new UninlinableEntryStylesheetError(`${href} contains a closing style tag`);
|
||||
}
|
||||
for (const [, url] of content.matchAll(CSS_URL_PATTERN)) {
|
||||
if (!/^(?:\/|https?:|data:|#)/u.test(url)) {
|
||||
throw new UninlinableEntryStylesheetError(`${href} references ${url} relative to its own location`);
|
||||
}
|
||||
}
|
||||
return `<style>${content}</style><link rel="prefetch" as="style" href="${href}">`;
|
||||
}
|
||||
|
||||
function authEntryDocumentPlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('AuthEntryDocumentPlugin', (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{name: 'AuthEntryDocumentPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_REPORT},
|
||||
() => {
|
||||
const asset = compilation.getAsset('index.html');
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
const html = String(asset.source.source());
|
||||
const links = [...html.matchAll(ENTRY_STYLESHEET_LINK_PATTERN)];
|
||||
if (links.length === 0) {
|
||||
throw new UninlinableEntryStylesheetError('index.html links no stylesheet');
|
||||
}
|
||||
let document = html;
|
||||
for (const [link, href] of links) {
|
||||
const name = compilation
|
||||
.getAssets()
|
||||
.find((candidate) => href.endsWith(`/${candidate.name}`) || href === candidate.name)?.name;
|
||||
if (name == null) {
|
||||
throw new UninlinableEntryStylesheetError(`${href} is not an emitted asset`);
|
||||
}
|
||||
const css = String(compilation.getAsset(name).source.source());
|
||||
document = document.replace(link, () => inlineEntryStylesheet(css, href));
|
||||
}
|
||||
compilation.emitAsset(AUTH_ENTRY_DOCUMENT, new sources.RawSource(document));
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fontPreloadPlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('FontPreloadPlugin', (compilation) => {
|
||||
HtmlRspackPlugin.getCompilationHooks(compilation).alterAssetTagGroups.tap('FontPreloadPlugin', (data) => {
|
||||
const fonts = [];
|
||||
const lowPriority = new Set(BOOT_LOW_PRIORITY_FONT_FILES);
|
||||
for (const sourceName of [...BOOT_PRELOAD_FONT_FILES, ...BOOT_LOW_PRIORITY_FONT_FILES]) {
|
||||
const asset = compilation
|
||||
.getAssets()
|
||||
.find((candidate) => String(candidate.info.sourceFilename ?? '').endsWith(sourceName));
|
||||
if (asset == null) {
|
||||
throw new Error(`Cannot preload ${sourceName}: no emitted asset came from it`);
|
||||
}
|
||||
fonts.push({
|
||||
tagName: 'link',
|
||||
voidTag: true,
|
||||
attributes: {
|
||||
rel: 'preload',
|
||||
as: 'font',
|
||||
type: 'font/woff2',
|
||||
crossorigin: '',
|
||||
href: `${data.publicPath}${asset.name}`,
|
||||
...(lowPriority.has(sourceName) ? {fetchpriority: 'low'} : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
data.headTags.unshift(...fonts);
|
||||
return data;
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nodeAssertStrictSchemePlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
@@ -69,6 +258,7 @@ function nodeAssertStrictSchemePlugin() {
|
||||
const mode = resolveMode();
|
||||
const isProduction = mode === 'production';
|
||||
const isDevelopment = !isProduction;
|
||||
const isDesktopRenderer = envString('FLUXER_DESKTOP_RENDERER') === 'true';
|
||||
const devJsName = 'assets/[name].js';
|
||||
const devCssName = 'assets/[name].css';
|
||||
const productionJsName = 'assets/[contenthash:16].js';
|
||||
@@ -103,19 +293,16 @@ function devServerHeaders() {
|
||||
|
||||
function resolveReleaseChannel() {
|
||||
const value = envString('PUBLIC_RELEASE_CHANNEL', envString('RELEASE_CHANNEL', 'canary')).trim().toLowerCase();
|
||||
if (value === 'stable' || value === 'canary') {
|
||||
if (value === 'stable' || value === 'canary' || value === 'development') {
|
||||
return value;
|
||||
}
|
||||
return 'canary';
|
||||
throw new Error(`PUBLIC_RELEASE_CHANNEL must be stable, canary, or development, received ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function resolvePublicValues() {
|
||||
return {
|
||||
PUBLIC_API_VERSION: envString('PUBLIC_API_VERSION', '1'),
|
||||
PUBLIC_BUILD_VERSION: envString('PUBLIC_BUILD_VERSION', envString('BUILD_VERSION', 'dev')),
|
||||
PUBLIC_RELEASE_CHANNEL: resolveReleaseChannel(),
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: envString('PUBLIC_BOOTSTRAP_API_ENDPOINT', '/api'),
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: envString('PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,11 +352,10 @@ function jsFilename(pathData) {
|
||||
export default () => {
|
||||
const linguiSwcPlugin = getLinguiSwcPluginConfig();
|
||||
const publicValues = resolvePublicValues();
|
||||
const assetBaseUrl = envString('PUBLIC_ASSET_BASE_URL');
|
||||
const staticCdnEndpoint = envString(
|
||||
'PUBLIC_STATIC_CDN_ENDPOINT',
|
||||
envString('FLUXER_STATIC_CDN_ENDPOINT', isProduction ? '' : ''),
|
||||
);
|
||||
const assetBaseUrl = isDesktopRenderer ? undefined : envString('PUBLIC_ASSET_BASE_URL');
|
||||
const staticCdnEndpoint = isDesktopRenderer
|
||||
? ''
|
||||
: envString('PUBLIC_STATIC_CDN_ENDPOINT', envString('FLUXER_STATIC_CDN_ENDPOINT', isProduction ? '' : ''));
|
||||
function resolveArboriumWasmAliases() {
|
||||
const arbDir = path.join(ROOT_DIR, 'node_modules', '@arborium');
|
||||
const aliases = {};
|
||||
@@ -192,10 +378,12 @@ export default () => {
|
||||
const publicPath = isProduction ? productionPublicPath : developmentPublicPath;
|
||||
return {
|
||||
mode,
|
||||
entry: {
|
||||
main: path.join(SRC_DIR, 'index.tsx'),
|
||||
sw: path.join(SRC_DIR, 'features', 'platform', 'service_worker', 'Worker.ts'),
|
||||
},
|
||||
entry: isDesktopRenderer
|
||||
? {main: path.join(SRC_DIR, 'index.tsx')}
|
||||
: {
|
||||
main: path.join(SRC_DIR, 'index.tsx'),
|
||||
sw: path.join(SRC_DIR, 'features', 'platform', 'service_worker', 'Worker.ts'),
|
||||
},
|
||||
output: {
|
||||
path: DIST_DIR,
|
||||
publicPath,
|
||||
@@ -337,6 +525,15 @@ export default () => {
|
||||
type: 'css/module',
|
||||
parser: {namedExports: false, dashedIdents: false, grid: false, container: false},
|
||||
},
|
||||
{
|
||||
test: /[\\/]@phosphor-icons[\\/]react[\\/]dist[\\/]defs[\\/][^\\/]+\.es\.js$/,
|
||||
use: [{loader: path.join(ROOT_DIR, 'scripts/build/rspack/phosphor-unused-weights-loader.cjs')}],
|
||||
},
|
||||
{
|
||||
test: /[\\/]node_modules[\\/]katex[\\/]dist[\\/]katex(\.min)?\.css$/,
|
||||
use: [{loader: path.join(ROOT_DIR, 'scripts/build/rspack/katex-legacy-fonts-loader.cjs')}],
|
||||
type: 'css',
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
exclude: /\.module\.css$/,
|
||||
@@ -411,6 +608,18 @@ export default () => {
|
||||
filename: isProduction ? 'assets/[contenthash:16][ext]' : 'assets/[name].[hash][ext]',
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceQuery: DESKTOP_MODULE_ASSET_QUERY,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: (pathData) => {
|
||||
const moduleName = desktopModuleAssetName(pathData.filename ?? '');
|
||||
return isProduction
|
||||
? `assets/${moduleName}/[contenthash:16][ext]`
|
||||
: `assets/${moduleName}/[name].[hash][ext]`;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
generator: {
|
||||
'css/module': {
|
||||
@@ -427,6 +636,9 @@ export default () => {
|
||||
},
|
||||
plugins: [
|
||||
nodeAssertStrictSchemePlugin(),
|
||||
fontPreloadPlugin(),
|
||||
...(isProduction ? [bootPreloadPlugin()] : []),
|
||||
...(isProduction && !isDesktopRenderer ? [authEntryDocumentPlugin()] : []),
|
||||
new HtmlRspackPlugin({
|
||||
template: path.join(ROOT_DIR, 'index.html'),
|
||||
filename: 'index.html',
|
||||
@@ -434,6 +646,7 @@ export default () => {
|
||||
inject: 'body',
|
||||
scriptLoading: 'module',
|
||||
excludeChunks: ['sw'],
|
||||
...(isDesktopRenderer ? {publicPath: '/'} : {}),
|
||||
}),
|
||||
new CopyRspackPlugin({
|
||||
patterns: [
|
||||
@@ -442,6 +655,14 @@ export default () => {
|
||||
to: DIST_DIR,
|
||||
noErrorOnMissing: true,
|
||||
},
|
||||
...(isDesktopRenderer
|
||||
? [
|
||||
{
|
||||
from: path.join(MONOREPO_ROOT, 'fluxer_static', 'web'),
|
||||
to: path.join(DIST_DIR, 'web'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
staticFilesPlugin({
|
||||
@@ -457,13 +678,7 @@ export default () => {
|
||||
'import.meta.env.PROD': JSON.stringify(isProduction),
|
||||
'import.meta.env.MODE': JSON.stringify(mode),
|
||||
'import.meta.env.PUBLIC_BUILD_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_VERSION'),
|
||||
'import.meta.env.PUBLIC_API_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_API_VERSION'),
|
||||
'import.meta.env.PUBLIC_RELEASE_CHANNEL': getPublicEnvVar(publicValues, 'PUBLIC_RELEASE_CHANNEL'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT': getPublicEnvVar(publicValues, 'PUBLIC_BOOTSTRAP_API_ENDPOINT'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT': getPublicEnvVar(
|
||||
publicValues,
|
||||
'PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT',
|
||||
),
|
||||
}),
|
||||
{
|
||||
apply(compiler) {
|
||||
@@ -482,7 +697,8 @@ export default () => {
|
||||
? {
|
||||
chunks: (chunk) => isMainRuntimeChunk(chunk),
|
||||
maxInitialRequests: 15,
|
||||
cacheGroups: {
|
||||
maxAsyncSize: 2_000_000,
|
||||
cacheGroups: mirrorCacheGroups({
|
||||
icons: {
|
||||
test: /[\\/]node_modules[\\/]@phosphor-icons[\\/]/,
|
||||
name: 'icons',
|
||||
@@ -497,8 +713,24 @@ export default () => {
|
||||
enforce: true,
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
tts: {
|
||||
test: /[\\/]node_modules[\\/]mespeak[\\/]/,
|
||||
name: 'tts',
|
||||
priority: 54,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
editor: {
|
||||
test: /[\\/]node_modules[\\/](@codemirror|@lezer|codemirror|style-mod|w3c-keyname|crelt|@marijn)[\\/]/,
|
||||
name: 'editor',
|
||||
priority: 53,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
livekit: {
|
||||
test: /[\\/]node_modules[\\/](livekit-client|@livekit)[\\/]/,
|
||||
test: /[\\/](node_modules|pkgs)[\\/](livekit-client|@livekit)[\\/]/,
|
||||
name: 'livekit',
|
||||
priority: 50,
|
||||
reuseExistingChunk: true,
|
||||
@@ -511,7 +743,7 @@ export default () => {
|
||||
maxSize: 100_000,
|
||||
},
|
||||
animation: {
|
||||
test: /[\\/]node_modules[\\/](framer-motion|motion)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](framer-motion|motion|motion-dom|motion-utils)[\\/]/,
|
||||
name: 'animation',
|
||||
priority: 45,
|
||||
reuseExistingChunk: true,
|
||||
@@ -529,8 +761,15 @@ export default () => {
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
colorPicker: {
|
||||
test: /[\\/]node_modules[\\/](@react-aria[\\/]color|@react-stately[\\/]color)[\\/]/,
|
||||
name: 'color-picker',
|
||||
priority: 42,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
reactAria: {
|
||||
test: /[\\/]node_modules[\\/]react-aria-components[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](react-aria-components|@react-aria|@react-stately|@internationalized)[\\/]/,
|
||||
name: 'react-aria',
|
||||
priority: 41,
|
||||
reuseExistingChunk: true,
|
||||
@@ -578,7 +817,7 @@ export default () => {
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
utils: {
|
||||
test: /[\\/]node_modules[\\/](lodash|clsx|qrcode|thumbhash|bowser|match-sorter)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](lodash|clsx|thumbhash|match-sorter)[\\/]/,
|
||||
name: 'utils',
|
||||
priority: 28,
|
||||
reuseExistingChunk: true,
|
||||
@@ -595,6 +834,62 @@ export default () => {
|
||||
priority: 25,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
platform: {
|
||||
test: /[\\/]node_modules[\\/]bowser[\\/]/,
|
||||
name: 'platform',
|
||||
priority: 27,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
phone: {
|
||||
test: /[\\/]node_modules[\\/]libphonenumber-js[\\/]/,
|
||||
name: 'phone',
|
||||
priority: 26,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
qrcode: {
|
||||
test: /[\\/]node_modules[\\/](qrcode|dijkstrajs)[\\/]/,
|
||||
name: 'qrcode',
|
||||
priority: 24,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
lexical: {
|
||||
test: /[\\/]node_modules[\\/](lexical|@lexical)[\\/]/,
|
||||
name: 'lexical',
|
||||
priority: 23,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](react-day-picker|date-fns|@date-fns)[\\/]/,
|
||||
name: 'calendar',
|
||||
priority: 22,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
webrtc: {
|
||||
test: /[\\/]node_modules[\\/](webrtc-adapter|sdp|sdp-transform)[\\/]/,
|
||||
name: 'webrtc',
|
||||
priority: 21,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
noiseFilter: {
|
||||
test: /[\\/]node_modules[\\/]deepfilternet3-noise-filter[\\/]/,
|
||||
name: 'noise-filter',
|
||||
priority: 20,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
virtualizer: {
|
||||
test: /[\\/]node_modules[\\/]@tanstack[\\/]/,
|
||||
name: 'virtualizer',
|
||||
priority: 19,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
vendor: {
|
||||
test: (module) => {
|
||||
if (!module.resource) return false;
|
||||
@@ -607,7 +902,7 @@ export default () => {
|
||||
priority: 10,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
: false,
|
||||
runtimeChunk: false,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const LEGACY_FONT_SOURCE = /,\s*url\([^)]*\)\s*format\("(?:woff|truetype)"\)/g;
|
||||
|
||||
class MissingKatexWoff2SourceError extends Error {
|
||||
constructor(resource) {
|
||||
super(`${resource} declares no format("woff2") source, refusing to drop its legacy font sources`);
|
||||
this.name = 'MissingKatexWoff2SourceError';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function katexLegacyFontsLoader(source) {
|
||||
this.cacheable(true);
|
||||
if (!source.includes('format("woff2")')) {
|
||||
throw new MissingKatexWoff2SourceError(this.resourcePath);
|
||||
}
|
||||
return source.replace(LEGACY_FONT_SOURCE, '');
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const UNUSED_WEIGHTS = ['thin', 'light'];
|
||||
const UNUSED_WEIGHT_ENTRY = /\[\s*"(?:thin|light)"\s*,/g;
|
||||
const REQUIRED_WEIGHTS = ['"bold"', '"duotone"', '"fill"', '"regular"'];
|
||||
|
||||
class UnreadablePhosphorWeightMapError extends Error {
|
||||
constructor(resource, detail) {
|
||||
super(`${resource} does not match the expected @phosphor-icons weight map shape (${detail})`);
|
||||
this.name = 'UnreadablePhosphorWeightMapError';
|
||||
}
|
||||
}
|
||||
|
||||
function findEntryEnd(source, start) {
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
if (quote != null) {
|
||||
if (char === '\\') {
|
||||
i++;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '[' || char === '(' || char === '{') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (char === ']' || char === ')' || char === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
module.exports = function phosphorUnusedWeightsLoader(source) {
|
||||
this.cacheable(true);
|
||||
for (const weight of REQUIRED_WEIGHTS) {
|
||||
if (!source.includes(weight)) {
|
||||
throw new UnreadablePhosphorWeightMapError(this.resourcePath, `missing ${weight}`);
|
||||
}
|
||||
}
|
||||
const spans = [];
|
||||
UNUSED_WEIGHT_ENTRY.lastIndex = 0;
|
||||
for (let match = UNUSED_WEIGHT_ENTRY.exec(source); match != null; match = UNUSED_WEIGHT_ENTRY.exec(source)) {
|
||||
const end = findEntryEnd(source, match.index);
|
||||
if (end < 0) {
|
||||
throw new UnreadablePhosphorWeightMapError(this.resourcePath, `unterminated entry at ${match.index}`);
|
||||
}
|
||||
spans.push([match.index, end]);
|
||||
UNUSED_WEIGHT_ENTRY.lastIndex = end;
|
||||
}
|
||||
if (spans.length !== UNUSED_WEIGHTS.length) {
|
||||
throw new UnreadablePhosphorWeightMapError(this.resourcePath, `found ${spans.length} unused weight entries`);
|
||||
}
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
for (const [start, end] of spans) {
|
||||
out += source.slice(cursor, start);
|
||||
cursor = end;
|
||||
while (cursor < source.length && /[\s,]/.test(source[cursor])) {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
return out + source.slice(cursor);
|
||||
};
|
||||
@@ -8,27 +8,11 @@ import * as esbuild from 'esbuild';
|
||||
|
||||
const PRECACHE_ROOT_FILES = ['manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
|
||||
const NEVER_PRECACHED_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
||||
|
||||
async function fileRevision(filePath: string): Promise<string> {
|
||||
const stat = await fs.stat(filePath);
|
||||
return `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
||||
}
|
||||
|
||||
function isLocalAssetUrl(value: string): boolean {
|
||||
if (!value.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
if (value.startsWith('//')) {
|
||||
return false;
|
||||
}
|
||||
const pathname = value.split(/[?#]/, 1)[0].toLowerCase();
|
||||
if (NEVER_PRECACHED_EXTENSIONS.some((extension) => pathname.endsWith(extension))) {
|
||||
return false;
|
||||
}
|
||||
return value.startsWith('/assets/') || PRECACHE_ROOT_FILES.some((file) => value === `/${file}`);
|
||||
}
|
||||
|
||||
async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
const entries = new Map<string, string>();
|
||||
for (const file of PRECACHE_ROOT_FILES) {
|
||||
@@ -37,21 +21,6 @@ async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
entries.set(`/${file}`, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const html = await fs.readFile(path.join(DIST_DIR, 'index.html'), 'utf8');
|
||||
const attributePattern = /\b(?:href|src)=["']([^"']+)["']/g;
|
||||
for (const match of html.matchAll(attributePattern)) {
|
||||
const url = match[1];
|
||||
if (!isLocalAssetUrl(url)) {
|
||||
continue;
|
||||
}
|
||||
const pathname = new URL(url, 'https://local.invalid').pathname;
|
||||
const filePath = path.join(DIST_DIR, pathname.slice(1));
|
||||
try {
|
||||
entries.set(pathname, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return Array.from(entries, ([url, revision]) => ({url, revision}));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,13 @@ import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {ensureAutostartDefaultEnabled} from '@app/features/platform/utils/Autostart';
|
||||
import {startDesktopJumpListBridge} from '@app/features/platform/utils/DesktopJumpListBridge';
|
||||
import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLocaleBridge';
|
||||
@@ -71,6 +74,8 @@ const SKIP_TO_CONTENT_DESCRIPTOR = msg({
|
||||
comment: 'Accessible skip-link label for keyboard users.',
|
||||
});
|
||||
|
||||
const logger = new Logger('App');
|
||||
|
||||
interface AppWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -122,6 +127,22 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void SessionManager.initialize()
|
||||
.then(() => {
|
||||
if (active) {
|
||||
GatewaySessions.startRestoredSession();
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Failed to initialize the restored gateway session', error);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
GatewaySessions.dispose();
|
||||
};
|
||||
}, []);
|
||||
useEffect(
|
||||
() =>
|
||||
reaction(
|
||||
@@ -155,7 +176,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
|
||||
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useWindowEventListeners();
|
||||
useRemScaleTracking();
|
||||
usePlatformClasses(platform, isNative);
|
||||
useThemeCssVariables({
|
||||
|
||||
@@ -12,6 +12,12 @@ import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {installLocaleSwitchWatchdog} from '@app/features/i18n/utils/LocaleSwitchWatchdog';
|
||||
import {installTranslationDomGuard} from '@app/features/i18n/utils/TranslationDomGuard';
|
||||
import {installScrollRestoration} from '@app/features/platform/components/router/ScrollRestoration';
|
||||
import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime';
|
||||
import type {
|
||||
AppStorageBootstrapHandle,
|
||||
AppStorageSessionAccount,
|
||||
} from '@app/features/platform/state/AppStorageBootstrap';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {
|
||||
getFormattedClientInfo,
|
||||
@@ -24,11 +30,13 @@ import {scheduleNonLatinScriptFaces} from '@app/features/theme/fonts/ScriptFontL
|
||||
import {installVoiceSubscriptionDebugApi} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugApi';
|
||||
import {PASSKEY_BRIDGE_PATH} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {i18n} from '@lingui/core';
|
||||
import type {ReactNode} from 'react';
|
||||
import {type ReactNode, startTransition} from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
const logger = new Logger('index');
|
||||
|
||||
const SIGNED_OUT_INSTANCE_DISCOVERY_TIMEOUT_MS = 5000;
|
||||
|
||||
if (typeof window !== 'undefined' && window.history) {
|
||||
bootstrapSyntheticHistory();
|
||||
installScrollRestoration();
|
||||
@@ -47,18 +55,22 @@ function createRoot(): ReactDOM.Root {
|
||||
|
||||
function mountRoot(content: ReactNode, dataFlxScope: string): void {
|
||||
installTranslationDomGuard();
|
||||
createRoot().render(
|
||||
<AppErrorBoundary
|
||||
fallback={(error) => (
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
|
||||
</AppI18nProvider>
|
||||
)}
|
||||
data-flx={`${dataFlxScope}.app-error-boundary`}
|
||||
>
|
||||
{content}
|
||||
</AppErrorBoundary>,
|
||||
);
|
||||
const root = createRoot();
|
||||
startTransition(() => {
|
||||
root.render(
|
||||
<AppErrorBoundary
|
||||
fallback={(error) => (
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
|
||||
</AppI18nProvider>
|
||||
)}
|
||||
data-flx={`${dataFlxScope}.app-error-boundary`}
|
||||
>
|
||||
{content}
|
||||
</AppErrorBoundary>,
|
||||
);
|
||||
});
|
||||
globalThis.window?.electron?.notifyFirstContentPainted?.();
|
||||
}
|
||||
|
||||
async function logClientInfo(): Promise<void> {
|
||||
@@ -82,14 +94,25 @@ async function preloadMarkdownParser(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapThemeStudio(): Promise<void> {
|
||||
function storageSessionAccount(account: Account | null): AppStorageSessionAccount | null {
|
||||
if (account === null) {
|
||||
return null;
|
||||
}
|
||||
if (account.instance === undefined) {
|
||||
throw new Error(`Authenticated account ${account.storageKey} has no instance runtime`);
|
||||
}
|
||||
return {accountKey: account.storageKey, userId: account.userId, token: account.token, instance: account.instance};
|
||||
}
|
||||
|
||||
async function bootstrapThemeStudio(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
|
||||
const markdownParserReady = preloadMarkdownParser();
|
||||
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: AccountManager}] = await Promise.all([
|
||||
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: Accounts}] = await Promise.all([
|
||||
loadLazyModule(() => import('@app/features/theme_studio/ThemeStudioStandaloneApp')),
|
||||
loadLazyModule(() => import('@app/app/SetupHttp')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/Accounts')),
|
||||
]);
|
||||
await AccountManager.bootstrap();
|
||||
await Accounts.bootstrap();
|
||||
await storageBootstrap.finalizeAfterSessionResolution(storageSessionAccount(Accounts.currentAccount));
|
||||
setupHttp();
|
||||
await markdownParserReady;
|
||||
mountRoot(
|
||||
@@ -132,7 +155,39 @@ async function probeSignedOutDomainMigration(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp(): Promise<void> {
|
||||
async function activateSignedOutDocumentInstance(): Promise<void> {
|
||||
if (isDesktopLocalAppDocument()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const {default: RuntimeConfig} = await loadLazyModule(() => import('@app/features/app/state/RuntimeConfig'));
|
||||
if (RuntimeConfig.getSnapshotOrNull() !== null) {
|
||||
return;
|
||||
}
|
||||
const resolution = await RuntimeConfig.resolveEndpoint({
|
||||
input: window.location.origin,
|
||||
signal: AbortSignal.timeout(SIGNED_OUT_INSTANCE_DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
if (RuntimeConfig.getSnapshotOrNull() === null) {
|
||||
RuntimeConfig.applySnapshot(resolution.snapshot);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to activate the instance this page belongs to:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareSignedOutFirstScreen(): Promise<void> {
|
||||
try {
|
||||
const {prepareSignedOutFirstScreen: prepare} = await loadLazyModule(
|
||||
() => import('@app/features/auth/flow/AuthFirstScreen'),
|
||||
);
|
||||
await prepare();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to prepare the signed-out first screen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
|
||||
const markdownParserReady = preloadMarkdownParser();
|
||||
const [
|
||||
{App},
|
||||
@@ -140,7 +195,7 @@ async function bootstrapApp(): Promise<void> {
|
||||
{default: CaptchaInterceptor},
|
||||
{initializeEmojiParser},
|
||||
{registerServiceWorker},
|
||||
{default: AccountManager},
|
||||
{default: Accounts},
|
||||
{default: ChannelDisplayName},
|
||||
_channelFrecency,
|
||||
_geoIp,
|
||||
@@ -152,15 +207,15 @@ async function bootstrapApp(): Promise<void> {
|
||||
{default: StatusPage},
|
||||
{installMigratedDeviceRemap},
|
||||
] = await Promise.all([
|
||||
loadLazyModule(() => import('@app/app/App')),
|
||||
loadLazyModule(() => import(/* webpackChunkName: "boot-app" */ '@app/app/App')),
|
||||
loadLazyModule(() => import('@app/app/SetupHttp')),
|
||||
loadLazyModule(() => import('@app/features/auth/altcha/CaptchaInterceptor')),
|
||||
loadLazyModule(() => import('@app/features/messaging/utils/markdown/EmojiProviderSetup')),
|
||||
loadLazyModule(() => import('@app/features/platform/service_worker/Register')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/Accounts')),
|
||||
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
|
||||
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
|
||||
loadLazyModule(() => import('@app/features/app/state/GeoIP')).then(({default: GeoIP}) => GeoIP.load()),
|
||||
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
|
||||
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
|
||||
loadLazyModule(() => import('@app/features/ui/state/Notification')),
|
||||
@@ -178,10 +233,14 @@ async function bootstrapApp(): Promise<void> {
|
||||
CaptchaInterceptor.setI18n(reactiveI18n);
|
||||
void StatusPage.checkIncidents();
|
||||
StatusPage.startPolling();
|
||||
await Promise.all([AccountManager.bootstrap(), installMigratedDeviceRemap()]);
|
||||
if (AccountManager.currentUserId === null) {
|
||||
await Promise.all([Accounts.bootstrap(), installMigratedDeviceRemap()]);
|
||||
await storageBootstrap.finalizeAfterSessionResolution(storageSessionAccount(Accounts.currentAccount));
|
||||
if (Accounts.currentUserId === null) {
|
||||
void probeSignedOutDomainMigration();
|
||||
}
|
||||
if (Accounts.currentAccount === null) {
|
||||
await Promise.all([activateSignedOutDocumentInstance(), prepareSignedOutFirstScreen()]);
|
||||
}
|
||||
setupHttp();
|
||||
initializeEmojiParser();
|
||||
await markdownParserReady;
|
||||
@@ -190,15 +249,15 @@ async function bootstrapApp(): Promise<void> {
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
export async function runApp(): Promise<void> {
|
||||
export async function runApp(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
|
||||
scheduleNonLatinScriptFaces();
|
||||
await initI18n();
|
||||
installLocaleSwitchWatchdog();
|
||||
installSelfXssNotice();
|
||||
void logClientInfo();
|
||||
if (window.location.pathname === Routes.THEME_STUDIO) {
|
||||
await bootstrapThemeStudio();
|
||||
await bootstrapThemeStudio(storageBootstrap);
|
||||
} else {
|
||||
await bootstrapApp();
|
||||
await bootstrapApp(storageBootstrap);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
import {NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {WELCOME_ROTATION} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
|
||||
import {
|
||||
AuthShellHintKind,
|
||||
recordAuthShellHint,
|
||||
resolveAuthShellPlaceholder,
|
||||
} from '@app/features/app/components/skeleton/AuthShellHint';
|
||||
import {SHELL_HINT_NAGBAR_TONE_ORDER, SHELL_HINT_VERSION} from '@app/features/app/components/skeleton/ShellHint';
|
||||
import {
|
||||
LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER,
|
||||
LEGACY_SHELL_HINT_SEED_VERSION,
|
||||
legacyShellHintSeed,
|
||||
} from '@fluxer/desktop_ipc/src/LegacyShellHintSeed';
|
||||
import {createElement, type ReactNode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {afterEach, expect, test, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: ({children}: {children?: ReactNode}) => children ?? null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
|
||||
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
|
||||
|
||||
function readBootScript(): string {
|
||||
const body = html.slice(html.indexOf('<div id="root"></div>'));
|
||||
const match = /<script[^>]*>([\s\S]*?)<\/script>/u.exec(body);
|
||||
expect(match).not.toBeNull();
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
interface ShellHintOverrides {
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
const ACCOUNT_KEY = 'https://one.example/api::100';
|
||||
|
||||
function desktopDMHint(overrides: ShellHintOverrides = {}): Record<string, unknown> {
|
||||
return {
|
||||
v: SHELL_HINT_VERSION,
|
||||
t: Date.now(),
|
||||
p: '/channels/@me',
|
||||
a: ACCOUNT_KEY,
|
||||
mo: 0,
|
||||
ua: 0,
|
||||
fs: 0,
|
||||
sw: 320,
|
||||
sv: 'dm',
|
||||
ck: 'other',
|
||||
cb: 1,
|
||||
rf: 1,
|
||||
rv: 1,
|
||||
ru: [],
|
||||
rti: 0,
|
||||
ro: 0,
|
||||
ri: [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
],
|
||||
rgi: -1,
|
||||
rbm: 15,
|
||||
rbi: -1,
|
||||
rs: 0,
|
||||
gb: 0,
|
||||
ga: 1778,
|
||||
gn: 0,
|
||||
gd: 0,
|
||||
gm: 0,
|
||||
sr: 6,
|
||||
sam: 7,
|
||||
ss: 1,
|
||||
cid: '',
|
||||
ch: 2,
|
||||
ct: 0,
|
||||
cnw: 0,
|
||||
ctw: 0,
|
||||
cda: -1,
|
||||
cma: -1,
|
||||
cfv: 1,
|
||||
cst: 0,
|
||||
cuv: 0,
|
||||
ml: 0,
|
||||
co: 4,
|
||||
com: 2,
|
||||
cdv: 0,
|
||||
pc: 0,
|
||||
pg: 16,
|
||||
pf: 16,
|
||||
ps: 16,
|
||||
pa: 0,
|
||||
pt: 56,
|
||||
pv: 800,
|
||||
nr: [],
|
||||
vh: 0,
|
||||
vc: 0,
|
||||
mr: 0,
|
||||
ms: 0,
|
||||
mn: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setViewportWidth(width: number): void {
|
||||
Object.defineProperty(window, 'innerWidth', {value: width, configurable: true});
|
||||
const matches = width >= 1024;
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: (query: string) => ({matches: query.includes('1024') ? matches : false, media: query}),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function failScript(): void {
|
||||
const script = document.createElement('script');
|
||||
document.body.appendChild(script);
|
||||
script.dispatchEvent(new Event('error', {bubbles: false}));
|
||||
}
|
||||
|
||||
const bootRoots: Array<HTMLElement> = [];
|
||||
|
||||
interface BootScriptOptions {
|
||||
readonly signedIn?: boolean;
|
||||
readonly storage?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
function runBootScript(
|
||||
hint: Record<string, unknown> | null,
|
||||
pathname = '/channels/@me',
|
||||
{signedIn = true, storage = {}}: BootScriptOptions = {},
|
||||
): HTMLElement | null {
|
||||
document.body.innerHTML = '<div id="root"></div>';
|
||||
window.history.pushState({}, '', pathname);
|
||||
localStorage.clear();
|
||||
if (signedIn) {
|
||||
localStorage.setItem('fluxer:gateway:preboot:session', '1');
|
||||
}
|
||||
localStorage.setItem('fluxer:auth:active-account-key', ACCOUNT_KEY);
|
||||
if (hint != null) {
|
||||
localStorage.setItem('fluxer:ui:shell-hint', JSON.stringify(hint));
|
||||
}
|
||||
for (const [key, value] of Object.entries(storage)) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
const root = document.getElementById('root');
|
||||
if (root != null) {
|
||||
bootRoots.push(root);
|
||||
}
|
||||
new Function(readBootScript())();
|
||||
return document.querySelector<HTMLElement>('#fluxer-boot-shell');
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of bootRoots.splice(0)) {
|
||||
root.replaceChildren(document.createElement('span'));
|
||||
root.replaceChildren();
|
||||
}
|
||||
await Promise.resolve();
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('a desktop hint paints the boot shell', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint());
|
||||
|
||||
expect(shell).not.toBeNull();
|
||||
expect(shell?.dataset.mobile).toBe('0');
|
||||
expect(shell?.dataset.userArea).toBe('1');
|
||||
expect(shell?.firstElementChild?.className).toBe('fluxer-boot-app');
|
||||
});
|
||||
|
||||
test('the boot shell is parsed in an inert template so the render-blocking expectation outlives it', () => {
|
||||
setViewportWidth(1400);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
||||
const rootWrites: Array<string> = [];
|
||||
const innerHTML = vi.spyOn(Element.prototype, 'innerHTML', 'set').mockImplementation(function (
|
||||
this: Element,
|
||||
value: string,
|
||||
) {
|
||||
if (this.id === 'root') {
|
||||
rootWrites.push(value);
|
||||
}
|
||||
descriptor?.set?.call(this, value);
|
||||
});
|
||||
const createElement = vi.spyOn(document, 'createElement');
|
||||
try {
|
||||
const shell = runBootScript(desktopDMHint());
|
||||
|
||||
expect(shell?.querySelector('svg')).not.toBeNull();
|
||||
expect(shell?.parentElement?.id).toBe('root');
|
||||
expect(rootWrites).toEqual([]);
|
||||
expect(createElement.mock.calls.map(([tagName]) => tagName)).toContain('template');
|
||||
} finally {
|
||||
createElement.mockRestore();
|
||||
innerHTML.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
function railGuildSlots(shell: HTMLElement | null): number {
|
||||
return shell?.querySelectorAll('.fluxer-boot-rail-items > .fluxer-boot-slot').length ?? 0;
|
||||
}
|
||||
|
||||
test('a hint written for another account is ignored and the default shell paints instead', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint({a: 'https://two.example/api::200', ri: [[0, 0]]}));
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(railGuildSlots(shell)).toBe(6);
|
||||
});
|
||||
|
||||
test('a signed-in boot without a hint paints the generic friends shell at the DM root', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(null);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.userArea).toBe('1');
|
||||
expect(railGuildSlots(shell)).toBe(6);
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a signed-in boot without a hint paints a guild chat shell with the member list on a channel route', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(null, '/channels/100/200');
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-chat')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-member-list')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-composer')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a signed-in boot without a hint paints the mobile channel list with a bottom nav on a phone', () => {
|
||||
setViewportWidth(390);
|
||||
|
||||
const shell = runBootScript(null, '/channels/@me');
|
||||
|
||||
expect(shell?.dataset.mobile).toBe('1');
|
||||
expect(shell?.dataset.bottomNav).toBe('1');
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a signed-in boot never paints a shell on a route the app renders without guild chrome', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(null, '/notifications')).toBeNull();
|
||||
expect(runBootScript(null, '/channels/@discover')).toBeNull();
|
||||
});
|
||||
|
||||
test('a days-old hint for the same account and route still paints as remembered', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint({t: Date.now() - 3 * 86_400_000}));
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(railGuildSlots(shell)).toBe(4);
|
||||
});
|
||||
|
||||
test('a hint for another route keeps its rail and global chrome but resolves the route it lands on', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint({nr: [[0, 0, 1]]}), '/channels/100/200');
|
||||
|
||||
expect(shell?.dataset.hint).toBe('derived');
|
||||
expect(railGuildSlots(shell)).toBe(4);
|
||||
expect(shell?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(1);
|
||||
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).toBeNull();
|
||||
});
|
||||
|
||||
test('a hint seeded from the legacy desktop client paints its rail, nagbars and sidebar on the first boot', () => {
|
||||
setViewportWidth(1400);
|
||||
const seed = legacyShellHintSeed(
|
||||
{
|
||||
SkeletonLayoutMemory: JSON.stringify({
|
||||
version: 9,
|
||||
chrome: {
|
||||
dmSidebar: {
|
||||
isMobile: false,
|
||||
friendsVisible: true,
|
||||
personalNotesVisible: true,
|
||||
premiumVisible: false,
|
||||
sectionVisible: true,
|
||||
channelRowCount: 2,
|
||||
channelSubtextFlags: [true, false],
|
||||
},
|
||||
guildRail: {
|
||||
inlineDmRowCount: 0,
|
||||
inlineDmUnreadFlags: [],
|
||||
selectedInlineDmRowIndex: -1,
|
||||
outageVisible: false,
|
||||
fluxerVisible: true,
|
||||
favoritesVisible: true,
|
||||
discoveryVisible: true,
|
||||
addGuildVisible: true,
|
||||
downloadVisible: false,
|
||||
helpVisible: true,
|
||||
selectedItemIndex: -1,
|
||||
organizedItems: [
|
||||
{kind: 'guild', indicator: 'none'},
|
||||
{kind: 'guild', indicator: 'unread'},
|
||||
],
|
||||
scrollTopPx: 0,
|
||||
},
|
||||
},
|
||||
nagbar: {
|
||||
rows: [
|
||||
{tone: NagbarToneKind.ALERT, hasActions: true, dismissible: false},
|
||||
{tone: NagbarToneKind.BRAND, hasActions: true, dismissible: true},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'fluxer:ui:sidebar-width': '306',
|
||||
},
|
||||
ACCOUNT_KEY,
|
||||
Date.now(),
|
||||
);
|
||||
const hint = JSON.parse(seed ?? '');
|
||||
expect(hint.v).toBe(SHELL_HINT_VERSION);
|
||||
expect(LEGACY_SHELL_HINT_SEED_VERSION).toBe(SHELL_HINT_VERSION);
|
||||
|
||||
const shell = runBootScript(hint);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(railGuildSlots(shell)).toBe(2);
|
||||
expect(shell?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(2);
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
|
||||
expect(shell?.firstElementChild?.getAttribute('style')).toContain('--layout-sidebar-width:19.125rem');
|
||||
|
||||
const guild = runBootScript(hint, '/channels/100/200');
|
||||
|
||||
expect(guild?.dataset.hint).toBe('derived');
|
||||
expect(railGuildSlots(guild)).toBe(2);
|
||||
expect(guild?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('the legacy seed encodes nagbar tones in the order the boot shell decodes them', () => {
|
||||
expect(LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER).toEqual(SHELL_HINT_NAGBAR_TONE_ORDER);
|
||||
});
|
||||
|
||||
test('the DM root paints the remembered route because the app restores it on boot', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(
|
||||
desktopDMHint({p: '/channels/100/200', sv: 'guild', ck: 'chat', cb: 0, cid: '200', ch: 0}),
|
||||
'/channels/@me',
|
||||
);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-chat')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a document script that never loads drops the boot shell because nothing else can mount the app', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(desktopDMHint())).not.toBeNull();
|
||||
|
||||
failScript();
|
||||
|
||||
expect(document.querySelector('#fluxer-boot-shell')).toBeNull();
|
||||
});
|
||||
|
||||
test('the backstop removes a boot shell React never cleared', () => {
|
||||
vi.useFakeTimers();
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(desktopDMHint())).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(document.querySelector('#fluxer-boot-shell')).toBeNull();
|
||||
});
|
||||
|
||||
test('the error listener and the backstop stop existing once React has cleared the boot shell', async () => {
|
||||
vi.useFakeTimers();
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(desktopDMHint())).not.toBeNull();
|
||||
|
||||
document.getElementById('root')?.replaceChildren();
|
||||
await Promise.resolve();
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.id = 'fluxer-boot-shell';
|
||||
document.body.appendChild(marker);
|
||||
|
||||
failScript();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(document.querySelector('#fluxer-boot-shell')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('React clearing its own container is what removes the boot shell', async () => {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'root';
|
||||
const shell = document.createElement('div');
|
||||
shell.id = 'fluxer-boot-shell';
|
||||
container.appendChild(shell);
|
||||
document.body.appendChild(container);
|
||||
|
||||
createRoot(container).render(createElement('span', null, 'mounted'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain('mounted');
|
||||
});
|
||||
|
||||
expect(container.querySelector('#fluxer-boot-shell')).toBeNull();
|
||||
});
|
||||
|
||||
test('the zoom script syncs --custom-zoom with the persisted preboot mirror before paint', () => {
|
||||
const chunk = html.split(/<script[^>]*>/u).find((part) => part.includes('zoom-preboot'));
|
||||
expect(chunk).toBeDefined();
|
||||
const script = (chunk ?? '').split('</script>')[0];
|
||||
expect(script).toContain('zoom-preboot');
|
||||
const run = (mirror: string | null): string => {
|
||||
document.documentElement.style.removeProperty('--custom-zoom');
|
||||
if (mirror === null) {
|
||||
localStorage.removeItem('fluxer:accessibility:zoom-preboot');
|
||||
} else {
|
||||
localStorage.setItem('fluxer:accessibility:zoom-preboot', mirror);
|
||||
}
|
||||
new Function(script)();
|
||||
return document.documentElement.style.getPropertyValue('--custom-zoom');
|
||||
};
|
||||
expect(run('125')).toBe('125');
|
||||
expect(run('75')).toBe('75');
|
||||
expect(run(null)).toBe('');
|
||||
expect(run('9999')).toBe('');
|
||||
expect(run('not-a-number')).toBe('');
|
||||
localStorage.removeItem('fluxer:accessibility:zoom-preboot');
|
||||
});
|
||||
|
||||
function runSignedOutBootScript(pathname: string, storage: Readonly<Record<string, string>> = {}): HTMLElement | null {
|
||||
return runBootScript(null, pathname, {signedIn: false, storage});
|
||||
}
|
||||
|
||||
function setNativePlatform(native: boolean): void {
|
||||
document.documentElement.classList.toggle('platform-native', native);
|
||||
}
|
||||
|
||||
test('a signed-out boot paints the sign-in card at its final size instead of a flat background', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runSignedOutBootScript('/login');
|
||||
|
||||
expect(shell?.dataset.auth).toBe('card');
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.height).toBe('214');
|
||||
expect(shell?.querySelector<HTMLElement>('.fluxer-boot-auth-card')?.style.height).toBe('13.375rem');
|
||||
expect(shell?.querySelectorAll('.fluxer-boot-auth-list > .fluxer-boot-block')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a signed-out boot on an app route paints the sign-in card the app redirects to', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runSignedOutBootScript('/channels/@me')?.dataset.category).toBe('login');
|
||||
expect(runSignedOutBootScript('/channels/100/200')?.dataset.category).toBe('login');
|
||||
});
|
||||
|
||||
test('a signed-out boot sizes the register card and the phone layout from their own defaults', () => {
|
||||
setViewportWidth(1400);
|
||||
expect(runSignedOutBootScript('/register')?.dataset.height).toBe('674');
|
||||
|
||||
setViewportWidth(390);
|
||||
const mobile = runSignedOutBootScript('/register');
|
||||
expect(mobile?.dataset.mobile).toBe('1');
|
||||
expect(mobile?.dataset.height).toBe('592');
|
||||
expect(mobile?.querySelector('.fluxer-boot-auth-mmain')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a remembered auth card size wins over the default', () => {
|
||||
setViewportWidth(1400);
|
||||
recordAuthShellHint({pathname: '/login', mobile: false, kind: AuthShellHintKind.CARD, heightPx: 388, widthPx: 512});
|
||||
const storage = {'fluxer:ui:auth-shell-hint': localStorage.getItem('fluxer:ui:auth-shell-hint') ?? ''};
|
||||
|
||||
const shell = runSignedOutBootScript('/login', storage);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(shell?.dataset.height).toBe('388');
|
||||
expect(shell?.querySelector<HTMLElement>('.fluxer-boot-auth-card')?.style.maxWidth).toBe('32rem');
|
||||
});
|
||||
|
||||
test('a remembered phone card is not used on a desktop-sized window', () => {
|
||||
setViewportWidth(1400);
|
||||
recordAuthShellHint({pathname: '/login', mobile: true, kind: AuthShellHintKind.CARD, heightPx: 500, widthPx: 342});
|
||||
const storage = {'fluxer:ui:auth-shell-hint': localStorage.getItem('fluxer:ui:auth-shell-hint') ?? ''};
|
||||
|
||||
expect(runSignedOutBootScript('/login', storage)?.dataset.height).toBe('214');
|
||||
});
|
||||
|
||||
test('a fresh desktop install paints the fullscreen welcome skeleton', () => {
|
||||
setViewportWidth(1400);
|
||||
setNativePlatform(true);
|
||||
try {
|
||||
const shell = runSignedOutBootScript('/login');
|
||||
|
||||
expect(shell?.dataset.auth).toBe('full');
|
||||
expect(shell?.querySelector('.fluxer-boot-auth-full')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-auth-wtext')?.textContent).toBe('Welcome');
|
||||
} finally {
|
||||
setNativePlatform(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the fullscreen welcome greets in the stored locale before the browser language', () => {
|
||||
setViewportWidth(1400);
|
||||
setNativePlatform(true);
|
||||
try {
|
||||
const shell = runSignedOutBootScript('/login', {locale: 'de'});
|
||||
|
||||
expect(shell?.querySelector('.fluxer-boot-auth-wtext')?.textContent).toBe('Willkommen');
|
||||
} finally {
|
||||
setNativePlatform(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the boot welcome words match the welcome rotation', () => {
|
||||
const table = /var t=(\{[^}]*\});/u.exec(readBootScript());
|
||||
expect(table).not.toBeNull();
|
||||
const words = new Function(`return ${table?.[1] ?? '{}'}`)() as Record<string, string>;
|
||||
|
||||
expect(words).toEqual(Object.fromEntries(WELCOME_ROTATION.map((entry) => [entry.code, entry.text])));
|
||||
});
|
||||
|
||||
test('a shell that stamps a stored session paints the app shell even before the preboot mirror exists', () => {
|
||||
setViewportWidth(1400);
|
||||
document.documentElement.setAttribute('data-boot-session', '1');
|
||||
try {
|
||||
const shell = runSignedOutBootScript('/channels/@me');
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.auth).toBeUndefined();
|
||||
} finally {
|
||||
document.documentElement.removeAttribute('data-boot-session');
|
||||
}
|
||||
});
|
||||
|
||||
test('a session an older client stored paints the app shell before the preboot mirror exists', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runSignedOutBootScript('/channels/200/300', {token: 'legacy-token', userId: '100'});
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.auth).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a half or emptied older session still paints the sign-in card', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runSignedOutBootScript('/channels/@me', {token: 'legacy-token'})?.dataset.auth).toBe('card');
|
||||
expect(runSignedOutBootScript('/channels/@me', {token: 'null', userId: '100'})?.dataset.auth).toBe('card');
|
||||
expect(
|
||||
runSignedOutBootScript('/channels/@me', {
|
||||
token: 'legacy-token',
|
||||
userId: '100',
|
||||
'fluxer:gateway:preboot:session': '0',
|
||||
})?.dataset.auth,
|
||||
).toBe('card');
|
||||
});
|
||||
|
||||
test('a signed-out boot leaves routes without an auth card alone', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runSignedOutBootScript('/oauth2/authorize')).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{pathname: '/login', width: 1400, native: false},
|
||||
{pathname: '/login', width: 390, native: false},
|
||||
{pathname: '/login', width: 1400, native: true},
|
||||
{pathname: '/register', width: 1400, native: false},
|
||||
{pathname: '/register', width: 390, native: false},
|
||||
{pathname: '/forgot', width: 1400, native: false},
|
||||
{pathname: '/invite/abc', width: 390, native: false},
|
||||
])(
|
||||
'the inline auth skeleton picks what the app placeholder picks for $pathname at $width px',
|
||||
({pathname, width, native}) => {
|
||||
setViewportWidth(width);
|
||||
setNativePlatform(native);
|
||||
try {
|
||||
const shell = runSignedOutBootScript(pathname);
|
||||
const placeholder = resolveAuthShellPlaceholder(pathname, {
|
||||
mobile: width < 640,
|
||||
native,
|
||||
passkeyLikely: false,
|
||||
});
|
||||
|
||||
expect(shell?.dataset.category).toBe(placeholder?.category);
|
||||
expect(shell?.dataset.auth).toBe(placeholder?.kind);
|
||||
expect(shell?.dataset.hint).toBe(placeholder?.source);
|
||||
expect(Number(shell?.dataset.height)).toBe(placeholder?.heightPx);
|
||||
} finally {
|
||||
setNativePlatform(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
import {FriendsSkeleton} from '@app/features/app/components/skeleton/FriendsSkeleton';
|
||||
import {SHELL_HINT_VERSION} from '@app/features/app/components/skeleton/ShellHint';
|
||||
import {
|
||||
type RememberedSkeletonActiveNowCard,
|
||||
type RememberedSkeletonFriendsLayout,
|
||||
SKELETON_DEFAULT_FRIENDS_LAYOUT,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {act, createElement, type ReactNode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {afterEach, expect, test, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: ({children}: {children?: ReactNode}) => children ?? null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
|
||||
const memory = vi.hoisted(() => ({friends: null as RememberedSkeletonFriendsLayout | null}));
|
||||
|
||||
vi.mock('@app/features/app/components/skeleton/SkeletonLayoutMemory', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/features/app/components/skeleton/SkeletonLayoutMemory')>();
|
||||
return {...actual, getRememberedSkeletonFriendsLayout: () => memory.friends};
|
||||
});
|
||||
|
||||
const ACCOUNT_KEY = 'https://one.example/api::100';
|
||||
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
|
||||
|
||||
function readBootScript(): string {
|
||||
const body = html.slice(html.indexOf('<div id="root"></div>'));
|
||||
return /<script[^>]*>([\s\S]*?)<\/script>/u.exec(body)?.[1] ?? '';
|
||||
}
|
||||
|
||||
function friendsHint(extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
v: SHELL_HINT_VERSION,
|
||||
t: Date.now(),
|
||||
p: '/channels/@me',
|
||||
a: ACCOUNT_KEY,
|
||||
mo: 0,
|
||||
ua: 0,
|
||||
sv: 'dm',
|
||||
ck: 'other',
|
||||
cb: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function bootSidebar(hint: Record<string, unknown> | null): Element | null {
|
||||
document.body.innerHTML = '<div id="root"></div>';
|
||||
window.history.pushState({}, '', '/channels/@me');
|
||||
localStorage.clear();
|
||||
localStorage.setItem('fluxer:gateway:preboot:session', '1');
|
||||
localStorage.setItem('fluxer:auth:active-account-key', ACCOUNT_KEY);
|
||||
if (hint != null) {
|
||||
localStorage.setItem('fluxer:ui:shell-hint', JSON.stringify(hint));
|
||||
}
|
||||
new Function(readBootScript())();
|
||||
const shell = document.querySelector('#fluxer-boot-shell');
|
||||
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
|
||||
return shell?.querySelector('.fluxer-boot-fr-side') ?? null;
|
||||
}
|
||||
|
||||
async function reactSidebar(friends: RememberedSkeletonFriendsLayout | null): Promise<Element | null> {
|
||||
memory.friends = friends;
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(createElement(FriendsSkeleton)));
|
||||
const sidebar = container.querySelector('flx-app-friends-skeleton-sidebar');
|
||||
const copy = sidebar?.cloneNode(true) as Element | undefined;
|
||||
await act(async () => root.unmount());
|
||||
return copy ?? null;
|
||||
}
|
||||
|
||||
function bootGeometry(sidebar: Element | null): Array<string> {
|
||||
return Array.from(sidebar?.querySelectorAll('.fluxer-boot-block') ?? []).map((block) => {
|
||||
const style = block.getAttribute('style') ?? '';
|
||||
const width = /width:([^;]+);/.exec(style)?.[1];
|
||||
const height = /height:([^;]+);/.exec(style)?.[1];
|
||||
return `${width}x${height}`;
|
||||
});
|
||||
}
|
||||
|
||||
function reactGeometry(sidebar: Element | null): Array<string> {
|
||||
return Array.from(sidebar?.querySelectorAll('flx-skeleton-line, flx-skeleton-block') ?? []).map((block) => {
|
||||
const style = (block as HTMLElement).style;
|
||||
return `${style.getPropertyValue('--skeleton-width')}x${style.getPropertyValue('--skeleton-height')}`;
|
||||
});
|
||||
}
|
||||
|
||||
function layoutWith(activeNowVisible: boolean, cards: ReadonlyArray<RememberedSkeletonActiveNowCard>) {
|
||||
return {...SKELETON_DEFAULT_FRIENDS_LAYOUT, activeNowVisible, activeNowCards: cards};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
memory.friends = null;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('with nobody active the boot shell paints the same empty state React paints', async () => {
|
||||
const boot = bootSidebar(friendsHint({fa: 1, fn: []}));
|
||||
const react = await reactSidebar(layoutWith(true, []));
|
||||
|
||||
expect(boot?.querySelector('.fluxer-boot-fr-empty')).not.toBeNull();
|
||||
expect(boot?.querySelector('.fluxer-boot-fr-card')).toBeNull();
|
||||
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
|
||||
});
|
||||
|
||||
test('remembered cards paint with the same count and geometry as the React skeleton', async () => {
|
||||
const cards = [
|
||||
{participantCount: 0, streaming: false},
|
||||
{participantCount: 3, streaming: true},
|
||||
{participantCount: 7, streaming: false},
|
||||
];
|
||||
const boot = bootSidebar(
|
||||
friendsHint({
|
||||
fa: 1,
|
||||
fn: [
|
||||
[0, 0],
|
||||
[3, 1],
|
||||
[7, 0],
|
||||
],
|
||||
}),
|
||||
);
|
||||
const react = await reactSidebar(layoutWith(true, cards));
|
||||
|
||||
expect(boot?.querySelectorAll('.fluxer-boot-fr-card')).toHaveLength(3);
|
||||
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
|
||||
});
|
||||
|
||||
test('a hidden active now column paints no sidebar in either skeleton', async () => {
|
||||
expect(bootSidebar(friendsHint({fa: 0, fn: []}))).toBeNull();
|
||||
expect(await reactSidebar(layoutWith(false, []))).toBeNull();
|
||||
});
|
||||
|
||||
test('without any remembered layout neither skeleton invents activity cards', async () => {
|
||||
const boot = bootSidebar(null);
|
||||
const react = await reactSidebar(null);
|
||||
|
||||
expect(boot?.querySelector('.fluxer-boot-fr-card')).toBeNull();
|
||||
expect(react?.querySelector('flx-app-friends-skeleton-active-card')).toBeNull();
|
||||
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {initI18n} from '@app/app/I18n';
|
||||
import {ensureActiveLocale, initI18n} from '@app/app/I18n';
|
||||
import {BootstrapErrorScreen} from '@app/features/app/components/BootstrapErrorScreen';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -16,10 +16,14 @@ export async function reportBootstrapError(error: unknown): Promise<void> {
|
||||
if (!container) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
await initI18n();
|
||||
ensureActiveLocale();
|
||||
ReactDOM.createRoot(container).render(
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<BootstrapErrorScreen error={normalized} data-flx="index.bootstrap-error-screen" />
|
||||
</AppI18nProvider>,
|
||||
);
|
||||
globalThis.window?.electron?.notifyFirstContentPainted?.();
|
||||
initI18n().catch((localeError: unknown) => {
|
||||
logger.warn('Failed to load the locale for the bootstrap error screen:', localeError);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,6 +206,16 @@ function activateLocale(localeCode: LocaleCode, messages: Messages): void {
|
||||
AppStorage.setItem('locale', localeCode);
|
||||
}
|
||||
|
||||
export function ensureActiveLocale(): void {
|
||||
if (i18n.locale) {
|
||||
return;
|
||||
}
|
||||
i18n.loadAndActivate({locale: DEFAULT_LOCALE, messages: messagesEnUS});
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
const inFlightLoads = new Map<LocaleCode, Promise<Messages>>();
|
||||
let requestedLocale: LocaleCode | null = null;
|
||||
|
||||
@@ -246,6 +256,11 @@ export function applyLocaleChange(localeCode: string): LocaleCode {
|
||||
}
|
||||
|
||||
let initPromise: Promise<typeof i18n> | null = null;
|
||||
let initialized = false;
|
||||
|
||||
export function isI18nInitialized(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
export async function initI18n(forceLocale?: string) {
|
||||
if (!initPromise) {
|
||||
@@ -257,6 +272,7 @@ export async function initI18n(forceLocale?: string) {
|
||||
logger.error('Failed to initialize i18n, falling back to default locale', error);
|
||||
activateLocale(DEFAULT_LOCALE, messagesEnUS);
|
||||
}
|
||||
initialized = true;
|
||||
return i18n;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -362,6 +362,7 @@ html.platform-native.platform-macos {
|
||||
}
|
||||
|
||||
html {
|
||||
--custom-zoom: 100;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
@@ -369,35 +370,112 @@ html {
|
||||
user-select: var(--user-select);
|
||||
-webkit-user-select: var(--user-select);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-size: calc(var(--custom-zoom, 100) * 0.16px);
|
||||
line-height: 1.5;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
html.platform-native {
|
||||
--custom-zoom: 100;
|
||||
font-size: calc(var(--custom-zoom, 100) * 1%);
|
||||
}
|
||||
|
||||
html.allow-transparency,
|
||||
html.allow-transparency body {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#fluxer-startup-native-titlebar {
|
||||
.fluxer-native-titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--native-titlebar-height);
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 0 0 calc(var(--spacing-3) + env(safe-area-inset-left));
|
||||
background: var(--background-secondary);
|
||||
-webkit-app-region: drag;
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
html.platform-native:not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
.fluxer-native-titlebar[data-platform='macos'] {
|
||||
padding-left: calc(var(--macos-traffic-light-inset) + env(safe-area-inset-left));
|
||||
padding-right: calc(var(--spacing-3) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
html.platform-native:not(.window-focused) .fluxer-native-titlebar,
|
||||
html.platform-native.window-focus-activation-guard .fluxer-native-titlebar {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
color: var(--text-primary-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__wordmark {
|
||||
display: block;
|
||||
height: 0.8125rem;
|
||||
width: auto;
|
||||
color: var(--text-primary-muted);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
gap: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control {
|
||||
-webkit-app-region: no-drag;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
width: 3rem;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control svg {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--user-area-divider-color);
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control:active {
|
||||
background: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control--close:hover {
|
||||
background: #e81123;
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control--close:active {
|
||||
background: #b50d1a;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {currentInstanceTarget} from '@app/features/platform/transport/InstanceHTTP';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {i18n} from '@lingui/core';
|
||||
|
||||
const logger = new Logger('ShortLinkAcceptModalOpener');
|
||||
|
||||
function openAcceptModal(kind: string, open: () => Promise<void>): void {
|
||||
void open().catch((error: unknown) => {
|
||||
logger.error(`Failed to open the ${kind} accept modal`, error);
|
||||
});
|
||||
}
|
||||
|
||||
export function openInvite(code: string): void {
|
||||
openAcceptModal('invite', async () => {
|
||||
const commands = await import('@app/features/invite/commands/InviteCommands');
|
||||
await commands.openAcceptModal(code, currentInstanceTarget());
|
||||
});
|
||||
}
|
||||
|
||||
export function openGift(code: string): void {
|
||||
openAcceptModal('gift', async () => {
|
||||
const commands = await import('@app/features/gift/commands/GiftCommands');
|
||||
await commands.openAcceptModal(code, currentInstanceTarget());
|
||||
});
|
||||
}
|
||||
|
||||
export function openTheme(themeId: string): void {
|
||||
openAcceptModal('theme', async () => {
|
||||
const commands = await import('@app/features/theme/commands/ThemeCommands');
|
||||
commands.openAcceptModal(themeId, i18n, RuntimeConfig.getSnapshot());
|
||||
});
|
||||
}
|
||||
@@ -15,18 +15,19 @@ import {
|
||||
import {isClientBooting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import {useLastLocationPersistence} from '@app/features/navigation/hooks/useLastLocationPersistence';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
import {navigateToWithMobileHistory} from '@app/features/navigation/utils/MobileNavigation';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import * as PushSubscriptionService from '@app/features/platform/push/PushSubscriptionService';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {resolveDocumentURLFromRoot} from '@app/features/platform/URLOriginUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {useHolidayClasses} from '@app/features/theme/hooks/useHolidayClasses';
|
||||
import Location from '@app/features/ui/state/Location';
|
||||
@@ -102,7 +103,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
if (
|
||||
isAuthenticated &&
|
||||
isLocationStateHydrated &&
|
||||
!AccountManager.isSwitching &&
|
||||
!Accounts.isSwitching &&
|
||||
pathname === Routes.ME &&
|
||||
!hasRestoredLocation
|
||||
) {
|
||||
@@ -115,7 +116,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
if (
|
||||
!isAuthenticated ||
|
||||
!isLocationStateHydrated ||
|
||||
AccountManager.isSwitching ||
|
||||
Accounts.isSwitching ||
|
||||
pathname !== Routes.ME ||
|
||||
hasRestoredLocation
|
||||
) {
|
||||
@@ -142,7 +143,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
}, [isAuthenticated]);
|
||||
const normalizeInternalUrl = useCallback((rawUrl: string): string => {
|
||||
try {
|
||||
const u = new URL(rawUrl, window.location.origin);
|
||||
const u = resolveDocumentURLFromRoot(rawUrl);
|
||||
if (u.origin === window.location.origin) {
|
||||
return u.pathname + u.search + u.hash;
|
||||
}
|
||||
@@ -157,7 +158,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
}, [shouldDismissTransientOverlays]);
|
||||
useEffect(() => {
|
||||
if (!SessionManager.isInitialized) return;
|
||||
if (AccountManager.isSwitching) return;
|
||||
if (Accounts.isSwitching) return;
|
||||
const isAuth = Authentication.isAuthenticated;
|
||||
if (isAuth && isStandaloneRoute) return;
|
||||
if (shouldBypassGateway) {
|
||||
@@ -180,17 +181,13 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
RouterUtils.replaceWith(setPathQueryParams(Routes.LOGIN, {redirect_to: pendingRedirectRef.current}));
|
||||
return;
|
||||
}
|
||||
if (isAuth && Initialization.isLoading) {
|
||||
void AuthenticationCommands.ensureSessionStarted();
|
||||
}
|
||||
}, [
|
||||
SessionManager.isInitialized,
|
||||
authToken,
|
||||
AccountManager.isSwitching,
|
||||
Accounts.isSwitching,
|
||||
Authentication.isAuthenticated,
|
||||
GatewayConnection.isConnected,
|
||||
GatewayConnection.isConnecting,
|
||||
Initialization.isLoading,
|
||||
shouldBypassGateway,
|
||||
shouldSkipAutoRedirect,
|
||||
pendingRedirectRef,
|
||||
@@ -238,12 +235,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
canNavigateToProtectedRoutes,
|
||||
location.pathname,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const shouldSaveLocation = Routes.isChannelRoute(location.pathname) || Routes.isSpecialPage(location.pathname);
|
||||
if (isAuthenticated && shouldSaveLocation) {
|
||||
Location.saveLocation(location.pathname);
|
||||
}
|
||||
}, [isAuthenticated, location.pathname]);
|
||||
useLastLocationPersistence(location.pathname);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !hasRestoredLocation) return;
|
||||
const previousMobileLayoutState = previousMobileLayoutStateRef.current;
|
||||
@@ -297,11 +289,22 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
}
|
||||
lastNotificationNavRef.current = {ts: now, key};
|
||||
void (async () => {
|
||||
if (targetUserId && targetUserId !== AccountManager.currentUserId && AccountManager.canSwitchAccounts) {
|
||||
const targetAccountKey =
|
||||
targetUserId == null ? null : (Accounts.getAccount(targetUserId)?.storageKey ?? null);
|
||||
if (targetUserId != null && targetUserId !== Accounts.currentUserId) {
|
||||
if (targetAccountKey == null || !Accounts.canSwitchAccounts) {
|
||||
logger.error('Cannot activate the notification target account', {targetUserId});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await AccountManager.switchToAccount(targetUserId);
|
||||
await Accounts.switchToAccount(targetAccountKey);
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch account for notification', error);
|
||||
return;
|
||||
}
|
||||
if (Accounts.currentUserId !== targetUserId) {
|
||||
logger.error('Notification target account was not activated', {targetUserId});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mobileLayoutState.enabled) {
|
||||
@@ -327,7 +330,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
if (urlParams.get('fromNotification') === '1') {
|
||||
const newParams = new URLSearchParams(urlParams);
|
||||
newParams.delete('fromNotification');
|
||||
const cleanUrl = new URL(location.pathname, window.location.origin);
|
||||
const cleanUrl = resolveDocumentURLFromRoot(location.pathname);
|
||||
cleanUrl.search = newParams.toString();
|
||||
const cleanPath = `${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`;
|
||||
if (mobileLayoutState.enabled) {
|
||||
@@ -370,7 +373,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
)}
|
||||
{(shouldShowLoadingSkeleton || isLoadingSkeletonRetained) && (
|
||||
<AppSkeleton
|
||||
key={`loading-skeleton:${AccountManager.currentUserId ?? 'unauthenticated'}`}
|
||||
key={`loading-skeleton:${Accounts.currentUserId ?? 'unauthenticated'}`}
|
||||
effectivePathname={effectiveSkeletonPathname ?? pendingRestorableSkeletonPathname ?? pathname}
|
||||
isExiting={!shouldShowLoadingSkeleton}
|
||||
onTransitionEnd={handleLoadingSkeletonTransitionEnd}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
|
||||
import {resolveForgotPasswordRedirect} from '@app/features/auth/flow/AccountRecoveryRedirects';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import {safeRedirectTarget, safeRedirectTargetOrFallback} from '@app/features/auth/utils/SafeRedirect';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {Redirect, type RouteContext, type To} from '@app/features/platform/components/router/RouterTypes';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {resolveDocumentURLFromRoot} from '@app/features/platform/URLOriginUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
|
||||
const logger = new Logger('AuthRouteRedirectPolicy');
|
||||
|
||||
export type AuthRouteEnterHandler = (context: RouteContext) => Redirect | undefined;
|
||||
|
||||
function resolveToPath(to: To): string {
|
||||
if (typeof to === 'string') {
|
||||
return to;
|
||||
}
|
||||
const url = resolveDocumentURLFromRoot(to.to);
|
||||
if (to.search) {
|
||||
url.search = '';
|
||||
for (const [key, value] of Object.entries(to.search)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === null) {
|
||||
url.searchParams.set(key, '');
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (to.hash) {
|
||||
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
|
||||
function isStillOnRouteContext(context: RouteContext): boolean {
|
||||
const current = window.location.pathname + window.location.search + window.location.hash;
|
||||
return current === context.url.pathname + context.url.search + context.url.hash;
|
||||
}
|
||||
|
||||
function applyDeferredSessionRedirect(context: RouteContext, handler: AuthRouteEnterHandler): void {
|
||||
if (!isStillOnRouteContext(context)) {
|
||||
return;
|
||||
}
|
||||
const redirect = handler(context);
|
||||
if (redirect instanceof Redirect) {
|
||||
RouterUtils.replaceWith(resolveToPath(redirect.to));
|
||||
}
|
||||
}
|
||||
|
||||
export function isDesktopHandoffLocation(): boolean {
|
||||
return isHandoffRequest(new URLSearchParams(window.location.search));
|
||||
}
|
||||
|
||||
export function resolveCurrentRedirectTarget(fallback: string): string {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
return safeRedirectTargetOrFallback(queryParams.get('redirect_to'), fallback);
|
||||
}
|
||||
|
||||
export function afterSessionInitialization(handler: AuthRouteEnterHandler): AuthRouteEnterHandler {
|
||||
return (context: RouteContext): Redirect | undefined => {
|
||||
if (SessionManager.isInitialized) {
|
||||
return handler(context);
|
||||
}
|
||||
void SessionManager.initialize()
|
||||
.then(() => {
|
||||
applyDeferredSessionRedirect(context, handler);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Failed to initialize the auth session for a route decision', error);
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function whenAuthenticated(handler: AuthRouteEnterHandler): AuthRouteEnterHandler {
|
||||
return afterSessionInitialization((context) => (Authentication.isAuthenticated ? handler(context) : undefined));
|
||||
}
|
||||
|
||||
export const requireAuthentication: AuthRouteEnterHandler = afterSessionInitialization((context) =>
|
||||
Authentication.isAuthenticated
|
||||
? undefined
|
||||
: new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: context.url.pathname + context.url.search})),
|
||||
);
|
||||
|
||||
export function resolveAuthenticatedLoginEntry(): Redirect | undefined {
|
||||
if (isDesktopHandoffLocation()) {
|
||||
return undefined;
|
||||
}
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const redirectTarget = safeRedirectTarget(queryParams.get('redirect_to'));
|
||||
if (redirectTarget == null || redirectTarget === '') {
|
||||
return new Redirect(Routes.ME);
|
||||
}
|
||||
return new Redirect(redirectTarget);
|
||||
}
|
||||
|
||||
export function resolveEmailFeatureEntry(): Redirect | undefined {
|
||||
const snapshot = RuntimeConfig.getSnapshotOrNull();
|
||||
if (snapshot === null || snapshot.features.emails_enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
|
||||
function resolveHomeEntry(): Redirect {
|
||||
return new Redirect(Routes.ME);
|
||||
}
|
||||
|
||||
function activeSnapshotUsesUsernameSignIn(): boolean {
|
||||
const snapshot = RuntimeConfig.getSnapshotOrNull();
|
||||
return snapshot !== null && usesUsernameSignIn(snapshot.features);
|
||||
}
|
||||
|
||||
export function resolvePasswordResetEntry(): Redirect | undefined {
|
||||
if (activeSnapshotUsesUsernameSignIn()) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveEmailFeatureEntry();
|
||||
}
|
||||
|
||||
export function resolveRecoverAccountEntry(context: RouteContext): Redirect | undefined {
|
||||
const snapshot = RuntimeConfig.getSnapshotOrNull();
|
||||
if (snapshot !== null && !usesUsernameSignIn(snapshot.features)) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return whenAuthenticated(resolveHomeEntry)(context);
|
||||
}
|
||||
|
||||
export function resolveForgotPasswordEntry(context: RouteContext): Redirect | undefined {
|
||||
const snapshot = RuntimeConfig.getSnapshotOrNull();
|
||||
const redirect = snapshot === null ? null : resolveForgotPasswordRedirect(snapshot);
|
||||
if (redirect !== null) {
|
||||
return new Redirect(redirect);
|
||||
}
|
||||
return whenAuthenticated(resolveHomeEntry)(context);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import * as AuthRouteRedirectPolicy from '@app/app/router/policy/AuthRouteRedirectPolicy';
|
||||
import {Redirect, type RouteContext} from '@app/features/platform/components/router/RouterTypes';
|
||||
|
||||
export interface ShortLinkRouteEnterHandlers {
|
||||
readonly onRegisterEnter: AuthRouteRedirectPolicy.AuthRouteEnterHandler;
|
||||
readonly onLoginEnter: AuthRouteRedirectPolicy.AuthRouteEnterHandler;
|
||||
}
|
||||
|
||||
interface ShortLinkRouteEnterRequest {
|
||||
readonly paramName: string;
|
||||
readonly openAcceptModal: (value: string) => void;
|
||||
}
|
||||
|
||||
interface AcceptShortLinkRequest {
|
||||
readonly context: RouteContext;
|
||||
readonly paramName: string;
|
||||
readonly openAcceptModal: (value: string) => void;
|
||||
}
|
||||
|
||||
function acceptShortLink({context, paramName, openAcceptModal}: AcceptShortLinkRequest): Redirect {
|
||||
const value = context.params[paramName];
|
||||
if (value != null && value !== '') {
|
||||
openAcceptModal(value);
|
||||
}
|
||||
return new Redirect(AuthRouteRedirectPolicy.resolveCurrentRedirectTarget(Routes.ME));
|
||||
}
|
||||
|
||||
function acceptShortLinkUnlessDesktopHandoff(request: AcceptShortLinkRequest): Redirect | undefined {
|
||||
if (AuthRouteRedirectPolicy.isDesktopHandoffLocation()) {
|
||||
return undefined;
|
||||
}
|
||||
return acceptShortLink(request);
|
||||
}
|
||||
|
||||
export function createHandlers({paramName, openAcceptModal}: ShortLinkRouteEnterRequest): ShortLinkRouteEnterHandlers {
|
||||
return {
|
||||
onRegisterEnter: AuthRouteRedirectPolicy.whenAuthenticated((context) =>
|
||||
acceptShortLink({context, paramName, openAcceptModal}),
|
||||
),
|
||||
onLoginEnter: AuthRouteRedirectPolicy.whenAuthenticated((context) =>
|
||||
acceptShortLinkUnlessDesktopHandoff({context, paramName, openAcceptModal}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {GuildChannelRouter} from '@app/app/router/components/GuildChannelRouter';
|
||||
import {requireAuthentication, whenAuthenticated} from '@app/app/router/policy/AuthRouteRedirectPolicy';
|
||||
import {rootRoute} from '@app/app/router/routes/RootRoutes';
|
||||
import {AppBadge} from '@app/features/app/components/AppNotificationBadge';
|
||||
import {AppLayout} from '@app/features/app/components/layout/AppLayout';
|
||||
@@ -9,14 +10,15 @@ import {DiscoveryLayout} from '@app/features/app/components/layout/DiscoveryLayo
|
||||
import {FavoritesLayout} from '@app/features/app/components/layout/FavoritesLayout';
|
||||
import {GuildsLayout} from '@app/features/app/components/layout/GuildsLayout';
|
||||
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import {
|
||||
AuthenticatedRuntimeInvariantError,
|
||||
readAuthenticatedRuntimeContext,
|
||||
} from '@app/features/auth/state/AuthenticatedRuntime';
|
||||
import {ChannelIndexPage} from '@app/features/channel/components/ChannelIndexPage';
|
||||
import {ChannelLayout} from '@app/features/channel/components/ChannelLayout';
|
||||
import {DMLayout} from '@app/features/channel/components/direct_message/DirectMessageLayout';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import {navigateToLinkedUserProfile} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {getDirectMessagesFallbackPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
@@ -34,10 +36,18 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {type ReactNode, useEffect, useState} from 'react';
|
||||
|
||||
type AppRouteComponentProps = Record<string, unknown>;
|
||||
|
||||
function authenticatedRuntimeDisablesDirectMessages(): boolean {
|
||||
const runtimeContext = readAuthenticatedRuntimeContext();
|
||||
if (runtimeContext === null) {
|
||||
throw new AuthenticatedRuntimeInvariantError('An authenticated route requires an active account runtime');
|
||||
}
|
||||
return runtimeContext.runtime.community.direct_messages_disabled;
|
||||
}
|
||||
|
||||
const YouPage = createNamedLoadableComponent<AppRouteComponentProps>({
|
||||
displayName: 'YouPage',
|
||||
load: async () => (await import('@app/features/app/components/pages/YouPage')).YouPage,
|
||||
@@ -77,24 +87,24 @@ const StatusChangeBottomSheet = createNamedLoadableComponent<AppRouteComponentPr
|
||||
(await import('@app/features/user/components/modals/StatusChangeBottomSheet')).StatusChangeBottomSheet,
|
||||
});
|
||||
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'appLayout',
|
||||
onEnter: () => {
|
||||
if (!SessionManager.isInitialized) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Authentication.isAuthenticated) {
|
||||
const current = window.location.pathname + window.location.search;
|
||||
return new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
layout: ({children}) => (
|
||||
const ProtectedAppLayout = observer(({children}: {children: ReactNode}) => {
|
||||
if (readAuthenticatedRuntimeContext() === null && !SessionManager.isSwitching) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AppBadge data-flx="app.router.app-routes.layout.app-badge" />
|
||||
<AppLayout data-flx="app.router.app-routes.layout.app-layout">{children}</AppLayout>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'appLayout',
|
||||
onEnter: requireAuthentication,
|
||||
layout: ({children}) => (
|
||||
<ProtectedAppLayout data-flx="app.router.app-routes.layout.protected-app-layout">{children}</ProtectedAppLayout>
|
||||
),
|
||||
});
|
||||
const guildsLayoutRoute = createRoute({
|
||||
@@ -162,6 +172,7 @@ export const premiumCallbackRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'premiumCallback',
|
||||
path: Routes.PREMIUM_CALLBACK,
|
||||
onEnter: requireAuthentication,
|
||||
preload: PremiumCallbackPage.preload,
|
||||
component: () => <PremiumCallbackPage data-flx="app.router.app-routes.premium-callback-page" />,
|
||||
});
|
||||
@@ -197,12 +208,12 @@ const meRoute = createRoute({
|
||||
getParentRoute: () => guildsLayoutRoute,
|
||||
id: 'me',
|
||||
path: '/channels/@me',
|
||||
onEnter: () => {
|
||||
if (RuntimeConfig.directMessagesDisabled) {
|
||||
onEnter: whenAuthenticated(() => {
|
||||
if (authenticatedRuntimeDisablesDirectMessages()) {
|
||||
return new Redirect(getDirectMessagesFallbackPath());
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
component: observer(() => {
|
||||
const isMobileLayout = MobileLayout.enabled;
|
||||
useEffect(() => {
|
||||
@@ -297,9 +308,9 @@ const channelRoute = createRoute({
|
||||
getParentRoute: () => channelsRoute,
|
||||
id: 'channel',
|
||||
path: '/channels/:guildId/:channelId',
|
||||
onEnter: (ctx) => {
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const {guildId, channelId} = ctx.params;
|
||||
if (guildId === ME && RuntimeConfig.directMessagesDisabled) {
|
||||
if (guildId === ME && authenticatedRuntimeDisablesDirectMessages()) {
|
||||
return new Redirect(getDirectMessagesFallbackPath());
|
||||
}
|
||||
const channel = Channels.getChannel(channelId);
|
||||
@@ -307,7 +318,7 @@ const channelRoute = createRoute({
|
||||
return new Redirect(Routes.guildChannel(guildId));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
component: () => (
|
||||
<ChannelLayout data-flx="app.router.app-routes.channel-layout--2">
|
||||
<ChannelIndexPage data-flx="app.router.app-routes.channel-index-page--2" />
|
||||
@@ -318,9 +329,9 @@ const messageRoute = createRoute({
|
||||
getParentRoute: () => channelRoute,
|
||||
id: 'message',
|
||||
path: '/channels/:guildId/:channelId/:messageId',
|
||||
onEnter: (ctx) => {
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const {guildId, channelId} = ctx.params;
|
||||
if (guildId === ME && RuntimeConfig.directMessagesDisabled) {
|
||||
if (guildId === ME && authenticatedRuntimeDisablesDirectMessages()) {
|
||||
return new Redirect(getDirectMessagesFallbackPath());
|
||||
}
|
||||
const channel = Channels.getChannel(channelId);
|
||||
@@ -328,7 +339,7 @@ const messageRoute = createRoute({
|
||||
return new Redirect(Routes.guildChannel(guildId));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
component: () => (
|
||||
<ChannelLayout data-flx="app.router.app-routes.channel-layout--3">
|
||||
<ChannelIndexPage data-flx="app.router.app-routes.channel-index-page--3" />
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import * as AuthRouteRedirectPolicy from '@app/app/router/policy/AuthRouteRedirectPolicy';
|
||||
import * as ShortLinkRouteEnterPolicy from '@app/app/router/policy/ShortLinkRouteEnterPolicy';
|
||||
import {rootRoute} from '@app/app/router/routes/RootRoutes';
|
||||
import * as ShortLinkAcceptModalOpener from '@app/app/router/ShortLinkAcceptModalOpener';
|
||||
import {AuthLayout} from '@app/features/app/components/layout/AuthLayout';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {
|
||||
type AuthRoutePage,
|
||||
createAuthRoutePage,
|
||||
createNamedAuthRoutePage,
|
||||
} from '@app/features/auth/flow/AuthLoadableRoutePage';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import {safeRedirectTarget, safeRedirectTargetOrFallback} from '@app/features/auth/utils/SafeRedirect';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {createRoute} from '@app/features/platform/components/router/RouterBuilder';
|
||||
import type {RouteConfig, RouteContext} from '@app/features/platform/components/router/RouterTypes';
|
||||
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {type AuthRoutePage, createAuthRoutePage} from '@app/features/auth/flow/AuthLoadableRoutePage';
|
||||
import {createRoute, type RouteBuilder} from '@app/features/platform/components/router/RouterBuilder';
|
||||
import {NotFound, Redirect, type RouteContext} from '@app/features/platform/components/router/RouterTypes';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {i18n} from '@lingui/core';
|
||||
|
||||
const AuthorizeIPPage = createAuthRoutePage(
|
||||
'AuthorizeIPPage',
|
||||
@@ -50,8 +41,8 @@ const ResetPasswordPage = createAuthRoutePage(
|
||||
'ResetPasswordPage',
|
||||
() => import('@app/features/auth/components/pages/ResetPasswordPage'),
|
||||
);
|
||||
const SsoCallbackPage = createAuthRoutePage(
|
||||
'SsoCallbackPage',
|
||||
const SSOCallbackPage = createAuthRoutePage(
|
||||
'SSOCallbackPage',
|
||||
() => import('@app/features/auth/components/pages/SsoCallbackPage'),
|
||||
);
|
||||
const VerifyEmailPage = createAuthRoutePage(
|
||||
@@ -74,10 +65,9 @@ const InviteRegisterPage = createAuthRoutePage(
|
||||
'InviteRegisterPage',
|
||||
() => import('@app/features/invite/components/pages/InviteRegisterPage'),
|
||||
);
|
||||
const ReportPage = createNamedAuthRoutePage('ReportPage', async () => {
|
||||
const module = await import('@app/features/moderation/components/pages/ReportPage');
|
||||
return module.ReportPage;
|
||||
});
|
||||
const ReportPage = createAuthRoutePage('ReportPage', async () => ({
|
||||
default: (await import('@app/features/moderation/components/pages/ReportPage')).ReportPage,
|
||||
}));
|
||||
const ThemeLoginPage = createAuthRoutePage(
|
||||
'ThemeLoginPage',
|
||||
() => import('@app/features/theme/components/pages/ThemeLoginPage'),
|
||||
@@ -87,81 +77,23 @@ const ThemeRegisterPage = createAuthRoutePage(
|
||||
() => import('@app/features/theme/components/pages/ThemeRegisterPage'),
|
||||
);
|
||||
|
||||
const currentRedirectTarget = (fallback: string): string => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
return safeRedirectTargetOrFallback(qp.get('redirect_to'), fallback);
|
||||
};
|
||||
|
||||
const resolveToPath = (to: Redirect['to']): string => {
|
||||
if (typeof to === 'string') {
|
||||
return to;
|
||||
}
|
||||
const url = new URL(to.to, window.location.origin);
|
||||
if (to.search) {
|
||||
url.search = '';
|
||||
for (const [k, v] of Object.entries(to.search)) {
|
||||
if (v === undefined) continue;
|
||||
if (v === null) {
|
||||
url.searchParams.set(k, '');
|
||||
} else {
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (to.hash) {
|
||||
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
};
|
||||
|
||||
type AuthRedirectHandler = (ctx: RouteContext) => Redirect | undefined;
|
||||
|
||||
const redirectWhenEmailsDisabled: RouteConfig['onEnter'] = () => {
|
||||
if (!RuntimeConfig.emailsEnabled) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const redirectWhenPasswordResetUnavailable: RouteConfig['onEnter'] = () => {
|
||||
if (!RuntimeConfig.emailsEnabled && !RuntimeConfig.usesUsernameSignIn) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const whenAuthenticated = (handler: AuthRedirectHandler) => {
|
||||
return (ctx: RouteContext): Redirect | undefined => {
|
||||
const execute = (): Redirect | undefined => handler(ctx);
|
||||
if (SessionManager.isInitialized) {
|
||||
return Authentication.isAuthenticated ? execute() : undefined;
|
||||
}
|
||||
void SessionManager.initialize().then(() => {
|
||||
if (Authentication.isAuthenticated) {
|
||||
const res = execute();
|
||||
if (res instanceof Redirect) {
|
||||
RouterUtils.replaceWith(resolveToPath(res.to));
|
||||
}
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
const authLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'authLayout',
|
||||
layout: ({children}) => <AuthLayout data-flx="app.router.auth-routes.layout.auth-layout">{children}</AuthLayout>,
|
||||
});
|
||||
|
||||
type AuthPageEnterHandler = (context: RouteContext) => Redirect | NotFound | undefined;
|
||||
|
||||
interface AuthPageRouteOptions {
|
||||
id: string;
|
||||
path: string;
|
||||
page: AuthRoutePage;
|
||||
dataFlx: string;
|
||||
onEnter?: RouteConfig['onEnter'];
|
||||
onEnter?: AuthPageEnterHandler;
|
||||
}
|
||||
|
||||
function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageRouteOptions) {
|
||||
function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageRouteOptions): RouteBuilder {
|
||||
return createRoute({
|
||||
getParentRoute: () => authLayoutRoute,
|
||||
id,
|
||||
@@ -172,7 +104,7 @@ function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageR
|
||||
});
|
||||
}
|
||||
|
||||
function createAuthRedirectRoute(id: string, path: string) {
|
||||
function createAuthRedirectRoute(id: string, path: string): RouteBuilder {
|
||||
return createRoute({
|
||||
getParentRoute: () => authLayoutRoute,
|
||||
id,
|
||||
@@ -181,25 +113,84 @@ function createAuthRedirectRoute(id: string, path: string) {
|
||||
});
|
||||
}
|
||||
|
||||
interface ShortLinkRoutePaths {
|
||||
readonly register: string;
|
||||
readonly login: string;
|
||||
}
|
||||
|
||||
interface ShortLinkAuthRouteOptions {
|
||||
id: string;
|
||||
dataFlxPrefix: string;
|
||||
paths: ShortLinkRoutePaths;
|
||||
registerPage: AuthRoutePage;
|
||||
loginPage: AuthRoutePage;
|
||||
handlers: {readonly onRegisterEnter: AuthPageEnterHandler; readonly onLoginEnter: AuthPageEnterHandler};
|
||||
}
|
||||
|
||||
function createShortLinkAuthRoutes({
|
||||
id,
|
||||
dataFlxPrefix,
|
||||
paths,
|
||||
registerPage,
|
||||
loginPage,
|
||||
handlers,
|
||||
}: ShortLinkAuthRouteOptions): Array<RouteBuilder> {
|
||||
return [
|
||||
createAuthPageRoute({
|
||||
id: `${id}Register`,
|
||||
path: paths.register,
|
||||
page: registerPage,
|
||||
dataFlx: `${dataFlxPrefix}-register-page`,
|
||||
onEnter: handlers.onRegisterEnter,
|
||||
}),
|
||||
createAuthPageRoute({
|
||||
id: `${id}Login`,
|
||||
path: paths.login,
|
||||
page: loginPage,
|
||||
dataFlx: `${dataFlxPrefix}-login-page`,
|
||||
onEnter: handlers.onLoginEnter,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const inviteRouteEnterHandlers = ShortLinkRouteEnterPolicy.createHandlers({
|
||||
paramName: 'code',
|
||||
openAcceptModal: ShortLinkAcceptModalOpener.openInvite,
|
||||
});
|
||||
const giftShortLinkHandlers = ShortLinkRouteEnterPolicy.createHandlers({
|
||||
paramName: 'code',
|
||||
openAcceptModal: ShortLinkAcceptModalOpener.openGift,
|
||||
});
|
||||
|
||||
function whenGiftingAvailable(handler: AuthRouteRedirectPolicy.AuthRouteEnterHandler): AuthPageEnterHandler {
|
||||
return (context: RouteContext): Redirect | NotFound | undefined => {
|
||||
if (RuntimeConfig.getSnapshotOrNull() !== null && !shouldShowPremiumFeatures()) {
|
||||
return new NotFound();
|
||||
}
|
||||
return handler(context);
|
||||
};
|
||||
}
|
||||
|
||||
const giftRouteEnterHandlers = {
|
||||
onRegisterEnter: whenGiftingAvailable(giftShortLinkHandlers.onRegisterEnter),
|
||||
onLoginEnter: whenGiftingAvailable(giftShortLinkHandlers.onLoginEnter),
|
||||
};
|
||||
const themeRouteEnterHandlers = ShortLinkRouteEnterPolicy.createHandlers({
|
||||
paramName: 'themeId',
|
||||
openAcceptModal: ShortLinkAcceptModalOpener.openTheme,
|
||||
});
|
||||
|
||||
const loginRoute = createAuthPageRoute({
|
||||
id: 'login',
|
||||
path: '/login',
|
||||
page: LoginPage,
|
||||
dataFlx: 'app.router.auth-routes.login-page',
|
||||
onEnter: whenAuthenticated(() => {
|
||||
const search = window.location.search;
|
||||
const qp = new URLSearchParams(search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const redirectTo = safeRedirectTarget(qp.get('redirect_to'));
|
||||
return new Redirect(redirectTo || Routes.ME);
|
||||
}),
|
||||
onEnter: AuthRouteRedirectPolicy.whenAuthenticated(AuthRouteRedirectPolicy.resolveAuthenticatedLoginEntry),
|
||||
});
|
||||
const ssoCallbackRoute = createAuthPageRoute({
|
||||
id: 'ssoCallback',
|
||||
path: Routes.SSO_CALLBACK,
|
||||
page: SsoCallbackPage,
|
||||
page: SSOCallbackPage,
|
||||
dataFlx: 'app.router.auth-routes.sso-callback-page',
|
||||
});
|
||||
const inviteBaseRoute = createAuthRedirectRoute('inviteBase', '/invite');
|
||||
@@ -216,137 +207,58 @@ const oauthAuthorizeRoute = createAuthPageRoute({
|
||||
path: Routes.OAUTH_AUTHORIZE,
|
||||
page: OAuthAuthorizePage,
|
||||
dataFlx: 'app.router.auth-routes.o-auth-authorize-page',
|
||||
onEnter: () => {
|
||||
const current = window.location.pathname + window.location.search;
|
||||
if (!SessionManager.isInitialized) {
|
||||
void SessionManager.initialize().then(() => {
|
||||
if (!Authentication.isAuthenticated) {
|
||||
RouterUtils.replaceWith(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (!Authentication.isAuthenticated) {
|
||||
return new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
onEnter: AuthRouteRedirectPolicy.requireAuthentication,
|
||||
});
|
||||
const inviteRegisterRoute = createAuthPageRoute({
|
||||
id: 'inviteRegister',
|
||||
path: '/invite/:code',
|
||||
page: InviteRegisterPage,
|
||||
dataFlx: 'app.router.auth-routes.invite-register-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/invite/commands/InviteCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
const inviteRoutes = createShortLinkAuthRoutes({
|
||||
id: 'invite',
|
||||
dataFlxPrefix: 'app.router.auth-routes.invite',
|
||||
paths: {register: Routes.INVITE_REGISTER, login: Routes.INVITE_LOGIN},
|
||||
registerPage: InviteRegisterPage,
|
||||
loginPage: InviteLoginPage,
|
||||
handlers: inviteRouteEnterHandlers,
|
||||
});
|
||||
const inviteLoginRoute = createAuthPageRoute({
|
||||
id: 'inviteLogin',
|
||||
path: '/invite/:code/login',
|
||||
page: InviteLoginPage,
|
||||
dataFlx: 'app.router.auth-routes.invite-login-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/invite/commands/InviteCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
});
|
||||
const giftRegisterRoute = createAuthPageRoute({
|
||||
id: 'giftRegister',
|
||||
path: '/gift/:code',
|
||||
page: GiftRegisterPage,
|
||||
dataFlx: 'app.router.auth-routes.gift-register-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/gift/commands/GiftCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
});
|
||||
const giftLoginRoute = createAuthPageRoute({
|
||||
id: 'giftLogin',
|
||||
path: '/gift/:code/login',
|
||||
page: GiftLoginPage,
|
||||
dataFlx: 'app.router.auth-routes.gift-login-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/gift/commands/GiftCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
const giftRoutes = createShortLinkAuthRoutes({
|
||||
id: 'gift',
|
||||
dataFlxPrefix: 'app.router.auth-routes.gift',
|
||||
paths: {register: Routes.GIFT_REGISTER, login: Routes.GIFT_LOGIN},
|
||||
registerPage: GiftRegisterPage,
|
||||
loginPage: GiftLoginPage,
|
||||
handlers: giftRouteEnterHandlers,
|
||||
});
|
||||
const forgotPasswordRoute = createAuthPageRoute({
|
||||
id: 'forgotPassword',
|
||||
path: Routes.FORGOT_PASSWORD,
|
||||
page: ForgotPasswordPage,
|
||||
dataFlx: 'app.router.auth-routes.forgot-password-page',
|
||||
onEnter: (ctx) => {
|
||||
if (RuntimeConfig.usesUsernameSignIn) {
|
||||
return new Redirect(Routes.RECOVER_ACCOUNT);
|
||||
}
|
||||
if (!RuntimeConfig.emailsEnabled) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return whenAuthenticated(() => new Redirect(Routes.ME))(ctx);
|
||||
},
|
||||
onEnter: AuthRouteRedirectPolicy.resolveForgotPasswordEntry,
|
||||
});
|
||||
const recoverAccountRoute = createAuthPageRoute({
|
||||
id: 'recoverAccount',
|
||||
path: Routes.RECOVER_ACCOUNT,
|
||||
page: RecoverAccountPage,
|
||||
dataFlx: 'app.router.auth-routes.recover-account-page',
|
||||
onEnter: (ctx) => {
|
||||
if (!RuntimeConfig.usesUsernameSignIn) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return whenAuthenticated(() => new Redirect(Routes.ME))(ctx);
|
||||
},
|
||||
onEnter: AuthRouteRedirectPolicy.resolveRecoverAccountEntry,
|
||||
});
|
||||
const resetPasswordRoute = createAuthPageRoute({
|
||||
id: 'resetPassword',
|
||||
path: Routes.RESET_PASSWORD,
|
||||
page: ResetPasswordPage,
|
||||
dataFlx: 'app.router.auth-routes.reset-password-page',
|
||||
onEnter: redirectWhenPasswordResetUnavailable,
|
||||
onEnter: AuthRouteRedirectPolicy.resolvePasswordResetEntry,
|
||||
});
|
||||
const emailRevertRoute = createAuthPageRoute({
|
||||
id: 'emailRevert',
|
||||
path: Routes.EMAIL_REVERT,
|
||||
page: EmailRevertPage,
|
||||
dataFlx: 'app.router.auth-routes.email-revert-page',
|
||||
onEnter: redirectWhenEmailsDisabled,
|
||||
onEnter: AuthRouteRedirectPolicy.resolveEmailFeatureEntry,
|
||||
});
|
||||
const verifyEmailRoute = createAuthPageRoute({
|
||||
id: 'verifyEmail',
|
||||
path: Routes.VERIFY_EMAIL,
|
||||
page: VerifyEmailPage,
|
||||
dataFlx: 'app.router.auth-routes.verify-email-page',
|
||||
onEnter: redirectWhenEmailsDisabled,
|
||||
onEnter: AuthRouteRedirectPolicy.resolveEmailFeatureEntry,
|
||||
});
|
||||
const authorizeIPRoute = createAuthPageRoute({
|
||||
id: 'authorizeIP',
|
||||
@@ -366,34 +278,14 @@ const themeRegisterRoute = createAuthPageRoute({
|
||||
path: Routes.THEME_REGISTER,
|
||||
page: ThemeRegisterPage,
|
||||
dataFlx: 'app.router.auth-routes.theme-register-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const themeId = ctx.params.themeId;
|
||||
if (themeId) {
|
||||
void import('@app/features/theme/commands/ThemeCommands').then((commands) => {
|
||||
commands.openAcceptModal(themeId, i18n);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
onEnter: themeRouteEnterHandlers.onRegisterEnter,
|
||||
});
|
||||
const themeLoginRoute = createAuthPageRoute({
|
||||
id: 'themeLogin',
|
||||
path: Routes.THEME_LOGIN,
|
||||
page: ThemeLoginPage,
|
||||
dataFlx: 'app.router.auth-routes.theme-login-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const themeId = ctx.params.themeId;
|
||||
if (themeId) {
|
||||
void import('@app/features/theme/commands/ThemeCommands').then((commands) => {
|
||||
commands.openAcceptModal(themeId, i18n);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
onEnter: themeRouteEnterHandlers.onLoginEnter,
|
||||
});
|
||||
|
||||
export const authRouteTree = authLayoutRoute.addChildren([
|
||||
@@ -404,8 +296,8 @@ export const authRouteTree = authLayoutRoute.addChildren([
|
||||
inviteBaseRoute,
|
||||
giftBaseRoute,
|
||||
themeBaseRoute,
|
||||
inviteRegisterRoute,
|
||||
inviteLoginRoute,
|
||||
...inviteRoutes,
|
||||
...giftRoutes,
|
||||
themeRegisterRoute,
|
||||
themeLoginRoute,
|
||||
forgotPasswordRoute,
|
||||
@@ -416,5 +308,4 @@ export const authRouteTree = authLayoutRoute.addChildren([
|
||||
authorizeIPRoute,
|
||||
pendingRoute,
|
||||
reportRoute,
|
||||
...(shouldShowPremiumFeatures() ? [giftRegisterRoute, giftLoginRoute] : []),
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {RootComponent} from '@app/app/router/components/RootComponent';
|
||||
import {afterSessionInitialization} from '@app/app/router/policy/AuthRouteRedirectPolicy';
|
||||
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
|
||||
import {readAuthenticatedRuntimeContext} from '@app/features/auth/state/AuthenticatedRuntime';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
import {createRootRoute, createRoute} from '@app/features/platform/components/router/RouterBuilder';
|
||||
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
|
||||
@@ -21,7 +23,12 @@ export const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'home',
|
||||
path: '/',
|
||||
onEnter: () => new Redirect(getDefaultLandingPath()),
|
||||
onEnter: afterSessionInitialization(() => {
|
||||
if (readAuthenticatedRuntimeContext() === null) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return new Redirect(getDefaultLandingPath());
|
||||
}),
|
||||
});
|
||||
export const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
|
||||
Vendored
+1
-14
@@ -2,7 +2,6 @@
|
||||
|
||||
import MediaEngineFacade from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import type {ElectronAPI} from '@app/features/platform/types/Electron';
|
||||
import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import {Buffer} from 'buffer';
|
||||
|
||||
type MediaEngineInstance = typeof MediaEngineFacade;
|
||||
@@ -21,15 +20,6 @@ interface FluxerDebugApi {
|
||||
|
||||
type FluxerDebugGlobal = Record<string, unknown> & FluxerDebugApi;
|
||||
|
||||
interface FluxerBootstrapGlobal {
|
||||
config: {
|
||||
releaseChannel: 'stable' | 'canary';
|
||||
bootstrapApiEndpoint: string;
|
||||
bootstrapApiPublicEndpoint?: string;
|
||||
};
|
||||
instance: InstanceDiscoveryResponse;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface FilePickerAcceptType {
|
||||
description?: string;
|
||||
@@ -56,9 +46,7 @@ declare global {
|
||||
readonly DEV: boolean;
|
||||
readonly PROD: boolean;
|
||||
readonly PUBLIC_BUILD_VERSION?: string;
|
||||
readonly PUBLIC_RELEASE_CHANNEL?: 'stable' | 'canary';
|
||||
readonly PUBLIC_BOOTSTRAP_API_ENDPOINT?: string;
|
||||
readonly PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT?: string;
|
||||
readonly PUBLIC_RELEASE_CHANNEL?: 'stable' | 'canary' | 'development';
|
||||
}
|
||||
interface ImportMetaHot {
|
||||
readonly data: Record<string, unknown>;
|
||||
@@ -77,7 +65,6 @@ declare global {
|
||||
};
|
||||
}
|
||||
interface Window {
|
||||
__FLUXER_BOOTSTRAP__?: FluxerBootstrapGlobal;
|
||||
__FLUXER_DEBUG__?: FluxerDebugGlobal;
|
||||
__notificationCleanup?: () => void;
|
||||
_mediaEngine?: MediaEngineInstance;
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
readStoredSessionUserId,
|
||||
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
|
||||
import AppStorage from '@app/features/platform/state/PersistentStorage';
|
||||
import {
|
||||
readRawStorageItem,
|
||||
writeRawStorageItem,
|
||||
ZOOM_PREBOOT_MIRROR_STORAGE_KEY,
|
||||
} from '@app/features/platform/state/PrebootMirror';
|
||||
import {initializeStore} from '@app/features/platform/utils/StoreInitialization';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {applyAppZoomToDocument} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {makeSyncedField} from '@app/features/user/state/SyncedField';
|
||||
@@ -151,6 +157,18 @@ function readLocalZoomLevel(storage: StartupSettingsStorage = AppStorage): numbe
|
||||
return typeof parsed === 'number' ? clampZoomLevel(parsed) : null;
|
||||
}
|
||||
|
||||
function readPrebootZoomFactor(): number | null {
|
||||
if (window.electron == null) {
|
||||
return null;
|
||||
}
|
||||
const raw = readRawStorageItem(ZOOM_PREBOOT_MIRROR_STORAGE_KEY);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const zoomPercent = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(zoomPercent) ? clampZoomLevel(zoomPercent / 100) : null;
|
||||
}
|
||||
|
||||
function readStoredBoolean(storage: StartupSettingsStorage, key: string): boolean | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
@@ -458,7 +476,7 @@ function readAccessibilityStartupSettings(
|
||||
storage: StartupSettingsStorage = AppStorage,
|
||||
options: AccessibilityStartupSettingsOptions = {},
|
||||
): AccessibilityStartupSettings | null {
|
||||
const localZoomLevel = readLocalZoomLevel(storage);
|
||||
const localZoomLevel = readPrebootZoomFactor() ?? readLocalZoomLevel(storage);
|
||||
const localMotionSettings = readLocalMotionSettings(storage);
|
||||
const legacySettings = readLegacyAccessibilityStartupSettings(storage);
|
||||
const cachedSyncedMotionSettings = localMotionSettings === null ? readCachedSyncedMotionSettings(storage) : null;
|
||||
@@ -504,6 +522,15 @@ function persistLocalZoomLevel(level: number): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function writeZoomPrebootMirror(level: number, isDesktop: boolean): void {
|
||||
if (!isDesktop) {
|
||||
writeRawStorageItem(ZOOM_PREBOOT_MIRROR_STORAGE_KEY, null);
|
||||
return;
|
||||
}
|
||||
const zoomPercent = Math.max(50, Math.min(200, Math.round(clampZoomLevel(level) * 100)));
|
||||
writeRawStorageItem(ZOOM_PREBOOT_MIRROR_STORAGE_KEY, String(zoomPercent));
|
||||
}
|
||||
|
||||
function persistLocalMotionSettings(settings: LocalMotionSettings): void {
|
||||
try {
|
||||
AppStorage.setItem(ACCESSIBILITY_MOTION_STORAGE_KEY, JSON.stringify(settings));
|
||||
@@ -779,7 +806,7 @@ class Accessibility {
|
||||
this.initializeShowNekoStorageSync();
|
||||
this.initializeVideoSeekPreviewThumbnailsStorageSync();
|
||||
this.applyStartupPresentationSettings();
|
||||
this.initPersistence();
|
||||
initializeStore(this, () => this.initPersistence());
|
||||
}
|
||||
|
||||
get isHydrated(): boolean {
|
||||
@@ -1479,6 +1506,7 @@ class Accessibility {
|
||||
async applyZoom(level: number): Promise<void> {
|
||||
const zoomLevel = clampZoomLevel(level);
|
||||
applyAppZoomToDocument(zoomLevel * 100, window.electron);
|
||||
writeZoomPrebootMirror(zoomLevel, window.electron != null);
|
||||
}
|
||||
|
||||
async applyStoredZoom(): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
|
||||
class StalledConnectionAccountSwitchUnavailableError extends Error {
|
||||
@@ -14,13 +16,21 @@ export function canSwitchAccountFromStalledConnection(): boolean {
|
||||
return SessionManager.canSwitchAccount() || SessionManager.isConnecting;
|
||||
}
|
||||
|
||||
export async function switchAccountFromStalledConnection(userId: string): Promise<void> {
|
||||
export async function switchAccountFromStalledConnection(accountKey: string): Promise<void> {
|
||||
if (!canSwitchAccountFromStalledConnection()) {
|
||||
throw new StalledConnectionAccountSwitchUnavailableError();
|
||||
}
|
||||
SessionManager.requireAccountOnCurrentInstance(userId);
|
||||
SessionManager.requireSwitchableAccount(accountKey);
|
||||
if (SessionManager.isConnecting) {
|
||||
SessionManager.handleConnectionFailed();
|
||||
}
|
||||
await AccountManager.switchToAccount(userId, null);
|
||||
await Accounts.switchToAccount(accountKey, Routes.ME);
|
||||
}
|
||||
|
||||
export function retryStalledConnection(): void {
|
||||
const accountKey = SessionManager.currentAccountKey;
|
||||
if (accountKey === null) {
|
||||
return;
|
||||
}
|
||||
GatewayConnection.recoverForegroundSession(accountKey);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import DesktopBackgroundGateway from '@app/features/gateway/transport/DesktopBackgroundGateway';
|
||||
import GuildReadState from '@app/features/guild/state/GuildReadState';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
@@ -39,6 +40,7 @@ const setElectronBadge = (badge: number): void => {
|
||||
}
|
||||
};
|
||||
const setFaviconBadge = (badge: number): void => {
|
||||
if (badge === 0 && favico === null) return;
|
||||
const fav = initFavico();
|
||||
if (!fav) return;
|
||||
try {
|
||||
@@ -75,7 +77,7 @@ const setBadge = (badge: number): void => {
|
||||
export const AppBadge: React.FC = observer(() => {
|
||||
const relationships = Relationships.getRelationships();
|
||||
const unreadMessageBadgeEnabled = Notification.unreadMessageBadgeEnabled;
|
||||
const mentionCount = GuildReadState.mentionCountAcrossGuilds();
|
||||
const mentionCount = GuildReadState.mentionCountAcrossGuilds() + DesktopBackgroundGateway.totalMentionCount;
|
||||
const hasUnread = GuildReadState.anyGuildUnread;
|
||||
const pendingCount = RuntimeConfig.directMessagesDisabled
|
||||
? 0
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BootstrapErrorKind, classifyBootstrapError} from '@app/features/app/components/BootstrapErrorKind';
|
||||
import {InstanceDiscoveryUnreachableError} from '@fluxer/instance_bootstrap/src/Discovery';
|
||||
import {expect, test} from 'vitest';
|
||||
|
||||
const DESKTOP_UNREACHABLE = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryUnreachableError: No usable instance discovery document was served for https://web.canary.fluxer.app/api: https://web.canary.fluxer.app/.well-known/fluxer (connect ECONNREFUSED 127.0.0.1:9)",
|
||||
);
|
||||
|
||||
test('a desktop discovery that could not reach the instance is a connection failure', () => {
|
||||
expect(classifyBootstrapError(DESKTOP_UNREACHABLE, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('a web discovery that no candidate served is a connection failure', () => {
|
||||
const error = new InstanceDiscoveryUnreachableError([{url: 'https://one.example', reason: 'Failed to fetch'}]);
|
||||
|
||||
expect(classifyBootstrapError(error, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('an unreachable instance wrapped as a cause or inside an aggregate is still a connection failure', () => {
|
||||
const wrapped = new Error('Failed to restore the session', {cause: DESKTOP_UNREACHABLE});
|
||||
const aggregate = new AggregateError([new Error('cancellation was rejected'), wrapped], 'Instance discovery failed');
|
||||
|
||||
expect(classifyBootstrapError(wrapped, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
expect(classifyBootstrapError(aggregate, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('any failure while the device is offline is a connection failure', () => {
|
||||
expect(classifyBootstrapError(new TypeError('Failed to fetch'), false)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('an instance that answered without a usable document, or a local failure, is not a connection failure', () => {
|
||||
const refused = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryFailedError: No usable instance discovery document was served for https://one.example/api: https://one.example/.well-known/fluxer (responded HTTP 404)",
|
||||
);
|
||||
|
||||
expect(classifyBootstrapError(refused, true)).toBe(BootstrapErrorKind.FAILED);
|
||||
expect(classifyBootstrapError(new Error('Stored account record is malformed'), true)).toBe(BootstrapErrorKind.FAILED);
|
||||
expect(classifyBootstrapError('boom', true)).toBe(BootstrapErrorKind.FAILED);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DESKTOP_RUNTIME_DISCOVERY_UNREACHABLE_ERROR_NAME} from '@fluxer/desktop_ipc/src/LocalAppRuntimeContract';
|
||||
import {InstanceDiscoveryUnreachableError} from '@fluxer/instance_bootstrap/src/Discovery';
|
||||
|
||||
export const BootstrapErrorKind = Object.freeze({
|
||||
UNREACHABLE: 'unreachable',
|
||||
FAILED: 'failed',
|
||||
} as const);
|
||||
|
||||
export type BootstrapErrorKind = (typeof BootstrapErrorKind)[keyof typeof BootstrapErrorKind];
|
||||
|
||||
const MAX_CAUSE_DEPTH = 8;
|
||||
|
||||
function reportsUnreachableInstance(error: unknown, depth: number): boolean {
|
||||
if (!(error instanceof Error) || depth > MAX_CAUSE_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
error instanceof InstanceDiscoveryUnreachableError ||
|
||||
error.message.includes(DESKTOP_RUNTIME_DISCOVERY_UNREACHABLE_ERROR_NAME)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof AggregateError && error.errors.some((inner) => reportsUnreachableInstance(inner, depth + 1))) {
|
||||
return true;
|
||||
}
|
||||
return reportsUnreachableInstance(error.cause, depth + 1);
|
||||
}
|
||||
|
||||
export function classifyBootstrapError(error: unknown, online: boolean): BootstrapErrorKind {
|
||||
return !online || reportsUnreachableInstance(error, 0) ? BootstrapErrorKind.UNREACHABLE : BootstrapErrorKind.FAILED;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {afterEach, expect, test, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: ({children}: {children?: ReactNode}) => children ?? null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
vi.mock('@app/features/platform/state/ResetClientState', () => ({
|
||||
ResetClientStateReason: {RESET_APP_DATA: 'reset_app_data'},
|
||||
resetClientState: vi.fn(),
|
||||
}));
|
||||
|
||||
const {BootstrapErrorScreen} = await import('@app/features/app/components/BootstrapErrorScreen');
|
||||
|
||||
const RESET_BUTTON = 'app.bootstrap-error-screen.button.reset';
|
||||
const RETRY_BUTTON = 'app.bootstrap-error-screen.button.retry';
|
||||
const COPY_DETAILS_BUTTON = 'app.bootstrap-error-screen.button.copy-details';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('an unreachable instance offers a retry and never a reset of app data', () => {
|
||||
const error = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryUnreachableError: No usable instance discovery document was served",
|
||||
);
|
||||
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={error} />);
|
||||
|
||||
expect(markup).toContain(RETRY_BUTTON);
|
||||
expect(markup).not.toContain(RESET_BUTTON);
|
||||
expect(markup).not.toContain('corrupted data');
|
||||
});
|
||||
|
||||
test('an unreachable instance keeps the raw error out of the page and offers it as a copy', () => {
|
||||
const error = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryUnreachableError: connect ECONNREFUSED 127.0.0.1:9",
|
||||
);
|
||||
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={error} />);
|
||||
|
||||
expect(markup).not.toContain('DesktopRuntimeDiscoveryUnreachableError');
|
||||
expect(markup).not.toContain('ECONNREFUSED');
|
||||
expect(markup).toContain(COPY_DETAILS_BUTTON);
|
||||
});
|
||||
|
||||
test('a failure while the device is offline never offers a reset of app data', () => {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
|
||||
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={new Error('Stored account record is malformed')} />);
|
||||
|
||||
expect(markup).not.toContain(RESET_BUTTON);
|
||||
});
|
||||
|
||||
test('a local startup failure still offers the reset of app data', () => {
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={new Error('Stored account record is malformed')} />);
|
||||
|
||||
expect(markup).toContain(RETRY_BUTTON);
|
||||
expect(markup).toContain(RESET_BUTTON);
|
||||
expect(markup).toContain('Stored account record is malformed');
|
||||
expect(markup).not.toContain(COPY_DETAILS_BUTTON);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BootstrapErrorKind, classifyBootstrapError} from '@app/features/app/components/BootstrapErrorKind';
|
||||
import styles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {
|
||||
BLUESKY_PROVIDER_NAME,
|
||||
@@ -7,29 +8,59 @@ import {
|
||||
PRODUCT_NAME,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import AppStorage, {
|
||||
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
|
||||
PRESERVED_RESET_STORAGE_KEYS,
|
||||
} from '@app/features/platform/state/PersistentStorage';
|
||||
import {ResetClientStateReason, resetClientState} from '@app/features/platform/state/ResetClientState';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {APPLICATION_ICON_DESCRIPTOR, FluxerIconMark} from '@app/features/ui/components/icons/FluxerIconMark';
|
||||
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
const UNREACHABLE_RETRY_INTERVAL_MS = 30_000;
|
||||
|
||||
interface BootstrapErrorScreenProps {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
function reloadApp(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function describeError(error: Error): string {
|
||||
return error.stack?.trim() || `${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error}) => {
|
||||
const {i18n} = useLingui();
|
||||
const handleRetry = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
const unreachable = classifyBootstrapError(error, navigator.onLine) === BootstrapErrorKind.UNREACHABLE;
|
||||
useEffect(() => {
|
||||
if (!unreachable) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener('online', reloadApp);
|
||||
const retry = window.setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
reloadApp();
|
||||
}
|
||||
}, UNREACHABLE_RETRY_INTERVAL_MS);
|
||||
return () => {
|
||||
window.removeEventListener('online', reloadApp);
|
||||
window.clearInterval(retry);
|
||||
};
|
||||
}, [unreachable]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopyDetails = useCallback(() => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
void import('@app/features/ui/commands/TextCopyCommands')
|
||||
.then(({copy}) => copy(i18n, describeError(error), true))
|
||||
.then(setCopied, () => setCopied(false));
|
||||
}, [error, i18n]);
|
||||
const handleReset = useCallback(() => {
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
|
||||
window.location.reload();
|
||||
void resetClientState({reason: ResetClientStateReason.RESET_APP_DATA, keepDrafts: true}).finally(() =>
|
||||
window.location.reload(),
|
||||
);
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles.errorFallbackContainer} data-flx="app.bootstrap-error-screen.error-fallback-container">
|
||||
@@ -40,14 +71,18 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
|
||||
/>
|
||||
<div className={styles.errorFallbackContent} data-flx="app.bootstrap-error-screen.error-fallback-content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="app.bootstrap-error-screen.error-fallback-title">
|
||||
<Trans>Failed to start</Trans>
|
||||
{unreachable ? <Trans>Can't connect</Trans> : <Trans>Failed to start</Trans>}
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription} data-flx="app.bootstrap-error-screen.error-fallback-description">
|
||||
<Trans>
|
||||
{PRODUCT_NAME} failed to start properly. This could be due to corrupted data or a temporary issue.
|
||||
</Trans>
|
||||
{unreachable ? (
|
||||
<Trans>Check your connection and try again.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{PRODUCT_NAME} failed to start properly. This could be due to corrupted data or a temporary issue.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
{error && (
|
||||
{error && !unreachable && (
|
||||
<p
|
||||
className={styles.errorFallbackDescription}
|
||||
style={{fontSize: '0.875rem', opacity: 0.8}}
|
||||
@@ -75,14 +110,31 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions} data-flx="app.bootstrap-error-screen.error-fallback-actions">
|
||||
<Button onClick={handleRetry} data-flx="app.bootstrap-error-screen.button.retry">
|
||||
<Button onClick={reloadApp} data-flx="app.bootstrap-error-screen.button.retry">
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="danger" data-flx="app.bootstrap-error-screen.button.reset">
|
||||
<Trans comment="Destructive button on the startup failure screen. Clears local app data except preserved drafts.">
|
||||
Reset app data
|
||||
</Trans>
|
||||
</Button>
|
||||
{error && unreachable && (
|
||||
<Button
|
||||
onClick={handleCopyDetails}
|
||||
variant="secondary"
|
||||
data-flx="app.bootstrap-error-screen.button.copy-details"
|
||||
>
|
||||
{copied ? (
|
||||
<Trans comment="Toast shown after copying text to the clipboard succeeds.">Copied to clipboard</Trans>
|
||||
) : (
|
||||
<Trans comment="Button on the crash screen that copies developer diagnostic text.">
|
||||
Copy stack trace
|
||||
</Trans>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!unreachable && (
|
||||
<Button onClick={handleReset} variant="danger" data-flx="app.bootstrap-error-screen.button.reset">
|
||||
<Trans comment="Destructive button on the startup failure screen. Clears local app data except preserved drafts.">
|
||||
Reset app data
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,8 @@
|
||||
import errorFallbackStyles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
|
||||
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import AppStorage, {
|
||||
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
|
||||
PRESERVED_RESET_STORAGE_KEYS,
|
||||
} from '@app/features/platform/state/PersistentStorage';
|
||||
import {ensureLatestAssets} from '@app/features/platform/types/Versioning';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {copy as copyText} from '@app/features/ui/commands/TextCopyCommands';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import LayerManager from '@app/features/ui/state/LayerManager';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
@@ -43,6 +37,16 @@ function getStackTraceText(error: Error | undefined, unknownErrorLabel: string):
|
||||
return `${name}: ${message}`;
|
||||
}
|
||||
|
||||
async function ensureLatestAssets(): Promise<{updateFound: boolean}> {
|
||||
const {ensureLatestAssets: ensure} = await import('@app/features/platform/types/Versioning');
|
||||
return await ensure({force: true});
|
||||
}
|
||||
|
||||
async function resetAppDataExceptDrafts(): Promise<void> {
|
||||
const {ResetClientStateReason, resetClientState} = await import('@app/features/platform/state/ResetClientState');
|
||||
await resetClientState({reason: ResetClientStateReason.RESET_APP_DATA, keepDrafts: true});
|
||||
}
|
||||
|
||||
async function cleanupRuntimeStateOnCrash(): Promise<void> {
|
||||
LayerManager.closeAll();
|
||||
const [{default: GatewayConnection}, {default: MediaEngine}] = await Promise.all([
|
||||
@@ -71,7 +75,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
let isMounted = true;
|
||||
const run = async () => {
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
const {updateFound} = await ensureLatestAssets();
|
||||
if (isMounted) {
|
||||
setUpdateAvailable(updateFound);
|
||||
}
|
||||
@@ -91,7 +95,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
const handleUpdate = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
const {updateFound} = await ensureLatestAssets();
|
||||
if (!updateFound) {
|
||||
setIsUpdating(false);
|
||||
window.location.reload();
|
||||
@@ -107,7 +111,10 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
}
|
||||
setIsCopyingStackTrace(true);
|
||||
try {
|
||||
await copyText(i18n, stackTraceText);
|
||||
const {copy} = await import('@app/features/ui/commands/TextCopyCommands');
|
||||
await copy(i18n, stackTraceText);
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy the stack trace:', error);
|
||||
} finally {
|
||||
setIsCopyingStackTrace(false);
|
||||
}
|
||||
@@ -157,8 +164,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
|
||||
location.reload();
|
||||
void resetAppDataExceptDrafts().finally(() => location.reload());
|
||||
}}
|
||||
variant="danger"
|
||||
disabled={checkingForUpdates}
|
||||
|
||||
@@ -5,28 +5,40 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
interface GenericErrorModalOptions {
|
||||
interface GenericErrorModalContentOptions {
|
||||
title: string | (() => string);
|
||||
message: ReactNode | (() => ReactNode);
|
||||
dataFlx?: string;
|
||||
}
|
||||
|
||||
interface GenericErrorModalOptions extends GenericErrorModalContentOptions {
|
||||
defer?: boolean;
|
||||
}
|
||||
|
||||
export function showGenericErrorModal({title, message, dataFlx, defer}: GenericErrorModalOptions): void {
|
||||
const pushErrorModal = () => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={typeof title === 'function' ? title() : title}
|
||||
message={typeof message === 'function' ? message() : message}
|
||||
data-flx={dataFlx}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
interface GenericErrorModalPortalOptions extends GenericErrorModalContentOptions {
|
||||
portalHost: HTMLElement;
|
||||
}
|
||||
|
||||
function createGenericErrorModal({title, message, dataFlx}: GenericErrorModalContentOptions) {
|
||||
return modal(() => (
|
||||
<GenericErrorModal
|
||||
title={typeof title === 'function' ? title() : title}
|
||||
message={typeof message === 'function' ? message() : message}
|
||||
data-flx={dataFlx}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function showGenericErrorModal({defer, ...content}: GenericErrorModalOptions): void {
|
||||
const pushErrorModal = () => ModalCommands.push(createGenericErrorModal(content));
|
||||
if (defer) {
|
||||
window.setTimeout(pushErrorModal, 0);
|
||||
return;
|
||||
}
|
||||
pushErrorModal();
|
||||
}
|
||||
|
||||
export function showGenericErrorModalInPortal({portalHost, ...content}: GenericErrorModalPortalOptions): void {
|
||||
if (!portalHost.isConnected) return;
|
||||
ModalCommands.pushToPortalHost({modal: createGenericErrorModal(content), portalHost});
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Limits} from '@app/features/app/utils/UserLimits';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import {MAX_BOOKMARKS_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -72,10 +71,10 @@ export const MaxBookmarksModal = observer(({user}: MaxBookmarksModalProps) => {
|
||||
title={i18n._(BOOKMARK_LIMIT_REACHED_DESCRIPTOR)}
|
||||
description={i18n._(YOU_VE_REACHED_THE_MAXIMUM_NUMBER_OF_BOOKMARKS_3_DESCRIPTOR, {
|
||||
count: maxBookmarks,
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
premiumCount: premiumBookmarks,
|
||||
})}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Limits} from '@app/features/app/utils/UserLimits';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {MAX_FAVORITE_MEMES_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {msg, plural} from '@lingui/core/macro';
|
||||
@@ -73,14 +72,14 @@ export const MaxFavoriteMemesModal = observer(() => {
|
||||
}
|
||||
const freeDescription = i18n._(YOU_VE_REACHED_THE_MAXIMUM_LIMIT_OF_FOR_DESCRIPTOR, {
|
||||
count: maxFavoriteMemes,
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
premiumCount: premiumLimit,
|
||||
});
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={i18n._(SAVED_MEDIA_LIMIT_REACHED_DESCRIPTOR)}
|
||||
description={freeDescription}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: hsl(0deg 0% 0%);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import styles from '@app/features/app/components/dialogs/LoadableSettingsModals.module.css';
|
||||
import type {ChannelSettingsModal as ChannelSettingsModalComponent} from '@app/features/channel/components/modals/ChannelSettingsModal';
|
||||
import type {GuildSettingsModal as GuildSettingsModalComponent} from '@app/features/guild/components/modals/GuildSettingsModal';
|
||||
import {CLOSE_DESCRIPTOR, TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
createNamedLoadableComponent,
|
||||
type LoadableErrorProps,
|
||||
} from '@app/features/platform/components/loadable/LoadableComponent';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {Spinner, SpinnerSize} from '@app/features/ui/components/Spinner';
|
||||
import LayerManager from '@app/features/ui/state/LayerManager';
|
||||
import {ModalStackContext} from '@app/features/ui/utils/ModalStackContext';
|
||||
import type {UserSettingsModal as UserSettingsModalComponent} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useContext, useEffect, useId} from 'react';
|
||||
|
||||
const SettingsModalPending = observer(function SettingsModalPending() {
|
||||
const {isVisible, needsBackdrop} = useContext(ModalStackContext);
|
||||
const layerKey = useId();
|
||||
useEffect(() => {
|
||||
LayerManager.addLayer('modal', layerKey, ModalCommands.pop);
|
||||
return () => {
|
||||
LayerManager.removeLayer('modal', layerKey);
|
||||
};
|
||||
}, [layerKey]);
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.overlay} data-flx="app.loadable-settings-modals.settings-modal-pending.overlay">
|
||||
{needsBackdrop && (
|
||||
<div className={styles.backdrop} data-flx="app.loadable-settings-modals.settings-modal-pending.backdrop" />
|
||||
)}
|
||||
<Spinner size={SpinnerSize.LARGE} data-flx="app.loadable-settings-modals.settings-modal-pending.spinner" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function SettingsModalUnavailable({retry}: LoadableErrorProps): React.JSX.Element {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={<Trans>Couldn't load settings</Trans>}
|
||||
description={<Trans>Check your connection and try again.</Trans>}
|
||||
primaryText={i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
onPrimary={retry}
|
||||
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
|
||||
disableAutoDismiss
|
||||
data-flx="app.loadable-settings-modals.settings-modal-unavailable.confirm-modal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const UserSettingsModal = createNamedLoadableComponent<React.ComponentProps<typeof UserSettingsModalComponent>>({
|
||||
displayName: 'UserSettingsModal',
|
||||
load: async () => (await import('@app/features/user/components/modals/UserSettingsModal')).UserSettingsModal,
|
||||
LoadingComponent: SettingsModalPending,
|
||||
ErrorComponent: SettingsModalUnavailable,
|
||||
});
|
||||
|
||||
export const GuildSettingsModal = createNamedLoadableComponent<
|
||||
React.ComponentProps<typeof GuildSettingsModalComponent>
|
||||
>({
|
||||
displayName: 'GuildSettingsModal',
|
||||
load: async () => (await import('@app/features/guild/components/modals/GuildSettingsModal')).GuildSettingsModal,
|
||||
LoadingComponent: SettingsModalPending,
|
||||
ErrorComponent: SettingsModalUnavailable,
|
||||
});
|
||||
|
||||
export const ChannelSettingsModal = createNamedLoadableComponent<
|
||||
React.ComponentProps<typeof ChannelSettingsModalComponent>
|
||||
>({
|
||||
displayName: 'ChannelSettingsModal',
|
||||
load: async () => (await import('@app/features/channel/components/modals/ChannelSettingsModal')).ChannelSettingsModal,
|
||||
LoadingComponent: SettingsModalPending,
|
||||
ErrorComponent: SettingsModalUnavailable,
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import styles from '@app/features/app/components/dialogs/Modal.module.css';
|
||||
import {resolveModalBackdropMotionSpec, resolveModalMotionSpec} from '@app/features/app/components/dialogs/ModalMotion';
|
||||
import {useModalResizePin, useWindowLiveResize} from '@app/features/app/components/dialogs/useModalResizePin';
|
||||
import {useModalBackHandler} from '@app/features/app/hooks/useModalBackHandler';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
@@ -80,6 +81,15 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
onClose,
|
||||
onAnimationComplete,
|
||||
});
|
||||
const isWindowLiveResizing = useWindowLiveResize();
|
||||
const setResizePinRef = useModalResizePin(isWindowLiveResizing, prefersReducedMotion);
|
||||
const setFocusLockRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
refs.setFloating(node);
|
||||
setResizePinRef(node);
|
||||
},
|
||||
[refs, setResizePinRef],
|
||||
);
|
||||
useModalBackHandler(handleClose, disableHistoryManagement);
|
||||
const isFirstModal = stackIndex === 0;
|
||||
useEffect(() => {
|
||||
@@ -265,7 +275,7 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
data-flx="app.modal.floating-focus-manager"
|
||||
>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
ref={setFocusLockRef}
|
||||
aria-labelledby={labelledBy}
|
||||
aria-modal={true}
|
||||
className={styles.focusLock}
|
||||
|
||||
+4
-2
@@ -37,6 +37,7 @@ import {
|
||||
GUILD_SETTINGS_LABEL_DESCRIPTOR,
|
||||
getGuildSettingsCategoryLabel,
|
||||
} from '@app/features/user/components/settings_utils/GuildSettingsConstants';
|
||||
import {getGuildSettingsTabComponent} from '@app/features/user/components/settings_utils/GuildSettingsTabComponents';
|
||||
import {useUnsavedChangesFlash} from '@app/features/user/hooks/useUnsavedChangesFlash';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -80,6 +81,7 @@ interface DesktopGuildSettingsViewProps {
|
||||
export const DesktopGuildSettingsView: React.FC<DesktopGuildSettingsViewProps> = observer(
|
||||
({guild, groupedSettingsTabs, currentTab, selectedTab, onTabSelect}) => {
|
||||
const {i18n} = useLingui();
|
||||
const CurrentTabComponent = currentTab ? getGuildSettingsTabComponent(currentTab.type) : null;
|
||||
const {showUnsavedBanner, flashBanner, tabData, checkUnsavedChanges} = useUnsavedChangesFlash(selectedTab);
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -314,8 +316,8 @@ export const DesktopGuildSettingsView: React.FC<DesktopGuildSettingsViewProps> =
|
||||
scrollKey={scrollKey}
|
||||
data-flx="app.desktop-guild-settings-view.settings-modal-desktop-scroll"
|
||||
>
|
||||
{currentTab && (
|
||||
<currentTab.component
|
||||
{CurrentTabComponent && (
|
||||
<CurrentTabComponent
|
||||
guildId={guild.id}
|
||||
data-flx="app.desktop-guild-settings-view.current-tab-component"
|
||||
/>
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErro
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {VerificationResult} from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {currentInstanceTarget} from '@app/features/platform/transport/InstanceHTTP';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -61,7 +62,7 @@ export const EmailVerificationAlert = observer(({title, children}: EmailVerifica
|
||||
const handleResend = async () => {
|
||||
if (isResending) return;
|
||||
setIsResending(true);
|
||||
const result = await AuthenticationCommands.resendVerificationEmail();
|
||||
const result = await AuthenticationCommands.resendVerificationEmail(currentInstanceTarget());
|
||||
switch (result) {
|
||||
case VerificationResult.SUCCESS:
|
||||
ToastCommands.success(i18n._(VERIFICATION_EMAIL_SENT_PLEASE_CHECK_YOUR_INBOX_DESCRIPTOR));
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
import {ComparisonCheckRow} from '@app/features/app/components/dialogs/components/ComparisonCheckRow';
|
||||
import {ComparisonRow} from '@app/features/app/components/dialogs/components/ComparisonRow';
|
||||
import styles from '@app/features/app/components/dialogs/components/FeatureComparisonTable.module.css';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {Limits} from '@app/features/app/utils/UserLimits';
|
||||
import {COMMUNITIES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {formatFileSize} from '@app/features/messaging/utils/FileUtils';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {
|
||||
isBooleanTierPerk,
|
||||
isNumericTierPerk,
|
||||
@@ -185,7 +185,7 @@ export const FeatureComparisonTable = observer(() => {
|
||||
<Trans>Free</Trans>
|
||||
</div>
|
||||
<div className={styles.headerStock} data-flx="app.feature-comparison-table.header-stock">
|
||||
{PREMIUM_PRODUCT_NAME}
|
||||
{getPremiumProductName()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -21,6 +21,7 @@ import type {
|
||||
GuildSettingsTabType,
|
||||
} from '@app/features/user/components/settings_utils/GuildSettingsConstants';
|
||||
import {getGuildSettingsCategoryLabel} from '@app/features/user/components/settings_utils/GuildSettingsConstants';
|
||||
import {getGuildSettingsTabComponent} from '@app/features/user/components/settings_utils/GuildSettingsTabComponents';
|
||||
import type {MobileNavigationState} from '@app/features/user/hooks/useMobileNavigation';
|
||||
import {useUnsavedChangesFlash} from '@app/features/user/hooks/useUnsavedChangesFlash';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -70,6 +71,7 @@ const headerFadeVariants = {
|
||||
export const MobileGuildSettingsView: React.FC<MobileGuildSettingsViewProps> = observer(
|
||||
({guild, groupedSettingsTabs, currentTab, mobileNav, onBack, onTabSelect}) => {
|
||||
const {i18n} = useLingui();
|
||||
const CurrentTabComponent = currentTab ? getGuildSettingsTabComponent(currentTab.type) : null;
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
const currentTabId = mobileNav.currentView?.tab;
|
||||
const {showUnsavedBanner, flashBanner, tabData, checkUnsavedChanges} = useUnsavedChangesFlash(currentTabId);
|
||||
@@ -203,7 +205,7 @@ export const MobileGuildSettingsView: React.FC<MobileGuildSettingsViewProps> = o
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{showMobileContent && currentTab && (
|
||||
{showMobileContent && CurrentTabComponent && (
|
||||
<motion.div
|
||||
key={`mobile-content-${mobileNav.currentView?.tab}`}
|
||||
custom={mobileNav.direction}
|
||||
@@ -221,7 +223,7 @@ export const MobileGuildSettingsView: React.FC<MobileGuildSettingsViewProps> = o
|
||||
data-flx="app.mobile-guild-settings-view.scroller-flex"
|
||||
>
|
||||
<div className={styles.contentContainer} data-flx="app.mobile-guild-settings-view.content-container">
|
||||
<currentTab.component
|
||||
<CurrentTabComponent
|
||||
guildId={guild.id}
|
||||
data-flx="app.mobile-guild-settings-view.current-tab-component"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/dialogs/components/PerksButton.module.css';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -17,7 +17,7 @@ export const PerksButton: React.FC<{
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={styles.link} data-flx="app.perks-button.link.click.button">
|
||||
{i18n._(PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {PricingSection} from '@app/features/app/components/dialogs/components/pl
|
||||
import {PurchaseHistorySection} from '@app/features/app/components/dialogs/components/plutonium/PurchaseHistorySection';
|
||||
import {SelfServeRefundSection} from '@app/features/app/components/dialogs/components/plutonium/SelfServeRefundSection';
|
||||
import {SubscriptionCard} from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import GeoIP from '@app/features/app/state/GeoIP';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
@@ -34,6 +33,8 @@ import {
|
||||
areGiftPurchasesAvailable,
|
||||
arePremiumPurchasesAvailable,
|
||||
canServiceStripeSubscriptions,
|
||||
getPremiumProductFullName,
|
||||
getPremiumProductName,
|
||||
shouldShowPremiumFeatures,
|
||||
} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
@@ -52,6 +53,7 @@ interface PlutoniumContentProps {
|
||||
}
|
||||
|
||||
export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumContentProps) => {
|
||||
const PREMIUM_PRODUCT_NAME = getPremiumProductName();
|
||||
const {i18n} = useLingui();
|
||||
const currentUser = Users.currentUser;
|
||||
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
|
||||
@@ -113,8 +115,8 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
!shouldShowPremiumFeatures() ||
|
||||
(!arePremiumPurchasesAvailable() && !(hasBillingRelationship && canServiceStripeSubscriptions()));
|
||||
const purchaseDisabledTooltip = !isClaimed
|
||||
? i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME})
|
||||
: i18n._(VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
? i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()})
|
||||
: i18n._(VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
|
||||
const handleSelectPlanGuarded = useCallback(
|
||||
(plan: 'monthly' | 'yearly' | 'gift_1_month' | 'gift_1_year') => {
|
||||
if (purchaseDisabled) return;
|
||||
@@ -191,7 +193,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
<div ref={perksSectionRef} data-flx="app.plutonium-content.div">
|
||||
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section">
|
||||
<SectionHeader
|
||||
title={i18n._(FREE_VS_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
title={i18n._(FREE_VS_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
data-flx="app.plutonium-content.section-header"
|
||||
/>
|
||||
<div
|
||||
@@ -210,7 +212,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
<GiftInventoryBanner currentUser={currentUser} data-flx="app.plutonium-content.gift-inventory-banner" />
|
||||
<div className={styles.header} data-flx="app.plutonium-content.header">
|
||||
<h1 className={styles.title} data-flx="app.plutonium-content.title">
|
||||
{PREMIUM_PRODUCT_FULL_NAME}
|
||||
{getPremiumProductFullName()}
|
||||
</h1>
|
||||
<p className={styles.description} data-flx="app.plutonium-content.description">
|
||||
<Trans>
|
||||
@@ -342,7 +344,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
<div ref={perksSectionRef} data-flx="app.plutonium-content.div--2">
|
||||
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section--2">
|
||||
<SectionHeader
|
||||
title={i18n._(FREE_VS_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
title={i18n._(FREE_VS_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
data-flx="app.plutonium-content.section-header--2"
|
||||
/>
|
||||
<div
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/BottomCTASection.module.css';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getPremiumProductFullName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
@@ -43,7 +43,7 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||
const {i18n} = useLingui();
|
||||
const tooltipText: React.ReactNode =
|
||||
purchaseDisabledTooltip ??
|
||||
i18n._(CLAIM_YOUR_ACCOUNT_TO_PURCHASE_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
i18n._(CLAIM_YOUR_ACCOUNT_TO_PURCHASE_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
|
||||
return (
|
||||
<div className={styles.container} data-flx="app.plutonium.bottom-cta-section.container">
|
||||
<h2 className={styles.title} data-flx="app.plutonium.bottom-cta-section.title">
|
||||
|
||||
@@ -6,7 +6,6 @@ import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/component
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/GiftSection.module.css';
|
||||
import {SectionHeader} from '@app/features/app/components/dialogs/components/plutonium/PlutoniumSectionHeader';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
BUY_GIFT_DESCRIPTOR,
|
||||
CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
SHARE_PREMIUM_EXPERIENCE_DESCRIPTOR,
|
||||
VIEW_PREMIUM_PERKS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductFullName, getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowDownIcon} from '@phosphor-icons/react';
|
||||
@@ -57,14 +57,14 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
const {i18n} = useLingui();
|
||||
const tooltipText: React.ReactNode =
|
||||
purchaseDisabledTooltip ??
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
|
||||
return (
|
||||
<div ref={giftSectionRef} data-flx="app.plutonium.gift-section.div">
|
||||
<section className={styles.section} data-flx="app.plutonium.gift-section.section">
|
||||
<SectionHeader
|
||||
title={i18n._(GIFT_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
title={i18n._(GIFT_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
description={i18n._(SHARE_PREMIUM_EXPERIENCE_DESCRIPTOR, {
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
})}
|
||||
data-flx="app.plutonium.gift-section.section-header"
|
||||
/>
|
||||
@@ -110,7 +110,7 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
<PurchaseDisclaimer data-flx="app.plutonium.gift-section.purchase-disclaimer" />
|
||||
<div className={styles.scrollPromptContainer} data-flx="app.plutonium.gift-section.scroll-prompt-container">
|
||||
<p className={styles.scrollPromptText} data-flx="app.plutonium.gift-section.scroll-prompt-text">
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</p>
|
||||
<ArrowDownIcon
|
||||
className={styles.scrollPromptIcon}
|
||||
|
||||
+12
-9
@@ -1,14 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {
|
||||
PREMIUM_UPSELL_BANNER_DESCRIPTOR,
|
||||
VIEW_PLANS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {PlutoniumUpsell} from '@app/features/ui/plutonium_upsell/PlutoniumUpsell';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
@@ -20,17 +20,20 @@ export const PlutoniumUpsellBanner = observer(() => {
|
||||
onButtonClick={() => {
|
||||
ModalCommands.pop();
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.plutonium.plutonium-upsell-banner.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.plutonium.plutonium-upsell-banner.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
}}
|
||||
data-flx="app.plutonium.plutonium-upsell-banner.plutonium-upsell"
|
||||
>
|
||||
{i18n._(PREMIUM_UPSELL_BANNER_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(PREMIUM_UPSELL_BANNER_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</PlutoniumUpsell>
|
||||
);
|
||||
});
|
||||
|
||||
+3
-3
@@ -6,13 +6,13 @@ import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/component
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/PricingSection.module.css';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {ToggleButton} from '@app/features/app/components/dialogs/components/ToggleButton';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
BUY_GIFT_DESCRIPTOR,
|
||||
CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR,
|
||||
ONE_TIME_PURCHASE_DESCRIPTOR,
|
||||
VIEW_PREMIUM_PERKS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductFullName, getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowDownIcon} from '@phosphor-icons/react';
|
||||
@@ -99,7 +99,7 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
const {i18n} = useLingui();
|
||||
const tooltipText: React.ReactNode =
|
||||
purchaseDisabledTooltip ??
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
|
||||
return (
|
||||
<section className={styles.section} data-flx="app.plutonium.pricing-section.section">
|
||||
{giftPurchasesAvailable && (
|
||||
@@ -208,7 +208,7 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
data-flx="app.plutonium.pricing-section.scroll-prompt-container"
|
||||
>
|
||||
<p className={styles.scrollPromptText} data-flx="app.plutonium.pricing-section.scroll-prompt-text">
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</p>
|
||||
<ArrowDownIcon
|
||||
className={styles.scrollPromptIcon}
|
||||
|
||||
+4
-8
@@ -6,11 +6,7 @@ import {useSubscriptionActions} from '@app/features/app/components/dialogs/compo
|
||||
import type {GracePeriodInfo} from '@app/features/app/components/dialogs/components/plutonium/hooks/useSubscriptionStatus';
|
||||
import statusStyles from '@app/features/app/components/dialogs/components/plutonium/PurchaseHistoryStatus.module.css';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard.module.css';
|
||||
import {
|
||||
PAYMENT_PROVIDER_NAME,
|
||||
PREMIUM_PRODUCT_FULL_NAME,
|
||||
PREMIUM_PRODUCT_NAME,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {PAYMENT_PROVIDER_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {JOIN_COMMUNITY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {
|
||||
@@ -18,7 +14,7 @@ import {
|
||||
MANAGE_SUBSCRIPTION_DESCRIPTOR,
|
||||
PREMIUM_SUBSCRIPTION_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getStoreName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {getPremiumProductFullName, getPremiumProductName, getStoreName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -195,7 +191,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
purchaseDisabledTooltip != null
|
||||
? () => purchaseDisabledTooltip
|
||||
: i18n._(CLAIM_ACCOUNT_TO_PURCHASE_OR_REDEEM_PREMIUM_DESCRIPTOR, {
|
||||
premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME,
|
||||
premiumProductFullName: getPremiumProductFullName(),
|
||||
});
|
||||
const targetBillingCycle = billingCycle === 'monthly' ? 'yearly' : billingCycle === 'yearly' ? 'monthly' : null;
|
||||
const effectiveMonthlyPrice =
|
||||
@@ -422,7 +418,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
{isVisionary ? (
|
||||
<Trans>Visionary</Trans>
|
||||
) : (
|
||||
i18n._(PREMIUM_SUBSCRIPTION_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})
|
||||
i18n._(PREMIUM_SUBSCRIPTION_DESCRIPTOR, {premiumProductName: getPremiumProductName()})
|
||||
)}
|
||||
</h3>
|
||||
<span className={badgeClass} data-flx="app.plutonium.subscription-card.badge">
|
||||
|
||||
+4
-5
@@ -5,7 +5,6 @@ import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {
|
||||
PAYMENT_PROVIDER_NAME,
|
||||
PIX_PAYMENT_METHOD,
|
||||
PREMIUM_PRODUCT_FULL_NAME,
|
||||
PRODUCT_NAME,
|
||||
SUPPORT_EMAIL,
|
||||
UPI_PAYMENT_METHOD,
|
||||
@@ -19,7 +18,7 @@ import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {recordPremiumCheckoutReturnIntent} from '@app/features/premium/utils/PremiumCheckoutReturnIntent';
|
||||
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getStoreName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {getPremiumProductFullName, getStoreName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
@@ -276,7 +275,7 @@ export const useCheckoutActions = (
|
||||
<GenericErrorModal
|
||||
title={i18n._(EMAIL_VERIFICATION_REQUIRED_TITLE_DESCRIPTOR)}
|
||||
message={i18n._(EMAIL_VERIFICATION_REQUIRED_BODY_DESCRIPTOR, {
|
||||
premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME,
|
||||
premiumProductFullName: getPremiumProductFullName(),
|
||||
})}
|
||||
data-flx="app.plutonium.use-checkout-actions.email-verification-required.generic-error-modal"
|
||||
/>
|
||||
@@ -304,7 +303,7 @@ export const useCheckoutActions = (
|
||||
const store = PremiumState.state?.store;
|
||||
const manageUrl = store?.provider === storeProvider ? store.manage_url : null;
|
||||
const description = i18n._(EXISTING_STORE_SUBSCRIPTION_BODY_DESCRIPTOR, {
|
||||
premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME,
|
||||
premiumProductFullName: getPremiumProductFullName(),
|
||||
storeName: getStoreName(storeProvider),
|
||||
});
|
||||
ModalCommands.push(
|
||||
@@ -337,7 +336,7 @@ export const useCheckoutActions = (
|
||||
<ConfirmModal
|
||||
title={i18n._(EXISTING_SUBSCRIPTION_TITLE_DESCRIPTOR)}
|
||||
description={i18n._(EXISTING_SUBSCRIPTION_BODY_DESCRIPTOR, {
|
||||
premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME,
|
||||
premiumProductFullName: getPremiumProductFullName(),
|
||||
})}
|
||||
primaryText={i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)}
|
||||
primaryVariant="primary"
|
||||
|
||||
@@ -6,6 +6,7 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import {
|
||||
DIRECT_MESSAGE_DESCRIPTOR,
|
||||
GROUP_DM_DESCRIPTOR,
|
||||
SEARCH_FRIENDS_DESCRIPTOR,
|
||||
SENT_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
@@ -19,18 +20,12 @@ import Users from '@app/features/user/state/Users';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {MagnifyingGlassIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useMemo, useRef, useState} from 'react';
|
||||
|
||||
const GROUP_DM_DESCRIPTOR = msg({
|
||||
message: 'Group DM',
|
||||
comment: 'Short label in the settings dialog recipient list.',
|
||||
});
|
||||
|
||||
export interface RecipientItem {
|
||||
id: string;
|
||||
user: User;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getLiveResizeSnapshot, subscribeToLiveResize} from '@app/features/window/state/LiveResize';
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useSyncExternalStore} from 'react';
|
||||
|
||||
const SETTLE_DURATION_MS = 200;
|
||||
const SETTLE_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
|
||||
const SETTLE_EPSILON_PX = 0.5;
|
||||
|
||||
export function useWindowLiveResize(): boolean {
|
||||
return useSyncExternalStore(subscribeToLiveResize, getLiveResizeSnapshot, getLiveResizeSnapshot);
|
||||
}
|
||||
|
||||
export function useModalResizePin(
|
||||
isResizing: boolean,
|
||||
prefersReducedMotion: boolean,
|
||||
): (node: HTMLElement | null) => void {
|
||||
const nodeRef = useRef<HTMLElement | null>(null);
|
||||
const pinnedRef = useRef<{left: number; top: number} | null>(null);
|
||||
|
||||
const setNode = useCallback((node: HTMLElement | null) => {
|
||||
nodeRef.current = node;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = nodeRef.current;
|
||||
if (node == null) return;
|
||||
|
||||
if (isResizing) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
pinnedRef.current = {left: rect.left, top: rect.top};
|
||||
node.style.transition = '';
|
||||
node.style.transform = '';
|
||||
node.style.position = 'absolute';
|
||||
node.style.left = `clamp(0px, ${rect.left}px, max(0px, 100% - ${rect.width}px))`;
|
||||
node.style.top = `clamp(0px, ${rect.top}px, max(0px, 100% - ${rect.height}px))`;
|
||||
return;
|
||||
}
|
||||
|
||||
const pinned = pinnedRef.current;
|
||||
if (pinned == null) return;
|
||||
pinnedRef.current = null;
|
||||
|
||||
const first = node.getBoundingClientRect();
|
||||
node.style.position = '';
|
||||
node.style.left = '';
|
||||
node.style.top = '';
|
||||
const last = node.getBoundingClientRect();
|
||||
const deltaX = first.left - last.left;
|
||||
const deltaY = first.top - last.top;
|
||||
|
||||
if (prefersReducedMotion || (Math.abs(deltaX) < SETTLE_EPSILON_PX && Math.abs(deltaY) < SETTLE_EPSILON_PX)) {
|
||||
node.style.transition = '';
|
||||
node.style.transform = '';
|
||||
return;
|
||||
}
|
||||
|
||||
node.style.transition = 'none';
|
||||
node.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
const settle = requestAnimationFrame(() => {
|
||||
node.style.transition = `transform ${SETTLE_DURATION_MS}ms ${SETTLE_EASING}`;
|
||||
node.style.transform = 'translate(0px, 0px)';
|
||||
});
|
||||
const clear = (event: TransitionEvent) => {
|
||||
if (event.propertyName !== 'transform') return;
|
||||
node.style.transition = '';
|
||||
node.style.transform = '';
|
||||
node.removeEventListener('transitionend', clear);
|
||||
};
|
||||
node.addEventListener('transitionend', clear);
|
||||
return () => {
|
||||
cancelAnimationFrame(settle);
|
||||
node.removeEventListener('transitionend', clear);
|
||||
};
|
||||
}, [isResizing, prefersReducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const node = nodeRef.current;
|
||||
if (node == null) return;
|
||||
node.style.position = '';
|
||||
node.style.left = '';
|
||||
node.style.top = '';
|
||||
node.style.transition = '';
|
||||
node.style.transform = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return setNode;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {createChannelStream} from '@app/features/messaging/utils/MessageGrouping
|
||||
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
|
||||
import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride';
|
||||
import UnreadChannels from '@app/features/notification/state/UnreadChannels';
|
||||
import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateCommands';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
@@ -172,6 +173,11 @@ interface CacheEntry {
|
||||
const previewCache = new Map<string, CacheEntry>();
|
||||
const inFlightRequests = new Map<string, Promise<void>>();
|
||||
|
||||
AccountScopedWork.registerCancellation(() => {
|
||||
previewCache.clear();
|
||||
inFlightRequests.clear();
|
||||
});
|
||||
|
||||
interface BulkPreviewRequest {
|
||||
channel_id: string;
|
||||
limit: number;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.switchMenu {
|
||||
composes: submenuPopover from '@app/features/ui/action_menu/ContextMenu.module.css';
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 17.5rem;
|
||||
margin-left: 1rem;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.switchMenuList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.instanceGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.instanceHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.625rem 0.25rem;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
.instanceHeadingLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceAccounts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.switchMenuFooter {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 0.0625rem solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.manageAccountsButton {
|
||||
composes: item from '@app/features/ui/action_menu/ContextMenu.module.css';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.accountMenuItem {
|
||||
composes: item from '@app/features/ui/action_menu/ContextMenu.module.css';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.accountMenuAvatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.accountMenuInfo {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.accountMenuTag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.accountMenuNameText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accountMenuDiscriminator {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.accountMenuMeta {
|
||||
color: var(--status-online);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
.accountMenuMetaExpired {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.accountMenuCheck {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/floating/UserAreaAccountSwitchMenu.module.css';
|
||||
import {useAccountAvatarURL} from '@app/features/auth/AccountAvatarUtils';
|
||||
import {resolveAccountInstanceKey, resolveAccountInstanceLabel} from '@app/features/auth/AccountDisplayUtils';
|
||||
import {
|
||||
AccountIdentityText,
|
||||
useAccountDisplayIdentityView,
|
||||
} from '@app/features/auth/components/accounts/AccountIdentity';
|
||||
import {getAccountMentionCount} from '@app/features/auth/components/accounts/account_switcher_modal/AccountMentionCounts';
|
||||
import {SESSION_EXPIRED_DESCRIPTOR} from '@app/features/auth/components/accounts/account_switcher_modal/AccountPickerShared';
|
||||
import {getAccountKey} from '@app/features/auth/state/AccountStorageKey';
|
||||
import {ACTIVE_ACCOUNT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
import {MentionBadge} from '@app/features/ui/components/MentionBadge';
|
||||
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CheckIcon, GearIcon} from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
const UNKNOWN_INSTANCE_GROUP_KEY = 'unknown';
|
||||
|
||||
interface AccountInstanceGroup {
|
||||
readonly groupKey: string;
|
||||
readonly instanceLabel: string | null;
|
||||
readonly accounts: Array<Account>;
|
||||
}
|
||||
|
||||
interface AccountInstanceGroups {
|
||||
readonly groups: Array<AccountInstanceGroup>;
|
||||
readonly hasMultipleInstances: boolean;
|
||||
}
|
||||
|
||||
function createAccountInstanceGroups(accounts: ReadonlyArray<Account>): Array<AccountInstanceGroup> {
|
||||
const groups: Array<AccountInstanceGroup> = [];
|
||||
const groupsByKey = new Map<string, AccountInstanceGroup>();
|
||||
let unknownGroup: AccountInstanceGroup | null = null;
|
||||
for (const account of accounts) {
|
||||
const instanceKey = resolveAccountInstanceKey(account);
|
||||
if (instanceKey == null) {
|
||||
if (unknownGroup == null) {
|
||||
unknownGroup = {groupKey: UNKNOWN_INSTANCE_GROUP_KEY, instanceLabel: null, accounts: []};
|
||||
}
|
||||
unknownGroup.accounts.push(account);
|
||||
continue;
|
||||
}
|
||||
const groupKey = `instance:${instanceKey}`;
|
||||
let group = groupsByKey.get(groupKey);
|
||||
if (group == null) {
|
||||
group = {groupKey, instanceLabel: resolveAccountInstanceLabel(account), accounts: []};
|
||||
groupsByKey.set(groupKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.accounts.push(account);
|
||||
}
|
||||
if (unknownGroup != null) {
|
||||
groups.push(unknownGroup);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function useAccountInstanceGroups(accounts: ReadonlyArray<Account>): AccountInstanceGroups {
|
||||
const groups = useMemo(() => createAccountInstanceGroups(accounts), [accounts]);
|
||||
return {groups, hasMultipleInstances: groups.length > 1};
|
||||
}
|
||||
|
||||
interface SwitchAccountMenuItemProps {
|
||||
readonly account: Account;
|
||||
readonly currentAccountKey: string | null;
|
||||
readonly onSelect: (account: Account) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
const SwitchAccountMenuItem = observer(
|
||||
({account, currentAccountKey, onSelect, onClose}: SwitchAccountMenuItemProps): React.ReactElement => {
|
||||
const {i18n} = useLingui();
|
||||
const accountKey = getAccountKey(account);
|
||||
const isCurrent = accountKey === currentAccountKey;
|
||||
const isExpired = account.isValid === false;
|
||||
const avatarUrl = useAccountAvatarURL(account);
|
||||
const identityView = useAccountDisplayIdentityView(account);
|
||||
const mentionCount = getAccountMentionCount(accountKey);
|
||||
let metaLabel: string | null = null;
|
||||
if (isExpired) {
|
||||
metaLabel = i18n._(SESSION_EXPIRED_DESCRIPTOR);
|
||||
} else if (isCurrent) {
|
||||
metaLabel = i18n._(ACTIVE_ACCOUNT_DESCRIPTOR);
|
||||
}
|
||||
return (
|
||||
<FocusRing offset={-2} data-flx="app.floating.user-area-account-switch-menu.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.accountMenuItem}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSelect(account);
|
||||
}
|
||||
onClose();
|
||||
PopoutCommands.close();
|
||||
}}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-menu-item.select.button"
|
||||
>
|
||||
<flx-app-account-switch-item-avatar
|
||||
className={flxElementClassName(styles.accountMenuAvatar)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-menu-avatar"
|
||||
>
|
||||
<MockAvatar
|
||||
size={24}
|
||||
avatarUrl={avatarUrl}
|
||||
userTag={identityView.tagLabel}
|
||||
data-flx="app.floating.user-area-account-switch-menu.mock-avatar"
|
||||
/>
|
||||
</flx-app-account-switch-item-avatar>
|
||||
<flx-app-account-switch-item-info
|
||||
className={flxElementClassName(styles.accountMenuInfo)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-menu-info"
|
||||
>
|
||||
<span
|
||||
className={styles.accountMenuTag}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-menu-tag"
|
||||
>
|
||||
<span
|
||||
className={styles.accountMenuNameText}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-menu-name-text"
|
||||
>
|
||||
<AccountIdentityText
|
||||
identityView={identityView}
|
||||
discriminatorClassName={styles.accountMenuDiscriminator}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-identity-text"
|
||||
/>
|
||||
</span>
|
||||
<MentionBadge
|
||||
mentionCount={mentionCount}
|
||||
size="small"
|
||||
data-flx="app.floating.user-area-account-switch-menu.mention-badge"
|
||||
/>
|
||||
</span>
|
||||
{metaLabel == null ? null : (
|
||||
<span
|
||||
className={clsx(styles.accountMenuMeta, isExpired && styles.accountMenuMetaExpired)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-menu-meta"
|
||||
>
|
||||
{metaLabel}
|
||||
</span>
|
||||
)}
|
||||
</flx-app-account-switch-item-info>
|
||||
{isCurrent ? (
|
||||
<flx-app-account-switch-item-check
|
||||
className={flxElementClassName(styles.accountMenuCheck)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.account-menu-check"
|
||||
>
|
||||
<CheckIcon
|
||||
size={remFromPx(10)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-account-switch-menu.check-icon"
|
||||
/>
|
||||
</flx-app-account-switch-item-check>
|
||||
) : null}
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface UserAreaAccountSwitchMenuProps {
|
||||
readonly accounts: Array<Account>;
|
||||
readonly currentAccountKey: string | null;
|
||||
readonly onSelect: (account: Account) => void;
|
||||
readonly onManage: () => void;
|
||||
readonly onClose: () => void;
|
||||
readonly 'data-flx'?: string;
|
||||
}
|
||||
|
||||
export const UserAreaAccountSwitchMenu = observer(
|
||||
({accounts, currentAccountKey, onSelect, onManage, onClose}: UserAreaAccountSwitchMenuProps) => {
|
||||
const {groups, hasMultipleInstances} = useAccountInstanceGroups(accounts);
|
||||
const renderAccount = (account: Account): React.ReactNode => (
|
||||
<SwitchAccountMenuItem
|
||||
key={getAccountKey(account)}
|
||||
account={account}
|
||||
currentAccountKey={currentAccountKey}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
data-flx="app.floating.user-area-account-switch-menu.switch-account-menu-item"
|
||||
/>
|
||||
);
|
||||
const renderInstanceGroup = (group: AccountInstanceGroup): React.ReactNode => (
|
||||
<flx-app-account-switch-menu-instance
|
||||
key={group.groupKey}
|
||||
className={flxElementClassName(styles.instanceGroup)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-group"
|
||||
>
|
||||
{group.instanceLabel == null ? null : (
|
||||
<flx-app-account-switch-menu-instance-title
|
||||
className={flxElementClassName(styles.instanceHeading)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-heading"
|
||||
>
|
||||
<span
|
||||
className={styles.instanceHeadingLabel}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-heading-label"
|
||||
>
|
||||
{group.instanceLabel}
|
||||
</span>
|
||||
</flx-app-account-switch-menu-instance-title>
|
||||
)}
|
||||
<flx-app-account-switch-menu-accounts
|
||||
className={flxElementClassName(styles.instanceAccounts)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.instance-accounts"
|
||||
>
|
||||
{group.accounts.map(renderAccount)}
|
||||
</flx-app-account-switch-menu-accounts>
|
||||
</flx-app-account-switch-menu-instance>
|
||||
);
|
||||
const renderAccounts = (): React.ReactNode => {
|
||||
if (!hasMultipleInstances) {
|
||||
return accounts.map(renderAccount);
|
||||
}
|
||||
return groups.map(renderInstanceGroup);
|
||||
};
|
||||
return (
|
||||
<flx-app-account-switch-menu
|
||||
className={flxElementClassName(styles.switchMenu)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.switch-menu"
|
||||
>
|
||||
<flx-app-account-switch-menu-list
|
||||
className={flxElementClassName(styles.switchMenuList)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.switch-menu-list"
|
||||
>
|
||||
{renderAccounts()}
|
||||
</flx-app-account-switch-menu-list>
|
||||
<flx-app-account-switch-menu-footer
|
||||
className={flxElementClassName(styles.switchMenuFooter)}
|
||||
data-flx="app.floating.user-area-account-switch-menu.switch-menu-footer"
|
||||
>
|
||||
<FocusRing offset={-2} data-flx="app.floating.user-area-account-switch-menu.focus-ring--2">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.manageAccountsButton}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onManage();
|
||||
}}
|
||||
data-flx="app.floating.user-area-account-switch-menu.manage-accounts-button.manage"
|
||||
>
|
||||
<GearIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-account-switch-menu.gear-icon"
|
||||
/>
|
||||
<Trans>Manage accounts</Trans>
|
||||
</button>
|
||||
</FocusRing>
|
||||
</flx-app-account-switch-menu-footer>
|
||||
</flx-app-account-switch-menu>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -179,83 +179,6 @@
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.switchMenu {
|
||||
composes: submenuPopover from '@app/features/ui/action_menu/ContextMenu.module.css';
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 17.5rem;
|
||||
margin-left: 1rem;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.switchMenuList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.switchMenuFooter {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 0.0625rem solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.manageAccountsButton {
|
||||
composes: item from '@app/features/ui/action_menu/ContextMenu.module.css';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.accountMenuItem {
|
||||
composes: item from '@app/features/ui/action_menu/ContextMenu.module.css';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.accountMenuAvatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.accountMenuInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.accountMenuTag {
|
||||
font-weight: 500;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.accountMenuDiscriminator {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.accountMenuMeta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--status-online);
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
.accountMenuCheck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: var(--brand-primary);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.statusMenuRow {
|
||||
margin: 0.0625rem 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {UserAreaAccountSwitchMenu} from '@app/features/app/components/floating/UserAreaAccountSwitchMenu';
|
||||
import styles from '@app/features/app/components/floating/UserAreaPopout.module.css';
|
||||
import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay';
|
||||
import {
|
||||
@@ -10,12 +13,8 @@ import {
|
||||
type TimeWindowPreset,
|
||||
} from '@app/features/app/config/TimeWindowPresets';
|
||||
import {getStatusTypeLabel, STATUS_UNTIL_I_CHANGE_IT_DESCRIPTOR} from '@app/features/app/constants/AppConstants';
|
||||
import {
|
||||
getAccountAvatarUrl,
|
||||
getAccountDisplayName,
|
||||
getAccountTag,
|
||||
} from '@app/features/auth/components/accounts/AccountListItem';
|
||||
import AccountSwitcherModal from '@app/features/auth/components/accounts/AccountSwitcherModal';
|
||||
import {openAccountSwitcherModal} from '@app/features/auth/commands/AccountSwitcherModalCommands';
|
||||
import {getAccountKey} from '@app/features/auth/state/AccountStorageKey';
|
||||
import {useAccountSwitcherLogic} from '@app/features/auth/utils/AccountSwitcherModalUtils';
|
||||
import DeveloperMode from '@app/features/devtools/state/DeveloperMode';
|
||||
import {COPY_USERNAME_DESCRIPTOR, UNKNOWN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
@@ -29,7 +28,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||
import {StatusIndicator} from '@app/features/ui/components/StatusIndicator';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope';
|
||||
@@ -37,7 +35,6 @@ import {Popout} from '@app/features/ui/popover/PopoverPopout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {CustomStatusModal} from '@app/features/user/components/modals/CustomStatusModal';
|
||||
import {UserProfileModal} from '@app/features/user/components/modals/UserProfileModal';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {UserProfileBadges} from '@app/features/user/components/popouts/UserProfileBadges';
|
||||
import userProfilePopoutStyles from '@app/features/user/components/popouts/UserProfilePopout.module.css';
|
||||
import {UserProfilePreviewBio} from '@app/features/user/components/popouts/UserProfileShared';
|
||||
@@ -64,9 +61,7 @@ import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
CaretRightIcon,
|
||||
ChartLineIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
GearIcon,
|
||||
IdentificationBadgeIcon,
|
||||
InfoIcon,
|
||||
PencilIcon,
|
||||
@@ -283,140 +278,50 @@ const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
|
||||
ActionButton.displayName = 'ActionButton';
|
||||
|
||||
interface SwitchAccountsMenuProps {
|
||||
accounts: Array<Account>;
|
||||
currentAccountId: string | null;
|
||||
onSelect: (userId: string) => void;
|
||||
onManage: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SwitchAccountsMenu = observer(
|
||||
({accounts, currentAccountId, onSelect, onManage, onClose}: SwitchAccountsMenuProps) => {
|
||||
return (
|
||||
<div className={styles.switchMenu} data-flx="app.floating.user-area-popout.switch-accounts-menu.switch-menu">
|
||||
<div
|
||||
className={styles.switchMenuList}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.switch-menu-list"
|
||||
>
|
||||
{accounts.map((account) => {
|
||||
const isCurrent = account.userId === currentAccountId;
|
||||
const avatarUrl = getAccountAvatarUrl(account);
|
||||
const displayName = getAccountDisplayName(account, '???');
|
||||
const rawAccountTag = getAccountTag(account);
|
||||
const accountTag = rawAccountTag ? NicknameUtils.formatTagForStreamerMode(rawAccountTag) : displayName;
|
||||
return (
|
||||
<FocusRing
|
||||
key={account.userId}
|
||||
offset={-2}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.focus-ring"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.accountMenuItem}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSelect(account.userId);
|
||||
}
|
||||
onClose();
|
||||
PopoutCommands.close();
|
||||
}}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-item.close.button"
|
||||
>
|
||||
<div
|
||||
className={styles.accountMenuAvatar}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-avatar"
|
||||
>
|
||||
<MockAvatar
|
||||
size={24}
|
||||
avatarUrl={avatarUrl}
|
||||
userTag={displayName}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.mock-avatar"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={styles.accountMenuInfo}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-info"
|
||||
>
|
||||
<span
|
||||
className={styles.accountMenuTag}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-tag"
|
||||
>
|
||||
{displayName}
|
||||
<span
|
||||
className={styles.accountMenuDiscriminator}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-discriminator"
|
||||
>
|
||||
{accountTag}
|
||||
</span>
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span
|
||||
className={styles.accountMenuMeta}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-meta"
|
||||
>
|
||||
<Trans>Active account</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<div
|
||||
className={styles.accountMenuCheck}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-check"
|
||||
>
|
||||
<CheckIcon
|
||||
size={10}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.check-icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={styles.switchMenuFooter}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.switch-menu-footer"
|
||||
>
|
||||
<FocusRing offset={-2} data-flx="app.floating.user-area-popout.switch-accounts-menu.focus-ring--2">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.manageAccountsButton}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onManage();
|
||||
}}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.manage-accounts-button.close"
|
||||
>
|
||||
<GearIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu.gear-icon"
|
||||
/>
|
||||
<Trans>Manage accounts</Trans>
|
||||
</button>
|
||||
</FocusRing>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const UserAreaPopout = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const accountLogic = useAccountSwitcherLogic();
|
||||
const openAccountSwitcher = useCallback((reloginAccount: Account | null) => {
|
||||
openAccountSwitcherModal(
|
||||
{
|
||||
closeCurrentAccountOnSelect: true,
|
||||
initialReloginAccount: reloginAccount,
|
||||
redirectAfterLogin: null,
|
||||
redirectAfterSwitch: Routes.ME,
|
||||
switchAccount: null,
|
||||
'data-flx': 'app.floating.user-area-popout.open-manage-accounts.account-switcher-modal',
|
||||
},
|
||||
null,
|
||||
);
|
||||
PopoutCommands.close();
|
||||
}, []);
|
||||
const openManageAccounts = useCallback(() => {
|
||||
openAccountSwitcher(null);
|
||||
}, [openAccountSwitcher]);
|
||||
const startAccountRelogin = useCallback(
|
||||
(account: Account) => {
|
||||
openAccountSwitcher(account);
|
||||
},
|
||||
[openAccountSwitcher],
|
||||
);
|
||||
const accountLogic = useAccountSwitcherLogic({
|
||||
onSessionExpired: startAccountRelogin,
|
||||
redirectAfterSwitch: Routes.ME,
|
||||
switchAccount: null,
|
||||
});
|
||||
const currentUser = Users.getCurrentUser();
|
||||
const currentUserId = currentUser?.id ?? null;
|
||||
const status = currentUserId ? Presence.getStatus(currentUserId) : StatusTypes.ONLINE;
|
||||
const openEditProfile = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="my_profile"
|
||||
data-flx="app.floating.user-area-popout.open-edit-profile.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="my_profile"
|
||||
data-flx="app.floating.user-area-popout.open-edit-profile.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
PopoutCommands.close();
|
||||
}, []);
|
||||
@@ -470,14 +375,6 @@ export const UserAreaPopout = observer(() => {
|
||||
}
|
||||
TextCopyCommands.copy(i18n, currentUser.tag);
|
||||
}, [currentUser, i18n]);
|
||||
const openManageAccounts = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<AccountSwitcherModal data-flx="app.floating.user-area-popout.open-manage-accounts.account-switcher-modal" />
|
||||
)),
|
||||
);
|
||||
PopoutCommands.close();
|
||||
}, []);
|
||||
const profile = useMemo(() => {
|
||||
if (!currentUser) {
|
||||
return null;
|
||||
@@ -654,16 +551,19 @@ export const UserAreaPopout = observer(() => {
|
||||
offsetMainAxis={8}
|
||||
animationType="none"
|
||||
render={({onClose}) => (
|
||||
<SwitchAccountsMenu
|
||||
<UserAreaAccountSwitchMenu
|
||||
accounts={accountLogic.accounts}
|
||||
currentAccountId={accountLogic.currentAccount?.userId ?? null}
|
||||
onSelect={(userId) => {
|
||||
accountLogic.handleSwitchAccount(userId);
|
||||
PopoutCommands.close();
|
||||
currentAccountKey={accountLogic.currentAccountKey}
|
||||
onSelect={(account) => {
|
||||
if (account.isValid === false) {
|
||||
startAccountRelogin(account);
|
||||
return;
|
||||
}
|
||||
void accountLogic.handleSwitchAccount(getAccountKey(account));
|
||||
}}
|
||||
onManage={openManageAccounts}
|
||||
onClose={onClose}
|
||||
data-flx="app.floating.user-area-popout.switch-accounts-menu"
|
||||
data-flx="app.floating.user-area-popout.user-area-account-switch-menu"
|
||||
/>
|
||||
)}
|
||||
data-flx="app.floating.user-area-popout.popout--2"
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
import styles from '@app/features/app/components/layout/AppLayout.module.css';
|
||||
import {useAppLayoutState} from '@app/features/app/components/layout/app_layout/AppLayoutHooks';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {RecoveryKitReminderGate} from '@app/features/user/components/RecoveryKitReminderGate';
|
||||
@@ -14,25 +11,11 @@ import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReco
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
const isAuthenticated = Authentication.isAuthenticated;
|
||||
const socket = GatewayConnection.socket;
|
||||
const appState = useAppLayoutState();
|
||||
useEffect(() => {
|
||||
if (Initialization.isLoading) {
|
||||
return;
|
||||
}
|
||||
void AuthenticationCommands.ensureSessionStarted();
|
||||
}, [
|
||||
isAuthenticated,
|
||||
socket,
|
||||
GatewayConnection.isConnected,
|
||||
GatewayConnection.isConnecting,
|
||||
Initialization.isLoading,
|
||||
AccountManager.isSwitching,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
|
||||
|
||||
@@ -26,14 +26,16 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
background-color: var(--background-secondary);
|
||||
background:
|
||||
radial-gradient(ellipse 54rem 30rem at 50% -8%, rgb(138 98 209 / 0.18), transparent 68%),
|
||||
linear-gradient(145deg, #05040d 0%, #080616 48%, #0d0a1c 100%);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
min-height: 100svh;
|
||||
width: 100%;
|
||||
background-color: var(--brand-primary);
|
||||
background-color: #080616;
|
||||
}
|
||||
|
||||
:global(.auth-page),
|
||||
@@ -45,9 +47,10 @@
|
||||
}
|
||||
|
||||
.characterBackground {
|
||||
composes: spaceBackground from '@app/features/ui/space_background/SpaceBackground.module.css';
|
||||
--starfield-position: fixed;
|
||||
min-height: 100svh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@
|
||||
min-height: 100svh;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leftSplitWrapper {
|
||||
@@ -87,6 +91,41 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cardMotionWrap {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100svh;
|
||||
width: 100%;
|
||||
padding: clamp(1.5rem, 4vw, 3rem);
|
||||
overflow: hidden;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.fullscreenContent {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100svh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fullscreenPassthrough {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.presentationProbe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
min-height: 100svh;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.splashImage {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
@@ -122,79 +161,57 @@
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(2rem, 6vw, 4rem);
|
||||
padding: clamp(1.5rem, 4vw, 3rem);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cardContainerInMotion {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
width: min(100%, 38rem);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
height: auto;
|
||||
min-height: 31.25rem;
|
||||
min-height: auto;
|
||||
width: 100%;
|
||||
max-width: 56rem;
|
||||
max-width: 32rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 0.0625rem solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary);
|
||||
box-shadow: 0 1.5625rem 3.125rem -0.75rem rgb(0 0 0 / 0.25);
|
||||
box-shadow: 0 1.25rem 4rem -1.5rem rgb(0 0 0 / 0.7);
|
||||
transition:
|
||||
max-width 180ms ease,
|
||||
min-height 180ms ease;
|
||||
}
|
||||
|
||||
.cardSingle {
|
||||
max-width: 42rem;
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.cardStandard {
|
||||
max-width: 32rem;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.cardCompact {
|
||||
max-width: 27rem;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.cardWide {
|
||||
max-width: 38rem;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.logoSide {
|
||||
display: flex;
|
||||
width: 33.333333%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
border-right: 0.0625rem solid var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 1.5rem;
|
||||
height: 8rem;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.formSide {
|
||||
display: flex;
|
||||
width: 66.666667%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
padding: clamp(2rem, 4vw, 2.75rem);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.formSideSingle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.formSideStandard {
|
||||
padding: 2.5rem;
|
||||
}
|
||||
@@ -214,7 +231,15 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cardMotionWrap {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cardContainer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.formSide {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -226,8 +251,13 @@
|
||||
}
|
||||
|
||||
.mobileContainer {
|
||||
composes: starfield from '@app/features/ui/space_background/SpaceBackground.module.css';
|
||||
--starfield-position: fixed;
|
||||
min-height: 100dvh;
|
||||
background-color: var(--background-secondary);
|
||||
background:
|
||||
radial-gradient(ellipse 28rem 18rem at 32% 0%, rgb(122 87 191 / 0.16), transparent 74%),
|
||||
radial-gradient(ellipse 22rem 18rem at 88% 92%, rgb(91 65 151 / 0.12), transparent 72%),
|
||||
linear-gradient(145deg, #05040d 0%, #080616 46%, #0d0a1c 100%);
|
||||
padding: calc(2rem + env(safe-area-inset-top, 0px)) 1.5rem calc(2.5rem + env(safe-area-inset-bottom, 0px));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -235,22 +265,22 @@
|
||||
}
|
||||
|
||||
.mobileContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.mobileLogoContainer {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobileWordmark {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
height: 2rem;
|
||||
color: var(--text-primary);
|
||||
.mobileFullscreenContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: calc(100dvh - 4.5rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
@@ -285,7 +315,7 @@
|
||||
:global(html.auth-page),
|
||||
:global(html.auth-page body) {
|
||||
height: 100%;
|
||||
background-color: var(--background-secondary);
|
||||
background-color: #080616;
|
||||
}
|
||||
|
||||
@supports (padding: env(safe-area-inset-top)) {
|
||||
|
||||
@@ -1,47 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import i18n, {initI18n} from '@app/app/I18n';
|
||||
import i18n, {initI18n, isI18nInitialized} from '@app/app/I18n';
|
||||
import styles from '@app/features/app/components/layout/AuthLayout.module.css';
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
import {NativeDragRegion} from '@app/features/app/components/layout/NativeDragRegion';
|
||||
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
|
||||
import {AuthShellHintKind, useAuthShellHintCapture} from '@app/features/app/components/skeleton/AuthShellHint';
|
||||
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import {useSetLayoutVariant} from '@app/features/app/state/LayoutVariantContext';
|
||||
import {AuthBackground} from '@app/features/auth/flow/AuthBackground';
|
||||
import {AuthCardContainer} from '@app/features/auth/flow/AuthCardContainer';
|
||||
import {useAuthBackground} from '@app/features/auth/hooks/useAuthBackground';
|
||||
import {type AuthCardVariant, AuthLayoutContext} from '@app/features/auth/state/AuthLayoutContext';
|
||||
import {AuthRegisterDraftContext, type AuthRegisterFormDraft} from '@app/features/auth/state/AuthRegisterDraftContext';
|
||||
import {AuthRuntimeTargetBoundary} from '@app/features/auth/flow/AuthRuntimeTargetBoundary';
|
||||
import {AuthSurfaceMotionKind, AuthSurfaceTransition} from '@app/features/auth/flow/AuthSurfaceTransition';
|
||||
import {useSplashImageLoader} from '@app/features/auth/hooks/useAuthBackground';
|
||||
import {
|
||||
type AuthCardVariant,
|
||||
AuthLayoutContentMode,
|
||||
AuthLayoutContext,
|
||||
type AuthLayoutContextType,
|
||||
} from '@app/features/auth/state/AuthLayoutContext';
|
||||
import {AuthRegisterDraftContext, useAuthRegisterDraft} from '@app/features/auth/state/AuthRegisterDraftContext';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {FluxerWordmark} from '@app/features/ui/components/icons/FluxerWordmark';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {isMobileExperienceEnabled} from '@app/features/ui/utils/MobileExperience';
|
||||
import {hasUnavailableElectronNativeContext} from '@app/features/ui/utils/NativeUtils';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import foodPatternUrl from '@app/media/images/i-like-food.svg';
|
||||
import Window from '@app/features/window/state/Window';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
|
||||
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
interface ResolveAuthLayoutContentModeRequest {
|
||||
current: AuthLayoutContentMode | null;
|
||||
value: SetStateAction<AuthLayoutContentMode>;
|
||||
}
|
||||
|
||||
function authSurfaceMotionKind(contentMode: AuthLayoutContentMode): AuthSurfaceMotionKind {
|
||||
if (contentMode === AuthLayoutContentMode.FULL) {
|
||||
return AuthSurfaceMotionKind.FULLSCREEN;
|
||||
}
|
||||
return AuthSurfaceMotionKind.CARD;
|
||||
}
|
||||
|
||||
function resolveAuthLayoutContentMode({current, value}: ResolveAuthLayoutContentModeRequest): AuthLayoutContentMode {
|
||||
const currentMode = current ?? AuthLayoutContentMode.CARD;
|
||||
if (typeof value === 'function') {
|
||||
return value(currentMode);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const AuthLayoutContent = observer(function AuthLayoutContent({children}: {children?: ReactNode}) {
|
||||
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight);
|
||||
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
|
||||
const {width: viewportWidth, height: viewportHeight} = Window.windowSize;
|
||||
const [splashUrl, setSplashUrl] = useState<string | null>(null);
|
||||
const [showLogoSide, setShowLogoSide] = useState(true);
|
||||
const [cardVariant, setCardVariant] = useState<AuthCardVariant>('default');
|
||||
const [contentMode, setContentMode] = useState<AuthLayoutContentMode | null>(null);
|
||||
const [splashAlignment, setSplashAlignment] = useState<GuildSplashCardAlignmentValue>(
|
||||
GuildSplashCardAlignment.CENTER,
|
||||
);
|
||||
const {isNative, platform} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const splashUrlRef = useRef<string | null>(null);
|
||||
const registerFormDraftsRef = useRef<Map<string, AuthRegisterFormDraft>>(new Map());
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const location = useLocation();
|
||||
const {patternReady, splashDimensions} = useAuthBackground(splashUrl, foodPatternUrl);
|
||||
const {dimensions: splashDimensions} = useSplashImageLoader(splashUrl);
|
||||
const handleSetSplashUrl = useCallback(
|
||||
(url: string | null) => {
|
||||
if (splashUrlRef.current === url) return;
|
||||
@@ -53,15 +90,6 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
},
|
||||
[setSplashAlignment],
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setViewportWidth(window.innerWidth);
|
||||
setViewportHeight(window.innerHeight);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('auth-page');
|
||||
return () => {
|
||||
@@ -71,6 +99,9 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
useEffect(() => {
|
||||
scrollerRef.current?.jumpToStartEdge();
|
||||
}, [location.pathname]);
|
||||
useLayoutEffect(() => {
|
||||
setContentMode((current) => current ?? AuthLayoutContentMode.CARD);
|
||||
}, []);
|
||||
const splashScale = useMemo(() => {
|
||||
if (!splashDimensions) return null;
|
||||
const {width, height} = splashDimensions;
|
||||
@@ -79,44 +110,39 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
const widthScale = viewportWidth / width;
|
||||
return Math.max(heightScale, widthScale);
|
||||
}, [splashDimensions, viewportHeight, viewportWidth]);
|
||||
const getRegisterFormDraft = useCallback((draftKey: string): AuthRegisterFormDraft | undefined => {
|
||||
const draft = registerFormDraftsRef.current.get(draftKey);
|
||||
if (!draft) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
formValues: {...draft.formValues},
|
||||
};
|
||||
const handleSetContentMode = useCallback<Dispatch<SetStateAction<AuthLayoutContentMode>>>((value) => {
|
||||
setContentMode((current) => resolveAuthLayoutContentMode({current, value}));
|
||||
}, []);
|
||||
const setRegisterFormDraft = useCallback((draftKey: string, draft: AuthRegisterFormDraft) => {
|
||||
registerFormDraftsRef.current.set(draftKey, {
|
||||
...draft,
|
||||
formValues: {...draft.formValues},
|
||||
});
|
||||
}, []);
|
||||
const clearRegisterFormDraft = useCallback((draftKey: string) => {
|
||||
registerFormDraftsRef.current.delete(draftKey);
|
||||
}, []);
|
||||
const authLayoutContextValue = useMemo(
|
||||
const authLayoutContextValue = useMemo<AuthLayoutContextType>(
|
||||
() => ({
|
||||
setSplashUrl: handleSetSplashUrl,
|
||||
setShowLogoSide,
|
||||
setCardVariant,
|
||||
setContentMode: handleSetContentMode,
|
||||
setSplashCardAlignment: setSplashAlignment,
|
||||
}),
|
||||
[handleSetSplashUrl],
|
||||
);
|
||||
const authRegisterDraftContextValue = useMemo(
|
||||
() => ({
|
||||
getRegisterFormDraft,
|
||||
setRegisterFormDraft,
|
||||
clearRegisterFormDraft,
|
||||
}),
|
||||
[clearRegisterFormDraft, getRegisterFormDraft, setRegisterFormDraft],
|
||||
[handleSetContentMode, handleSetSplashUrl],
|
||||
);
|
||||
const authRegisterDraftContextValue = useAuthRegisterDraft();
|
||||
const isMobileExperience = isMobileExperienceEnabled();
|
||||
const showCorruptedInstallationNagbar = hasUnavailableElectronNativeContext();
|
||||
const isProbing = contentMode == null;
|
||||
const resolvedMode = contentMode ?? AuthLayoutContentMode.CARD;
|
||||
const surfaceKey = `${resolvedMode}:${location.pathname}`;
|
||||
const isFullscreen = resolvedMode === AuthLayoutContentMode.FULL;
|
||||
const resolveAuthShellHintTarget = useCallback((): HTMLElement | null => {
|
||||
const main = mainRef.current;
|
||||
if (main == null || isMobileExperience || isFullscreen) {
|
||||
return main;
|
||||
}
|
||||
return main.querySelector<HTMLElement>('flx-auth-card-surface');
|
||||
}, [isFullscreen, isMobileExperience]);
|
||||
useAuthShellHintCapture({
|
||||
enabled: !isProbing,
|
||||
pathname: location.pathname,
|
||||
mobile: isMobileExperience,
|
||||
kind: isFullscreen ? AuthShellHintKind.FULL : AuthShellHintKind.CARD,
|
||||
resolveTarget: resolveAuthShellHintTarget,
|
||||
});
|
||||
if (isMobileExperience) {
|
||||
return (
|
||||
<AuthRegisterDraftContext.Provider value={authRegisterDraftContextValue}>
|
||||
@@ -126,14 +152,20 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
data-flx="app.auth-layout.auth-layout-content.top-drag-region"
|
||||
/>
|
||||
{showCorruptedInstallationNagbar && (
|
||||
<div className={styles.nagbarHost} data-flx="app.auth-layout.auth-layout-content.nagbar-host">
|
||||
<flx-app-auth-layout-nagbar-host
|
||||
className={flxElementClassName(styles.nagbarHost)}
|
||||
data-flx="app.auth-layout.auth-layout-content.nagbar-host"
|
||||
>
|
||||
<CorruptedInstallationNagbar
|
||||
isMobile={true}
|
||||
data-flx="app.auth-layout.auth-layout-content.corrupted-installation-nagbar"
|
||||
/>
|
||||
</div>
|
||||
</flx-app-auth-layout-nagbar-host>
|
||||
)}
|
||||
<div className={styles.scrollerWrapper} data-flx="app.auth-layout.auth-layout-content.scroller-wrapper">
|
||||
<flx-app-auth-layout-scroller
|
||||
className={flxElementClassName(styles.scrollerWrapper)}
|
||||
data-flx="app.auth-layout.auth-layout-content.scroller-wrapper"
|
||||
>
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.mobileContainer}
|
||||
@@ -141,26 +173,29 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
key="auth-layout-mobile-scroller"
|
||||
data-flx="app.auth-layout.auth-layout-content.mobile-container"
|
||||
>
|
||||
<div
|
||||
<flx-app-auth-layout-mobile-main
|
||||
ref={mainRef}
|
||||
id="main-content"
|
||||
className={styles.mobileContent}
|
||||
className={flxElementClassName(styles.mobileContent)}
|
||||
tabIndex={-1}
|
||||
data-flx="app.auth-layout.auth-layout-content.main-content"
|
||||
>
|
||||
<div
|
||||
className={styles.mobileLogoContainer}
|
||||
data-flx="app.auth-layout.auth-layout-content.mobile-logo-container"
|
||||
<AuthSurfaceTransition
|
||||
surfaceKey={surfaceKey}
|
||||
motionKind={authSurfaceMotionKind(resolvedMode)}
|
||||
suppressed={isProbing}
|
||||
anchorsContentOnInteraction={false}
|
||||
className={clsx(
|
||||
isProbing && styles.presentationProbe,
|
||||
!isProbing && isFullscreen && styles.mobileFullscreenContent,
|
||||
)}
|
||||
data-flx="app.auth-layout.auth-layout-content.auth-surface-transition"
|
||||
>
|
||||
<FluxerWordmark
|
||||
variant="monochrome"
|
||||
className={styles.mobileWordmark}
|
||||
data-flx="app.auth-layout.auth-layout-content.mobile-wordmark"
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</AuthSurfaceTransition>
|
||||
</flx-app-auth-layout-mobile-main>
|
||||
</Scroller>
|
||||
</div>
|
||||
</flx-app-auth-layout-scroller>
|
||||
</AuthLayoutContext.Provider>
|
||||
</AuthRegisterDraftContext.Provider>
|
||||
);
|
||||
@@ -173,14 +208,20 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
data-flx="app.auth-layout.auth-layout-content.top-drag-region--2"
|
||||
/>
|
||||
{showCorruptedInstallationNagbar && (
|
||||
<div className={styles.nagbarHost} data-flx="app.auth-layout.auth-layout-content.nagbar-host--2">
|
||||
<flx-app-auth-layout-nagbar-host
|
||||
className={flxElementClassName(styles.nagbarHost)}
|
||||
data-flx="app.auth-layout.auth-layout-content.nagbar-host--2"
|
||||
>
|
||||
<CorruptedInstallationNagbar
|
||||
isMobile={false}
|
||||
data-flx="app.auth-layout.auth-layout-content.corrupted-installation-nagbar--2"
|
||||
/>
|
||||
</div>
|
||||
</flx-app-auth-layout-nagbar-host>
|
||||
)}
|
||||
<div className={styles.scrollerWrapper} data-flx="app.auth-layout.auth-layout-content.scroller-wrapper--2">
|
||||
<flx-app-auth-layout-scroller
|
||||
className={flxElementClassName(styles.scrollerWrapper)}
|
||||
data-flx="app.auth-layout.auth-layout-content.scroller-wrapper--2"
|
||||
>
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.container}
|
||||
@@ -190,58 +231,71 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
{isNative && !useSystemTitleBar && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.auth-layout.auth-layout-content.native-titlebar" />
|
||||
)}
|
||||
<div
|
||||
className={styles.characterBackground}
|
||||
<flx-app-auth-layout-backdrop
|
||||
className={flxElementClassName(styles.characterBackground)}
|
||||
data-flx="app.auth-layout.auth-layout-content.character-background"
|
||||
>
|
||||
<AuthBackground
|
||||
splashUrl={splashUrl}
|
||||
splashDimensions={splashDimensions}
|
||||
splashScale={splashScale}
|
||||
patternReady={patternReady}
|
||||
patternImageUrl={foodPatternUrl}
|
||||
splashAlignment={splashAlignment}
|
||||
useFullCover={false}
|
||||
data-flx="app.auth-layout.auth-layout-content.auth-background"
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
<flx-app-auth-layout-split
|
||||
className={flxElementClassName(
|
||||
styles.leftSplit,
|
||||
splashAlignment === GuildSplashCardAlignment.LEFT && styles.alignLeft,
|
||||
splashAlignment === GuildSplashCardAlignment.RIGHT && styles.alignRight,
|
||||
)}
|
||||
data-flx="app.auth-layout.auth-layout-content.left-split"
|
||||
>
|
||||
<div
|
||||
className={styles.leftSplitWrapper}
|
||||
<flx-app-auth-layout-split-column
|
||||
className={flxElementClassName(styles.leftSplitWrapper)}
|
||||
data-flx="app.auth-layout.auth-layout-content.left-split-wrapper"
|
||||
>
|
||||
<div
|
||||
<flx-app-auth-layout-main
|
||||
ref={mainRef}
|
||||
id="main-content"
|
||||
className={styles.leftSplitAnimated}
|
||||
className={flxElementClassName(styles.leftSplitAnimated)}
|
||||
tabIndex={-1}
|
||||
data-flx="app.auth-layout.auth-layout-content.main-content--2"
|
||||
>
|
||||
<AuthCardContainer
|
||||
showLogoSide={showLogoSide}
|
||||
variant={cardVariant}
|
||||
isInert={false}
|
||||
data-flx="app.auth-layout.auth-layout-content.auth-card-container"
|
||||
<AuthSurfaceTransition
|
||||
surfaceKey={surfaceKey}
|
||||
motionKind={authSurfaceMotionKind(resolvedMode)}
|
||||
suppressed={isProbing}
|
||||
anchorsContentOnInteraction={!isFullscreen}
|
||||
className={clsx(
|
||||
isProbing && styles.presentationProbe,
|
||||
!isProbing && isFullscreen && styles.fullscreenContent,
|
||||
!isProbing && !isFullscreen && styles.cardMotionWrap,
|
||||
)}
|
||||
data-flx="app.auth-layout.auth-layout-content.auth-surface-transition--2"
|
||||
>
|
||||
{children}
|
||||
</AuthCardContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthCardContainer
|
||||
variant={cardVariant}
|
||||
presentation={resolvedMode}
|
||||
isInert={false}
|
||||
className={clsx(!isFullscreen && styles.cardContainerInMotion)}
|
||||
data-flx="app.auth-layout.auth-layout-content.auth-card-container"
|
||||
>
|
||||
{children}
|
||||
</AuthCardContainer>
|
||||
</AuthSurfaceTransition>
|
||||
</flx-app-auth-layout-main>
|
||||
</flx-app-auth-layout-split-column>
|
||||
</flx-app-auth-layout-split>
|
||||
</flx-app-auth-layout-backdrop>
|
||||
</Scroller>
|
||||
</div>
|
||||
</flx-app-auth-layout-scroller>
|
||||
</AuthLayoutContext.Provider>
|
||||
</AuthRegisterDraftContext.Provider>
|
||||
);
|
||||
});
|
||||
export const AuthLayout = observer(function AuthLayout({children}: {children?: ReactNode}) {
|
||||
const [isI18nInitialized, setIsI18nInitialized] = useState(false);
|
||||
const [i18nReady, setI18nReady] = useState(isI18nInitialized);
|
||||
const setLayoutVariant = useSetLayoutVariant();
|
||||
useEffect(() => {
|
||||
setLayoutVariant('auth');
|
||||
@@ -250,16 +304,23 @@ export const AuthLayout = observer(function AuthLayout({children}: {children?: R
|
||||
};
|
||||
}, [setLayoutVariant]);
|
||||
useEffect(() => {
|
||||
if (i18nReady) {
|
||||
return;
|
||||
}
|
||||
initI18n().then(() => {
|
||||
setIsI18nInitialized(true);
|
||||
setI18nReady(true);
|
||||
});
|
||||
}, []);
|
||||
if (!isI18nInitialized) {
|
||||
}, [i18nReady]);
|
||||
if (!i18nReady) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<AuthLayoutContent data-flx="app.auth-layout.auth-layout-content">{children}</AuthLayoutContent>
|
||||
<AuthLayoutContent data-flx="app.auth-layout.auth-layout-content">
|
||||
<AuthRuntimeTargetBoundary data-flx="app.auth-layout.auth-runtime-target-boundary">
|
||||
{children}
|
||||
</AuthRuntimeTargetBoundary>
|
||||
</AuthLayoutContent>
|
||||
</AppI18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility, {ChannelTypingIndicatorMode} from '@app/features/accessibility/state/Accessibility';
|
||||
import {ChannelSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import styles from '@app/features/app/components/layout/ChannelItem.module.css';
|
||||
import {ChannelItemContent} from '@app/features/app/components/layout/ChannelItemContent';
|
||||
import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon';
|
||||
@@ -30,7 +31,6 @@ import {CategoryBottomSheet} from '@app/features/channel/components/bottomsheets
|
||||
import {ChannelBottomSheet} from '@app/features/channel/components/bottomsheets/ChannelBottomSheet';
|
||||
import {Typing} from '@app/features/channel/components/ChannelTyping';
|
||||
import {ChannelCreateModal} from '@app/features/channel/components/modals/ChannelCreateModal';
|
||||
import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal';
|
||||
import {getTypingText, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
@@ -607,12 +607,15 @@ export const ChannelItem = observer(
|
||||
const handleChannelSettingsClick = useCallback(() => {
|
||||
armActionModalReturn();
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ChannelSettingsModal
|
||||
channelId={channel.id}
|
||||
data-flx="app.channel-item.handle-channel-settings-click.channel-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<ChannelSettingsModal
|
||||
channelId={channel.id}
|
||||
data-flx="app.channel-item.handle-channel-settings-click.channel-settings-modal"
|
||||
/>
|
||||
),
|
||||
'channel-settings',
|
||||
),
|
||||
);
|
||||
}, [channel.id, armActionModalReturn]);
|
||||
const channelSettingsLabel = channelIsCategory
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/TopNagbarContext';
|
||||
import styles from '@app/features/app/components/layout/GuildLayout.module.css';
|
||||
import {GuildNavbar} from '@app/features/app/components/layout/GuildNavbar';
|
||||
@@ -9,6 +10,7 @@ import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {GuildSidebarSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import {MatureContentChannelGate} from '@app/features/channel/components/MatureContentChannelGate';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as GuildCommands from '@app/features/guild/commands/GuildCommands';
|
||||
@@ -34,7 +36,6 @@ import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen';
|
||||
import {ElevatedPermissions, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -196,13 +197,16 @@ const GuildMfaRequirementNagbar = observer(({isMobile, guildId}: {isMobile: bool
|
||||
if (!guild) return null;
|
||||
const handleEnableMfa = () => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="account_security"
|
||||
initialSubtab="security"
|
||||
data-flx="app.guild-layout.handle-enable-mfa.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="account_security"
|
||||
initialSubtab="security"
|
||||
data-flx="app.guild-layout.handle-enable-mfa.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
};
|
||||
const handleDismiss = () => {
|
||||
@@ -352,7 +356,8 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
PermissionUtils.NONE
|
||||
: false;
|
||||
const guildUnavailable = guildId && (unavailableGuilds.has(guildId) || guild?.unavailable);
|
||||
const guildNotFound = !guildUnavailable && !guild;
|
||||
const viewLive = Accounts.isViewLive;
|
||||
const guildNotFound = viewLive && !guildUnavailable && !guild;
|
||||
const firstAccessibleTextChannel = useMemo(() => {
|
||||
if (!guild) return null;
|
||||
for (const ch of channels) {
|
||||
@@ -437,7 +442,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
}
|
||||
}, [nagbarCount]);
|
||||
useEffect(() => {
|
||||
if (!guild || !channelId || guildUnavailable || guildNotFound) return;
|
||||
if (!viewLive || !guild || !channelId || guildUnavailable) return;
|
||||
const currentChannel = Channels.getChannel(channelId);
|
||||
const currentPath = Navigation.pathname;
|
||||
const expectedPath = Routes.guildChannel(guildId, channelId);
|
||||
@@ -446,7 +451,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
Navigation.navigateToGuild(guildId, firstAccessibleTextChannel.id, undefined, 'replace');
|
||||
}
|
||||
}
|
||||
}, [guild, guildId, channelId, firstAccessibleTextChannel, guildUnavailable, guildNotFound]);
|
||||
}, [viewLive, guild, guildId, channelId, firstAccessibleTextChannel, guildUnavailable]);
|
||||
const guildNagbars = (
|
||||
<>
|
||||
{showStaffOnlyGuildNagbar && guildId && (
|
||||
@@ -492,6 +497,18 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
</TopNagbarContext.Provider>
|
||||
);
|
||||
}
|
||||
if (!viewLive && !guildUnavailable && !guild) {
|
||||
return (
|
||||
<TopNagbarContext.Provider value={nagbarContextValue}>
|
||||
<div className={styles.guildLayoutContainer} data-flx="app.guild-layout.guild-layout-container.pending">
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content.pending">
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton.pending" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content.pending" />
|
||||
</div>
|
||||
</div>
|
||||
</TopNagbarContext.Provider>
|
||||
);
|
||||
}
|
||||
if (mobileLayout.enabled) {
|
||||
if (!channelId && (guildUnavailable || guildNotFound)) {
|
||||
return (
|
||||
|
||||
@@ -86,8 +86,6 @@ import GuildListState, {type OrganizedItem} from '@app/features/guild/state/Guil
|
||||
import GuildReadState from '@app/features/guild/state/GuildReadState';
|
||||
import HiddenGuildListButtons from '@app/features/guild/state/HiddenGuildListButtons';
|
||||
import {PRIMARY_NAVIGATION_LANDMARK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {openMacPermissionsModal} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
||||
import MacPermissions from '@app/features/permissions/system/state/MacPermissions';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
@@ -2239,14 +2237,6 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
||||
if (!WhatsNew.shouldShow(latestEntry.id, latestEntry.date, user.createdAt)) return;
|
||||
openWhatsNewModal();
|
||||
}, [isReady, user]);
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
if (!user) return;
|
||||
if (!MacPermissions.shouldShowOnboarding) return;
|
||||
if (MacPermissions.onboardingOpenedThisSession) return;
|
||||
MacPermissions.markOnboardingOpenedThisSession();
|
||||
openMacPermissionsModal();
|
||||
}, [isReady, user]);
|
||||
const shouldShowSidebarDivider = !mobileLayout.enabled;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--native-titlebar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0 0 calc(var(--spacing-3) + env(safe-area-inset-left));
|
||||
gap: var(--spacing-2);
|
||||
background: var(--background-secondary);
|
||||
-webkit-app-region: drag;
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
.titlebar[data-platform='macos'] {
|
||||
padding-left: calc(var(--macos-traffic-light-inset) + env(safe-area-inset-left));
|
||||
padding-right: calc(var(--spacing-3) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.window-focused)) .titlebar,
|
||||
:global(html.platform-native.window-focus-activation-guard) .titlebar {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
color: var(--text-primary-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
display: block;
|
||||
height: 0.8125rem;
|
||||
width: auto;
|
||||
color: var(--text-primary-muted);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
gap: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
-webkit-app-region: no-drag;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
width: 3rem;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controlButton svg {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
}
|
||||
|
||||
.controlButton:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--user-area-divider-color);
|
||||
}
|
||||
|
||||
.controlButton:active {
|
||||
background: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background: #e81123;
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.closeButton:active {
|
||||
background: #b50d1a;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/NativeTitlebar.module.css';
|
||||
import {NativeWindowControls} from '@app/features/app/components/layout/NativeWindowControls';
|
||||
import {FluxerWordmark} from '@app/features/ui/components/icons/FluxerWordmark';
|
||||
import {getElectronAPI, type NativePlatform} from '@app/features/ui/utils/NativeUtils';
|
||||
import {NATIVE_TITLEBAR_CLASS} from '@fluxer/desktop_ipc/src/NativeTitlebarShell';
|
||||
import type React from 'react';
|
||||
import {useLayoutEffect} from 'react';
|
||||
|
||||
@@ -27,14 +27,14 @@ export const NativeTitlebar: React.FC<NativeTitlebarProps> = ({platform}) => {
|
||||
electronApi.windowMaximize();
|
||||
};
|
||||
const brand = (
|
||||
<div className={styles.left} data-flx="app.native-titlebar.left">
|
||||
<FluxerWordmark className={styles.wordmark} data-flx="app.native-titlebar.wordmark" />
|
||||
<div className={NATIVE_TITLEBAR_CLASS.left} data-flx="app.native-titlebar.left">
|
||||
<FluxerWordmark className={NATIVE_TITLEBAR_CLASS.wordmark} data-flx="app.native-titlebar.wordmark" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
className={styles.titlebar}
|
||||
className={NATIVE_TITLEBAR_CLASS.root}
|
||||
onDoubleClick={isMacOS ? undefined : handleDoubleClick}
|
||||
data-platform={platform}
|
||||
data-native-titlebar=""
|
||||
@@ -42,13 +42,13 @@ export const NativeTitlebar: React.FC<NativeTitlebarProps> = ({platform}) => {
|
||||
>
|
||||
{isMacOS ? (
|
||||
<>
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
<div className={NATIVE_TITLEBAR_CLASS.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
{brand}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{brand}
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
<div className={NATIVE_TITLEBAR_CLASS.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
<NativeWindowControls data-flx="app.native-titlebar.controls" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/NativeTitlebar.module.css';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import {
|
||||
NATIVE_TITLEBAR_CLASS,
|
||||
NATIVE_TITLEBAR_CONTROL_ATTR,
|
||||
NATIVE_TITLEBAR_CONTROL_ICON_PATH,
|
||||
type NativeTitlebarControlIconName,
|
||||
} from '@fluxer/desktop_ipc/src/NativeTitlebarShell';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CopySimpleIcon, MinusIcon, SquareIcon, XIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -27,6 +31,17 @@ const CLOSE_WINDOW_DESCRIPTOR = msg({
|
||||
comment: 'Short label in the app layout native titlebar.',
|
||||
});
|
||||
|
||||
interface ControlIconProps {
|
||||
icon: NativeTitlebarControlIconName;
|
||||
'data-flx'?: string;
|
||||
}
|
||||
|
||||
const ControlIcon: React.FC<ControlIconProps> = ({icon, 'data-flx': dataFlx}) => (
|
||||
<svg viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg" data-flx={dataFlx}>
|
||||
<path d={NATIVE_TITLEBAR_CONTROL_ICON_PATH[icon]} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface NativeWindowControlsProps {
|
||||
className?: string;
|
||||
'data-flx'?: string;
|
||||
@@ -75,7 +90,7 @@ export const NativeWindowControls: React.FC<NativeWindowControlsProps> = ({
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
className={clsx(styles.controls, className)}
|
||||
className={clsx(NATIVE_TITLEBAR_CLASS.controls, className)}
|
||||
onDoubleClick={handleControlsDoubleClick}
|
||||
data-no-drag=""
|
||||
data-flx={dataFlx}
|
||||
@@ -84,40 +99,42 @@ export const NativeWindowControls: React.FC<NativeWindowControlsProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={styles.controlButton}
|
||||
className={NATIVE_TITLEBAR_CLASS.control}
|
||||
{...{[NATIVE_TITLEBAR_CONTROL_ATTR]: 'minimize'}}
|
||||
onClick={handleMinimize}
|
||||
aria-label={i18n._(MINIMIZE_WINDOW_DESCRIPTOR)}
|
||||
data-flx="app.native-titlebar.control-button.minimize"
|
||||
>
|
||||
<MinusIcon weight="bold" data-flx="app.native-titlebar.minus-icon" />
|
||||
<ControlIcon icon="minimize" data-flx="app.native-titlebar.minus-icon" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
<FocusRing offset={-2} data-flx="app.native-titlebar.focus-ring--2">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={styles.controlButton}
|
||||
className={NATIVE_TITLEBAR_CLASS.control}
|
||||
{...{[NATIVE_TITLEBAR_CONTROL_ATTR]: 'maximize'}}
|
||||
onClick={handleToggleMaximize}
|
||||
aria-label={isMaximized ? i18n._(RESTORE_WINDOW_DESCRIPTOR) : i18n._(MAXIMIZE_WINDOW_DESCRIPTOR)}
|
||||
data-flx="app.native-titlebar.control-button.toggle-maximize"
|
||||
>
|
||||
{isMaximized ? (
|
||||
<CopySimpleIcon weight="bold" data-flx="app.native-titlebar.copy-simple-icon" />
|
||||
) : (
|
||||
<SquareIcon weight="bold" data-flx="app.native-titlebar.square-icon" />
|
||||
)}
|
||||
<ControlIcon
|
||||
icon={isMaximized ? 'restore' : 'maximize'}
|
||||
data-flx="app.native-titlebar.toggle-maximize-icon"
|
||||
/>
|
||||
</button>
|
||||
</FocusRing>
|
||||
<FocusRing offset={-2} data-flx="app.native-titlebar.focus-ring--3">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.controlButton, styles.closeButton)}
|
||||
className={clsx(NATIVE_TITLEBAR_CLASS.control, NATIVE_TITLEBAR_CLASS.controlClose)}
|
||||
{...{[NATIVE_TITLEBAR_CONTROL_ATTR]: 'close'}}
|
||||
onClick={handleClose}
|
||||
aria-label={i18n._(CLOSE_WINDOW_DESCRIPTOR)}
|
||||
data-flx="app.native-titlebar.control-button.close"
|
||||
>
|
||||
<XIcon weight="bold" data-flx="app.native-titlebar.x-icon" />
|
||||
<ControlIcon icon="close" data-flx="app.native-titlebar.x-icon" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {UserAreaPopout} from '@app/features/app/components/floating/UserAreaPopout';
|
||||
import styles from '@app/features/app/components/layout/UserArea.module.css';
|
||||
import {
|
||||
@@ -27,7 +28,6 @@ import {TooltipWithKeybind} from '@app/features/ui/keybind_hint/KeybindHint';
|
||||
import {Popout} from '@app/features/ui/popover/PopoverPopout';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {USER_SETTINGS_LABEL_DESCRIPTOR} from '@app/features/user/components/settings_utils/SettingsConstants';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
@@ -142,7 +142,10 @@ const UserAreaInner = observer(
|
||||
};
|
||||
const handleSettingsClick = () => {
|
||||
ModalCommands.push(
|
||||
modal(() => <UserSettingsModal data-flx="app.user-area.handle-settings-click.user-settings-modal" />),
|
||||
modal(
|
||||
() => <UserSettingsModal data-flx="app.user-area.handle-settings-click.user-settings-modal" />,
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
};
|
||||
const storeConnectedChannelId = MediaEngine.channelId;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
@@ -299,7 +300,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
? false
|
||||
: nagbarState.forceUnclaimedAccount
|
||||
? true
|
||||
: Boolean(user && !user.isClaimed()),
|
||||
: Boolean(!Accounts.isSwitching && user && !user.isClaimed()),
|
||||
userNeedsVerification: nagbarState.forceHideEmailVerification
|
||||
? false
|
||||
: nagbarState.forceEmailVerification
|
||||
|
||||
+34
-14
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {switchAccountFromStalledConnection} from '@app/features/app/ConnectionRecovery';
|
||||
import {retryStalledConnection, switchAccountFromStalledConnection} from '@app/features/app/ConnectionRecovery';
|
||||
import styles from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar.module.css';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
@@ -17,11 +17,9 @@ import {
|
||||
ConnectionNoticeTone,
|
||||
useConnectionNotice,
|
||||
} from '@app/features/app/hooks/useConnectionNotice';
|
||||
import AccountSwitcherModal from '@app/features/auth/components/accounts/AccountSwitcherModal';
|
||||
import {openAccountSwitcherModal} from '@app/features/auth/commands/AccountSwitcherModalCommands';
|
||||
import {Typing} from '@app/features/channel/components/ChannelTyping';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -38,6 +36,10 @@ const SWITCH_ACCOUNT_DESCRIPTOR = msg({
|
||||
message: 'Switch account',
|
||||
comment: 'Button on the connection banner that opens the account switcher.',
|
||||
});
|
||||
const TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: 'Try again',
|
||||
comment: 'Button on the connection banner that retries a foreground gateway connection after bounded recovery fails.',
|
||||
});
|
||||
|
||||
function resolveConnectionActionUrl(notice: ConnectionNotice | null): string | null {
|
||||
if (notice == null || notice.action == null) {
|
||||
@@ -63,18 +65,19 @@ export const ConnectionNagbar = observer(({isMobile, rememberedRows}: Connection
|
||||
openExternalUrlWithWarning(actionUrl);
|
||||
}, [actionUrl]);
|
||||
const handleSwitchAccount = useCallback(() => {
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => (
|
||||
<AccountSwitcherModal
|
||||
redirectAfterLogin={null}
|
||||
redirectAfterSwitch={null}
|
||||
switchAccount={switchAccountFromStalledConnection}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.handle-switch-account.account-switcher-modal"
|
||||
/>
|
||||
)),
|
||||
openAccountSwitcherModal(
|
||||
{
|
||||
closeCurrentAccountOnSelect: true,
|
||||
initialReloginAccount: null,
|
||||
redirectAfterLogin: null,
|
||||
redirectAfterSwitch: null,
|
||||
switchAccount: switchAccountFromStalledConnection,
|
||||
'data-flx': 'app.app-layout.nagbars.connection-nagbar.handle-switch-account.account-switcher-modal',
|
||||
},
|
||||
CONNECTION_ACCOUNT_SWITCHER_MODAL_KEY,
|
||||
);
|
||||
}, []);
|
||||
const handleRetry = useCallback(() => retryStalledConnection(), []);
|
||||
if (notice == null) {
|
||||
return (
|
||||
<NagbarSkeleton
|
||||
@@ -118,13 +121,30 @@ export const ConnectionNagbar = observer(({isMobile, rememberedRows}: Connection
|
||||
</NagbarButton>
|
||||
);
|
||||
}
|
||||
function renderRetryAction() {
|
||||
if (!activeNotice.showRetry) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleRetry}
|
||||
disabled={false}
|
||||
submitting={false}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.render-retry-action.nagbar-button.retry"
|
||||
>
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
);
|
||||
}
|
||||
function renderActions() {
|
||||
if (activeNotice.action == null && !activeNotice.showSwitchAccount) {
|
||||
if (activeNotice.action == null && !activeNotice.showRetry && !activeNotice.showSwitchAccount) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{renderNoticeAction()}
|
||||
{renderRetryAction()}
|
||||
{renderSwitchAccountAction()}
|
||||
</>
|
||||
);
|
||||
|
||||
+10
-7
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
@@ -8,7 +9,6 @@ import {OPEN_SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDe
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {BouncedEmailChangeModal} from '@app/features/user/components/modals/BouncedEmailChangeModal';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -45,12 +45,15 @@ export const EmailVerificationNagbar = observer(({isMobile}: {isMobile: boolean}
|
||||
};
|
||||
const openUserSettings = () => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="account_security"
|
||||
data-flx="app.app-layout.nagbars.email-verification-nagbar.open-user-settings.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="account_security"
|
||||
data-flx="app.app-layout.nagbars.email-verification-nagbar.open-user-settings.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
};
|
||||
return (
|
||||
|
||||
+10
-7
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
@@ -8,7 +9,6 @@ import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtil
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as UserCommands from '@app/features/user/commands/UserCommands';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -31,12 +31,15 @@ const GiftInventoryNagbarContent = observer(function GiftInventoryNagbarContent(
|
||||
const message = i18n._(GIFT_INVENTORY_MESSAGE_DESCRIPTOR, {count: unreadCount});
|
||||
const handleOpenGiftInventory = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="gift_inventory"
|
||||
data-flx="app.app-layout.nagbars.gift-inventory-nagbar.handle-open-gift-inventory.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="gift_inventory"
|
||||
data-flx="app.app-layout.nagbars.gift-inventory-nagbar.handle-open-gift-inventory.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const handleDismiss = useCallback(() => {
|
||||
|
||||
+5
-2
@@ -13,6 +13,7 @@ import {InviteAcceptModal} from '@app/features/invite/components/modals/InviteAc
|
||||
import Invites from '@app/features/invite/state/Invites';
|
||||
import {isGuildInvite} from '@app/features/invite/types/InviteTypes';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import {currentInstanceTarget} from '@app/features/platform/transport/InstanceHTTP';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
@@ -34,7 +35,8 @@ export const GuildMembershipCtaNagbar = observer(({isMobile}: {isMobile: boolean
|
||||
const {i18n} = useLingui();
|
||||
const isSelfHosted = RuntimeConfig.isSelfHosted();
|
||||
const currentUserId = Authentication.currentUserId;
|
||||
const inviteState = Invites.invites.get(FLUXER_HQ_INVITE_CODE);
|
||||
const instanceTarget = currentInstanceTarget();
|
||||
const inviteState = Invites.getInvite(FLUXER_HQ_INVITE_CODE, instanceTarget);
|
||||
const invite = inviteState?.data ?? null;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -60,13 +62,14 @@ export const GuildMembershipCtaNagbar = observer(({isMobile}: {isMobile: boolean
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await InviteCommands.fetchWithCoalescing(FLUXER_HQ_INVITE_CODE);
|
||||
await InviteCommands.fetchWithCoalescing(FLUXER_HQ_INVITE_CODE, instanceTarget);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<InviteAcceptModal
|
||||
code={FLUXER_HQ_INVITE_CODE}
|
||||
target={instanceTarget}
|
||||
data-flx="app.app-layout.nagbars.guild-membership-cta-nagbar.handle-join-guild.invite-accept-modal"
|
||||
/>
|
||||
)),
|
||||
|
||||
+12
-9
@@ -1,20 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import * as PlutoniumPageCommands from '@app/features/premium/commands/PlutoniumPageCommands';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PlutoniumPageRollout from '@app/features/premium/state/PlutoniumPageRollout';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
|
||||
@@ -34,12 +34,15 @@ export const LegacyPriceOptInNagbar = observer(function LegacyPriceOptInNagbar({
|
||||
return;
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.handle-open-premium-settings.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.handle-open-premium-settings.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const handleDismiss = useCallback(() => {
|
||||
@@ -72,7 +75,7 @@ export const LegacyPriceOptInNagbar = observer(function LegacyPriceOptInNagbar({
|
||||
? resolved.campaign.optInMonthlyMessage
|
||||
: resolved.campaign.optInYearlyMessage,
|
||||
{
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
currentPrice,
|
||||
newPrice,
|
||||
effectiveDate: getFormattedLongDate(listPriceSwitch.effective_at, locale),
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@ import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
@@ -76,7 +76,7 @@ export const PremiumExpiredNagbar = observer(({isMobile}: {isMobile: boolean}) =
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(PREMIUM_EXPIRED_MESSAGE_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
message={i18n._(PREMIUM_EXPIRED_MESSAGE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
|
||||
+2
-2
@@ -5,11 +5,11 @@ import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
|
||||
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
@@ -79,7 +79,7 @@ export const PremiumGracePeriodNagbar = observer(({isMobile}: {isMobile: boolean
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(PREMIUM_GRACE_PERIOD_MESSAGE_DESCRIPTOR, {
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
formattedGraceDate,
|
||||
})}
|
||||
actions={
|
||||
|
||||
+18
-12
@@ -1,19 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import * as PlutoniumPageCommands from '@app/features/premium/commands/PlutoniumPageCommands';
|
||||
import PlutoniumPageRollout from '@app/features/premium/state/PlutoniumPageRollout';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {getStoreOwnedSubscription} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {
|
||||
getPremiumProductFullName,
|
||||
getPremiumProductName,
|
||||
getStoreOwnedSubscription,
|
||||
} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
import * as UserCommands from '@app/features/user/commands/UserCommands';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {getFormattedFullDate} from '@app/features/user/utils/DateFormatting';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -69,8 +72,8 @@ function useOnboardingMessage(i18n: I18n): string {
|
||||
const storeSubscription =
|
||||
user != null && PremiumState.loadedForUserId === user.id ? getStoreOwnedSubscription(PremiumState.state) : null;
|
||||
return useMemo(() => {
|
||||
const premiumProductFullName = PREMIUM_PRODUCT_FULL_NAME;
|
||||
const premiumProductName = PREMIUM_PRODUCT_NAME;
|
||||
const premiumProductFullName = getPremiumProductFullName();
|
||||
const premiumProductName = getPremiumProductName();
|
||||
if (!user) {
|
||||
return i18n._(PREMIUM_ONBOARDING_DEFAULT_MESSAGE_DESCRIPTOR, {premiumProductFullName, premiumProductName});
|
||||
}
|
||||
@@ -127,12 +130,15 @@ export const PremiumOnboardingNagbar = observer(function PremiumOnboardingNagbar
|
||||
return;
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.premium-onboarding-nagbar.handle-open-premium-settings.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.premium-onboarding-nagbar.handle-open-premium-settings.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const handleDismiss = useCallback(() => {
|
||||
@@ -158,7 +164,7 @@ export const PremiumOnboardingNagbar = observer(function PremiumOnboardingNagbar
|
||||
onClick={handleOpenPremiumSettings}
|
||||
data-flx="app.app-layout.nagbars.premium-onboarding-nagbar.nagbar-button.open-premium-settings"
|
||||
>
|
||||
{i18n._(VIEW_PREMIUM_FEATURES_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(VIEW_PREMIUM_FEATURES_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.premium-onboarding-nagbar.nagbar-content"
|
||||
|
||||
+12
-9
@@ -1,20 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import * as PlutoniumPageCommands from '@app/features/premium/commands/PlutoniumPageCommands';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PlutoniumPageRollout from '@app/features/premium/state/PlutoniumPageRollout';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {VIEW_PLANS_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -36,12 +36,15 @@ export const PriceAnnouncementNagbar = observer(function PriceAnnouncementNagbar
|
||||
return;
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.handle-open-plans.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.handle-open-plans.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
}, [campaignId]);
|
||||
const handleDismiss = useCallback(() => {
|
||||
@@ -66,7 +69,7 @@ export const PriceAnnouncementNagbar = observer(function PriceAnnouncementNagbar
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(resolved.campaign.announcementMessage, {
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
monthlyPrice,
|
||||
yearlyPrice,
|
||||
})}
|
||||
|
||||
+11
-8
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
@@ -9,7 +10,6 @@ import {ENABLE_TWO_FACTOR_AUTH_DESCRIPTOR} from '@app/features/i18n/utils/Common
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -24,13 +24,16 @@ export const VisionaryMfaNagbar = observer(({isMobile}: {isMobile: boolean}) =>
|
||||
const {i18n} = useLingui();
|
||||
const handleEnableMfa = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="account_security"
|
||||
initialSubtab="security"
|
||||
data-flx="app.app-layout.nagbars.visionary-mfa-nagbar.handle-enable-mfa.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="account_security"
|
||||
initialSubtab="security"
|
||||
data-flx="app.app-layout.nagbars.visionary-mfa-nagbar.handle-enable-mfa.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const handleDismiss = useCallback(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface GuildListItemContentProps extends GuildListItemProps {
|
||||
readonly isDesktopLayout: boolean;
|
||||
@@ -47,6 +47,10 @@ export const GuildListItemContent = observer((props: GuildListItemContentProps)
|
||||
isSortingList: props.isSortingList,
|
||||
itemRef: props.itemRef,
|
||||
});
|
||||
const [hasOpenedBottomSheet, setHasOpenedBottomSheet] = useState(false);
|
||||
if (interaction.bottomSheetOpen && !hasOpenedBottomSheet) {
|
||||
setHasOpenedBottomSheet(true);
|
||||
}
|
||||
const focusableRef = useRef<HTMLElement | null>(null);
|
||||
const focusRingTargetRef = useRef<HTMLElement | null>(null);
|
||||
const didMountRef = useRef(false);
|
||||
@@ -137,7 +141,7 @@ export const GuildListItemContent = observer((props: GuildListItemContentProps)
|
||||
/>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
{props.isMobileExperience && (
|
||||
{props.isMobileExperience && hasOpenedBottomSheet && (
|
||||
<GuildHeaderBottomSheet
|
||||
isOpen={interaction.bottomSheetOpen}
|
||||
onClose={interaction.handleCloseBottomSheet}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import styles from '@app/features/app/components/pages/YouPage.module.css';
|
||||
import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay';
|
||||
import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl';
|
||||
@@ -10,7 +11,6 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import {NoteEditSheet} from '@app/features/user/components/modals/NoteEditSheet';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {UserProfileBadges} from '@app/features/user/components/popouts/UserProfileBadges';
|
||||
import {UserProfileBio, UserProfileMembershipInfo} from '@app/features/user/components/popouts/UserProfileShared';
|
||||
import {normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
|
||||
@@ -40,13 +40,18 @@ export const YouPage = observer(({onAvatarClick}: YouPageProps) => {
|
||||
const userNote = user ? UserNote.getUserNote(user.id) : '';
|
||||
const [noteSheetOpen, setNoteSheetOpen] = useState(false);
|
||||
const handleSettings = () => {
|
||||
ModalCommands.push(modal(() => <UserSettingsModal data-flx="app.you-page.handle-settings.user-settings-modal" />));
|
||||
ModalCommands.push(
|
||||
modal(() => <UserSettingsModal data-flx="app.you-page.handle-settings.user-settings-modal" />, 'user-settings'),
|
||||
);
|
||||
};
|
||||
const handleEditProfile = () => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal initialTab="my_profile" data-flx="app.you-page.handle-edit-profile.user-settings-modal" />
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal initialTab="my_profile" data-flx="app.you-page.handle-edit-profile.user-settings-modal" />
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
};
|
||||
const profile = useMemo(() => (user ? createMockProfile(user) : null), [user]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.backdrop {
|
||||
isolation: isolate;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(75, 168, 255, 0.34), transparent 58%),
|
||||
@@ -89,7 +90,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: 5.1rem;
|
||||
height: 7.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@
|
||||
.welcomeWordMeasure {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
font-size: 3.35rem;
|
||||
font-size: 4.75rem;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
@@ -374,7 +375,7 @@
|
||||
|
||||
.welcomeWord,
|
||||
.welcomeWordMeasure {
|
||||
font-size: 2.65rem;
|
||||
font-size: 3.4rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -43,11 +43,15 @@ import {
|
||||
ThemeStep,
|
||||
WelcomeStep,
|
||||
} from '@app/features/app/components/setup/SetupWizardSteps';
|
||||
import {resolveAppShellBranding} from '@app/features/app/state/AppShellBranding';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import {AuthRegisterDraftContext, type AuthRegisterFormDraft} from '@app/features/auth/state/AuthRegisterDraftContext';
|
||||
import {getAcceptString} from '@app/features/expressions/utils/AssetFormatCopy';
|
||||
import {BACK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
|
||||
import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork';
|
||||
import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -55,8 +59,15 @@ import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
|
||||
import {PortalHostContext} from '@app/features/ui/overlay/PortalHostContext';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import ModalState from '@app/features/ui/state/Modal';
|
||||
import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel';
|
||||
import {
|
||||
ModalStackContext,
|
||||
type ModalStackContextValue,
|
||||
UNSTACKED_MODAL_CONTEXT,
|
||||
} from '@app/features/ui/utils/ModalStackContext';
|
||||
import * as RecoveryKitCommands from '@app/features/user/commands/RecoveryKitCommands';
|
||||
import {confirmNewRecoveryKit} from '@app/features/user/components/modals/tabs/account_security_tab/RecoveryKitSettings';
|
||||
import RecoveryKitStatus from '@app/features/user/state/RecoveryKitStatus';
|
||||
@@ -81,6 +92,8 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('SelfHostedSetupWizardGate');
|
||||
|
||||
const BEHIND_STACKED_MODAL_PLACEMENT: ModalStackContextValue = {...UNSTACKED_MODAL_CONTEXT, isTopmost: false};
|
||||
|
||||
const SETUP_WIZARD_DESCRIPTOR = msg({
|
||||
message: 'Self-host setup',
|
||||
comment: 'Accessible label for the self-host setup wizard carousel.',
|
||||
@@ -89,10 +102,6 @@ const HEADER_DESCRIPTOR = msg({
|
||||
message: 'Set up {productName}',
|
||||
comment: 'Title for the self-host setup wizard modal.',
|
||||
});
|
||||
const BACK_DESCRIPTOR = msg({
|
||||
message: 'Back',
|
||||
comment: 'Button that moves to the previous setup wizard step.',
|
||||
});
|
||||
const NEXT_DESCRIPTOR = msg({
|
||||
message: 'Next',
|
||||
comment: 'Button that moves to the next setup wizard step.',
|
||||
@@ -417,12 +426,15 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const authStoreAuthenticated = Authentication.isAuthenticated;
|
||||
const accountWorkAdmitted = !AccountScopedWork.isSuspended;
|
||||
const themeHydrated = Theme.isHydrated;
|
||||
const fallbackProductName = RuntimeConfig.productName;
|
||||
const fallbackProductName = resolveAppShellBranding(RuntimeConfig.getSnapshotOrNull()).productName;
|
||||
const welcomeInitialFocusRef = useRef<HTMLElement | null>(null);
|
||||
const registerFormDraftsRef = useRef<Map<string, AuthRegisterFormDraft>>(new Map());
|
||||
const stepNavigationLockedRef = useRef(false);
|
||||
const stepNavigationUnlockTimerRef = useRef<number | null>(null);
|
||||
const [layerHost, setLayerHost] = useState<HTMLDivElement | null>(null);
|
||||
const modalStackPlacement = ModalState.hasModalOpen() ? BEHIND_STACKED_MODAL_PLACEMENT : UNSTACKED_MODAL_CONTEXT;
|
||||
|
||||
const [config, setConfig] = useState<InstanceConfigResponse | null>(null);
|
||||
const accountIdentity = config?.account_identity.mode ?? RuntimeConfig.accountIdentity;
|
||||
@@ -614,7 +626,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || config) return;
|
||||
if (!isAuthenticated || config || !accountWorkAdmitted) return;
|
||||
let cancelled = false;
|
||||
setLoadError(null);
|
||||
void (async () => {
|
||||
@@ -623,7 +635,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
if (cancelled) return;
|
||||
hydrateFromConfig(next);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || isAccountTransitionAbortError(error)) return;
|
||||
const cause =
|
||||
error instanceof HttpError && error.status === 401 ? await classifySetupUnauthorized() : 'unknown';
|
||||
if (cancelled) return;
|
||||
@@ -638,7 +650,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated, config, hydrateFromConfig, resetStaleSetupSession]);
|
||||
}, [isAuthenticated, config, accountWorkAdmitted, hydrateFromConfig, resetStaleSetupSession]);
|
||||
|
||||
const serviceAvailability: ServiceAvailability = useMemo(
|
||||
() => ({
|
||||
@@ -971,300 +983,312 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
|
||||
return (
|
||||
<AuthRegisterDraftContext.Provider value={authRegisterDraftContextValue}>
|
||||
<div className={styles.backdrop} data-flx="app.self-hosted-setup-wizard-gate.backdrop">
|
||||
<Modal.Root
|
||||
size="medium"
|
||||
centered
|
||||
disableHistoryManagement
|
||||
className={styles.modal}
|
||||
initialFocusRef={welcomeInitialFocusRef}
|
||||
backdropSlot={
|
||||
<div className={styles.backdropVisual} data-flx="app.self-hosted-setup-wizard-gate.backdrop-visual" />
|
||||
}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.modal"
|
||||
>
|
||||
<Modal.Header
|
||||
title={i18n._(HEADER_DESCRIPTOR, {productName: fallbackProductName})}
|
||||
icon={<WrenchIcon size={22} weight="bold" data-flx="app.self-hosted-setup-wizard-gate.header-icon" />}
|
||||
hideCloseButton
|
||||
data-flx="app.self-hosted-setup-wizard-gate.header"
|
||||
/>
|
||||
<Modal.Content className={styles.contentScroller} data-flx="app.self-hosted-setup-wizard-gate.content">
|
||||
<div className={styles.content} data-flx="app.self-hosted-setup-wizard-gate.content-inner">
|
||||
{loadError ? (
|
||||
<div className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.load-error-wrap">
|
||||
<p
|
||||
className={styles.submitError}
|
||||
role="alert"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.load-error"
|
||||
>
|
||||
{i18n._(loadError)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<SteppedCarousel
|
||||
step={step}
|
||||
steps={steps}
|
||||
direction={direction}
|
||||
focusOnStepChange
|
||||
ariaLabel={i18n._(SETUP_WIZARD_DESCRIPTOR)}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.carousel"
|
||||
>
|
||||
{step === 'welcome' && (
|
||||
<WelcomeStep
|
||||
productName={fallbackProductName}
|
||||
isAuthenticated={isAuthenticated}
|
||||
initialFocusRef={welcomeInitialFocusRef}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.welcome-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'theme' && (
|
||||
<ThemeStep
|
||||
theme={setupTheme}
|
||||
onThemeChange={setSetupTheme}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.theme-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'sign_in_method' && (
|
||||
<SignInMethodStep
|
||||
mode={signInMethod}
|
||||
tagStyle={tagStyleChoice}
|
||||
onTagStyleChange={(value) => {
|
||||
setTagStyleChoice(value);
|
||||
setSignInMethodError(null);
|
||||
}}
|
||||
disabled={savingSignInMethod}
|
||||
error={signInMethodError}
|
||||
onChange={handleSignInMethodChange}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.sign-in-method-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'admin_intro' && (
|
||||
<AdminIntroStep data-flx="app.setup.self-hosted-setup-wizard-gate.admin-intro-step" />
|
||||
)}
|
||||
{step === 'admin_account' && (
|
||||
<AdminAccountStep
|
||||
theme={setupTheme}
|
||||
usernameSignIn={accountIdentity === AccountIdentityModes.USERNAME}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.admin-account-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'admin_recovery_kit' && (
|
||||
<AdminRecoveryKitStep
|
||||
hasRecoveryKit={hasRecoveryKit}
|
||||
creating={creatingRecoveryKit}
|
||||
onCreate={handleCreateRecoveryKit}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.admin-recovery-kit-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'loading' && (
|
||||
<LoadingStep data-flx="app.setup.self-hosted-setup-wizard-gate.loading-step" />
|
||||
)}
|
||||
{step === 'branding' && (
|
||||
<BrandingStep
|
||||
productName={productName}
|
||||
productNameError={productNameError}
|
||||
themeColor={themeColor}
|
||||
assets={assets}
|
||||
disabled={submitting}
|
||||
onProductNameChange={setProductName}
|
||||
onThemeColorChange={setThemeColor}
|
||||
onUploadAsset={handleUploadAsset}
|
||||
onClearAsset={handleClearAsset}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.branding-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'registration' && (
|
||||
<RegistrationStep
|
||||
mode={registrationMode}
|
||||
disabled={submitting}
|
||||
onChange={setRegistrationMode}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.registration-step.set-registration-mode"
|
||||
/>
|
||||
)}
|
||||
{step === 'community' && (
|
||||
<CommunityStep
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
singleCommunityName={singleCommunityName}
|
||||
singleCommunityNameError={singleCommunityNameError}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
disabled={submitting}
|
||||
onToggleSingleCommunity={setSingleCommunityEnabled}
|
||||
onSingleCommunityNameChange={setSingleCommunityName}
|
||||
onToggleDirectMessages={setDirectMessagesDisabled}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.community-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'media_expiry' && (
|
||||
<MediaExpiryStep
|
||||
draft={mediaExpiryDraft}
|
||||
disabled={submitting}
|
||||
onDraftChange={handleMediaExpiryDraftChange}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.media-expiry-step"
|
||||
/>
|
||||
)}
|
||||
{wizardStepToIntegrationKind(step) && (
|
||||
<IntegrationStep
|
||||
kind={wizardStepToIntegrationKind(step)!}
|
||||
draft={integrationDraft}
|
||||
disabled={submitting}
|
||||
smtpTesting={smtpTesting}
|
||||
smtpTestResult={smtpTestResult}
|
||||
onDraftChange={handleIntegrationDraftChange}
|
||||
onTestSmtp={handleTestSmtp}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'push_relay_consent' && (
|
||||
<PushRelayConsentStep
|
||||
accepted={pushRelayConsentAccepted}
|
||||
disabled={submitting}
|
||||
onChange={setPushRelayConsentAccepted}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'services' && (
|
||||
<ServicesStep
|
||||
available={serviceAvailability}
|
||||
selection={serviceSelection}
|
||||
disabled={submitting}
|
||||
onToggle={handleToggleService}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.services-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'premium' && (
|
||||
<PremiumStep
|
||||
mode={premiumMode}
|
||||
disabled={submitting}
|
||||
onChange={setPremiumMode}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.premium-step.set-premium-mode"
|
||||
/>
|
||||
)}
|
||||
{step === 'finish' && (
|
||||
<FinishStep
|
||||
productName={productNameTrimmed}
|
||||
accountIdentity={accountIdentity}
|
||||
tagStyle={config?.account_identity.tag_style ?? RuntimeConfig.tagStyle}
|
||||
registrationMode={registrationMode}
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
|
||||
pushRelayConsentAccepted={pushRelayConsentAccepted}
|
||||
premiumMode={premiumMode}
|
||||
submitError={submitError}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
|
||||
/>
|
||||
)}
|
||||
</SteppedCarousel>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<AnimatePresence initial={false} data-flx="app.self-hosted-setup-wizard-gate.footer-presence">
|
||||
{showFooter && (
|
||||
<motion.div
|
||||
key="footer"
|
||||
className={styles.footerReveal}
|
||||
initial={{height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={{height: 0, opacity: 0}}
|
||||
transition={shouldReduceMotion ? instantTransition : footerRevealTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.footer-reveal"
|
||||
<div ref={setLayerHost} className={styles.backdrop} data-flx="app.self-hosted-setup-wizard-gate.backdrop">
|
||||
{layerHost && (
|
||||
<PortalHostContext.Provider value={layerHost}>
|
||||
<ModalStackContext.Provider value={modalStackPlacement}>
|
||||
<Modal.Root
|
||||
size="medium"
|
||||
centered
|
||||
disableHistoryManagement
|
||||
className={styles.modal}
|
||||
initialFocusRef={welcomeInitialFocusRef}
|
||||
backdropSlot={
|
||||
<div className={styles.backdropVisual} data-flx="app.self-hosted-setup-wizard-gate.backdrop-visual" />
|
||||
}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.modal"
|
||||
>
|
||||
<Modal.Footer className={styles.footer} data-flx="app.self-hosted-setup-wizard-gate.footer">
|
||||
<div className={styles.footerActions} data-flx="app.self-hosted-setup-wizard-gate.footer-actions">
|
||||
<AnimatePresence initial={false} data-flx="app.self-hosted-setup-wizard-gate.back-presence">
|
||||
{showBackButton && (
|
||||
<motion.div
|
||||
key="back"
|
||||
initial={footerButtonInitial}
|
||||
animate={footerButtonAnimate}
|
||||
exit={footerButtonExit}
|
||||
transition={shouldReduceMotion ? instantTransition : footerButtonTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.back-button-wrap"
|
||||
<Modal.Header
|
||||
title={i18n._(HEADER_DESCRIPTOR, {productName: fallbackProductName})}
|
||||
icon={<WrenchIcon size={22} weight="bold" data-flx="app.self-hosted-setup-wizard-gate.header-icon" />}
|
||||
hideCloseButton
|
||||
data-flx="app.self-hosted-setup-wizard-gate.header"
|
||||
/>
|
||||
<Modal.Content className={styles.contentScroller} data-flx="app.self-hosted-setup-wizard-gate.content">
|
||||
<div className={styles.content} data-flx="app.self-hosted-setup-wizard-gate.content-inner">
|
||||
{loadError ? (
|
||||
<div className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.load-error-wrap">
|
||||
<p
|
||||
className={styles.submitError}
|
||||
role="alert"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.load-error"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={stepNavigationLocked}
|
||||
leftIcon={
|
||||
<ArrowLeftIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.arrow-left-icon"
|
||||
/>
|
||||
}
|
||||
onClick={goBack}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.back-button"
|
||||
>
|
||||
{i18n._(BACK_DESCRIPTOR)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className={styles.footerPrimary} data-flx="app.self-hosted-setup-wizard-gate.footer-primary">
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
initial={false}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.primary-presence"
|
||||
{i18n._(loadError)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<SteppedCarousel
|
||||
step={step}
|
||||
steps={steps}
|
||||
direction={direction}
|
||||
focusOnStepChange
|
||||
ariaLabel={i18n._(SETUP_WIZARD_DESCRIPTOR)}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.carousel"
|
||||
>
|
||||
{showPrimaryButton && isFinish && (
|
||||
<motion.div
|
||||
key="finish"
|
||||
initial={footerButtonInitial}
|
||||
animate={footerButtonAnimate}
|
||||
exit={footerButtonExit}
|
||||
transition={shouldReduceMotion ? instantTransition : footerButtonTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.finish-button-wrap"
|
||||
>
|
||||
<Button
|
||||
submitting={submitting}
|
||||
rightIcon={
|
||||
<CheckIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.check-icon"
|
||||
/>
|
||||
}
|
||||
onClick={submit}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.finish-button"
|
||||
>
|
||||
{i18n._(FINISH_DESCRIPTOR)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
{step === 'welcome' && (
|
||||
<WelcomeStep
|
||||
productName={fallbackProductName}
|
||||
isAuthenticated={isAuthenticated}
|
||||
initialFocusRef={welcomeInitialFocusRef}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.welcome-step"
|
||||
/>
|
||||
)}
|
||||
{showPrimaryButton && !isFinish && (
|
||||
<motion.div
|
||||
key="next"
|
||||
initial={footerButtonInitial}
|
||||
animate={footerButtonAnimate}
|
||||
exit={footerButtonExit}
|
||||
transition={shouldReduceMotion ? instantTransition : footerButtonTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.next-button-wrap"
|
||||
>
|
||||
<Button
|
||||
disabled={primaryButtonDisabled}
|
||||
submitting={primaryButtonSubmitting}
|
||||
rightIcon={
|
||||
<ArrowRightIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.arrow-right-icon"
|
||||
/>
|
||||
}
|
||||
onClick={handlePrimaryButton}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.next-button"
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</motion.div>
|
||||
{step === 'theme' && (
|
||||
<ThemeStep
|
||||
theme={setupTheme}
|
||||
onThemeChange={setSetupTheme}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.theme-step"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{step === 'sign_in_method' && (
|
||||
<SignInMethodStep
|
||||
mode={signInMethod}
|
||||
tagStyle={tagStyleChoice}
|
||||
onTagStyleChange={(value) => {
|
||||
setTagStyleChoice(value);
|
||||
setSignInMethodError(null);
|
||||
}}
|
||||
disabled={savingSignInMethod}
|
||||
error={signInMethodError}
|
||||
onChange={handleSignInMethodChange}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.sign-in-method-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'admin_intro' && (
|
||||
<AdminIntroStep data-flx="app.setup.self-hosted-setup-wizard-gate.admin-intro-step" />
|
||||
)}
|
||||
{step === 'admin_account' && (
|
||||
<AdminAccountStep
|
||||
theme={setupTheme}
|
||||
usernameSignIn={accountIdentity === AccountIdentityModes.USERNAME}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.admin-account-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'admin_recovery_kit' && (
|
||||
<AdminRecoveryKitStep
|
||||
hasRecoveryKit={hasRecoveryKit}
|
||||
creating={creatingRecoveryKit}
|
||||
onCreate={handleCreateRecoveryKit}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.admin-recovery-kit-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'loading' && (
|
||||
<LoadingStep data-flx="app.setup.self-hosted-setup-wizard-gate.loading-step" />
|
||||
)}
|
||||
{step === 'branding' && (
|
||||
<BrandingStep
|
||||
productName={productName}
|
||||
productNameError={productNameError}
|
||||
themeColor={themeColor}
|
||||
assets={assets}
|
||||
disabled={submitting}
|
||||
onProductNameChange={setProductName}
|
||||
onThemeColorChange={setThemeColor}
|
||||
onUploadAsset={handleUploadAsset}
|
||||
onClearAsset={handleClearAsset}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.branding-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'registration' && (
|
||||
<RegistrationStep
|
||||
mode={registrationMode}
|
||||
disabled={submitting}
|
||||
onChange={setRegistrationMode}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.registration-step.set-registration-mode"
|
||||
/>
|
||||
)}
|
||||
{step === 'community' && (
|
||||
<CommunityStep
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
singleCommunityName={singleCommunityName}
|
||||
singleCommunityNameError={singleCommunityNameError}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
disabled={submitting}
|
||||
onToggleSingleCommunity={setSingleCommunityEnabled}
|
||||
onSingleCommunityNameChange={setSingleCommunityName}
|
||||
onToggleDirectMessages={setDirectMessagesDisabled}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.community-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'media_expiry' && (
|
||||
<MediaExpiryStep
|
||||
draft={mediaExpiryDraft}
|
||||
disabled={submitting}
|
||||
onDraftChange={handleMediaExpiryDraftChange}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.media-expiry-step"
|
||||
/>
|
||||
)}
|
||||
{wizardStepToIntegrationKind(step) && (
|
||||
<IntegrationStep
|
||||
kind={wizardStepToIntegrationKind(step)!}
|
||||
draft={integrationDraft}
|
||||
disabled={submitting}
|
||||
smtpTesting={smtpTesting}
|
||||
smtpTestResult={smtpTestResult}
|
||||
onDraftChange={handleIntegrationDraftChange}
|
||||
onTestSmtp={handleTestSmtp}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'push_relay_consent' && (
|
||||
<PushRelayConsentStep
|
||||
accepted={pushRelayConsentAccepted}
|
||||
disabled={submitting}
|
||||
onChange={setPushRelayConsentAccepted}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'services' && (
|
||||
<ServicesStep
|
||||
available={serviceAvailability}
|
||||
selection={serviceSelection}
|
||||
disabled={submitting}
|
||||
onToggle={handleToggleService}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.services-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'premium' && (
|
||||
<PremiumStep
|
||||
mode={premiumMode}
|
||||
disabled={submitting}
|
||||
onChange={setPremiumMode}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.premium-step.set-premium-mode"
|
||||
/>
|
||||
)}
|
||||
{step === 'finish' && (
|
||||
<FinishStep
|
||||
productName={productNameTrimmed}
|
||||
accountIdentity={accountIdentity}
|
||||
tagStyle={config?.account_identity.tag_style ?? RuntimeConfig.tagStyle}
|
||||
registrationMode={registrationMode}
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
|
||||
pushRelayConsentAccepted={pushRelayConsentAccepted}
|
||||
premiumMode={premiumMode}
|
||||
submitError={submitError}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
|
||||
/>
|
||||
)}
|
||||
</SteppedCarousel>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Modal.Root>
|
||||
</Modal.Content>
|
||||
<AnimatePresence initial={false} data-flx="app.self-hosted-setup-wizard-gate.footer-presence">
|
||||
{showFooter && (
|
||||
<motion.div
|
||||
key="footer"
|
||||
className={styles.footerReveal}
|
||||
initial={{height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={{height: 0, opacity: 0}}
|
||||
transition={shouldReduceMotion ? instantTransition : footerRevealTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.footer-reveal"
|
||||
>
|
||||
<Modal.Footer className={styles.footer} data-flx="app.self-hosted-setup-wizard-gate.footer">
|
||||
<div
|
||||
className={styles.footerActions}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.footer-actions"
|
||||
>
|
||||
<AnimatePresence initial={false} data-flx="app.self-hosted-setup-wizard-gate.back-presence">
|
||||
{showBackButton && (
|
||||
<motion.div
|
||||
key="back"
|
||||
initial={footerButtonInitial}
|
||||
animate={footerButtonAnimate}
|
||||
exit={footerButtonExit}
|
||||
transition={shouldReduceMotion ? instantTransition : footerButtonTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.back-button-wrap"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={stepNavigationLocked}
|
||||
leftIcon={
|
||||
<ArrowLeftIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.arrow-left-icon"
|
||||
/>
|
||||
}
|
||||
onClick={goBack}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.back-button"
|
||||
>
|
||||
{i18n._(BACK_DESCRIPTOR)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
className={styles.footerPrimary}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.footer-primary"
|
||||
>
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
initial={false}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.primary-presence"
|
||||
>
|
||||
{showPrimaryButton && isFinish && (
|
||||
<motion.div
|
||||
key="finish"
|
||||
initial={footerButtonInitial}
|
||||
animate={footerButtonAnimate}
|
||||
exit={footerButtonExit}
|
||||
transition={shouldReduceMotion ? instantTransition : footerButtonTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.finish-button-wrap"
|
||||
>
|
||||
<Button
|
||||
submitting={submitting}
|
||||
rightIcon={
|
||||
<CheckIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.check-icon"
|
||||
/>
|
||||
}
|
||||
onClick={submit}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.finish-button"
|
||||
>
|
||||
{i18n._(FINISH_DESCRIPTOR)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
{showPrimaryButton && !isFinish && (
|
||||
<motion.div
|
||||
key="next"
|
||||
initial={footerButtonInitial}
|
||||
animate={footerButtonAnimate}
|
||||
exit={footerButtonExit}
|
||||
transition={shouldReduceMotion ? instantTransition : footerButtonTransition}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.next-button-wrap"
|
||||
>
|
||||
<Button
|
||||
disabled={primaryButtonDisabled}
|
||||
submitting={primaryButtonSubmitting}
|
||||
rightIcon={
|
||||
<ArrowRightIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.arrow-right-icon"
|
||||
/>
|
||||
}
|
||||
onClick={handlePrimaryButton}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.next-button"
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Modal.Root>
|
||||
</ModalStackContext.Provider>
|
||||
</PortalHostContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
</AuthRegisterDraftContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
|
||||
import {SetupWelcomeRoll} from '@app/features/app/components/setup/SetupWelcomeRoll';
|
||||
import {useSetupWelcomeRotation} from '@app/features/app/components/setup/useSetupWelcomeRotation';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
const WELCOME_AUTHED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Welcome to {productName}',
|
||||
comment: 'Setup wizard title shown once the operator is signed in as the instance admin.',
|
||||
});
|
||||
const WELCOME_AUTHED_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
'You are signed in as the instance administrator. The next steps configure branding, registration, and core policy for everyone on this instance.',
|
||||
comment: 'Setup wizard body shown to the signed-in instance administrator.',
|
||||
});
|
||||
|
||||
interface SetupWelcomeGateProps {
|
||||
localeCode: string;
|
||||
productName: string;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export const SetupWelcomeGate = observer(function SetupWelcomeGate({
|
||||
localeCode,
|
||||
productName,
|
||||
isAuthenticated,
|
||||
}: SetupWelcomeGateProps) {
|
||||
const {i18n} = useLingui();
|
||||
const {entry, upcoming} = useSetupWelcomeRotation(localeCode);
|
||||
const renderAdministratorIntroduction = (): ReactNode => {
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.welcomeTitle} data-flx="app.self-hosted-setup-wizard-gate.welcome-title">
|
||||
{i18n._(WELCOME_AUTHED_TITLE_DESCRIPTOR, {productName})}
|
||||
</h3>
|
||||
<p className={styles.body} data-flx="app.self-hosted-setup-wizard-gate.welcome-body">
|
||||
{i18n._(WELCOME_AUTHED_BODY_DESCRIPTOR)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<flx-app-setup-welcome-gate
|
||||
className={flxElementClassName(styles.welcomeHero)}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-hero"
|
||||
>
|
||||
<SetupWelcomeRoll
|
||||
entry={entry}
|
||||
upcoming={upcoming}
|
||||
frameClassName={styles.welcomeWordFrame}
|
||||
measureClassName={styles.welcomeWordMeasure}
|
||||
wordClassName={styles.welcomeWord}
|
||||
data-flx="app.setup.setup-welcome-gate.setup-welcome-roll"
|
||||
/>
|
||||
{renderAdministratorIntroduction()}
|
||||
</flx-app-setup-welcome-gate>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {WelcomeRotationEntry} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {AnimatePresence, motion, useReducedMotion} from 'framer-motion';
|
||||
import {useCallback, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
const WELCOME_WORD_INLINE_PADDING_PX = 8;
|
||||
const WELCOME_SCALE_EPSILON = 0.005;
|
||||
|
||||
interface SetupWelcomeRollProps {
|
||||
entry: WelcomeRotationEntry;
|
||||
upcoming: WelcomeRotationEntry;
|
||||
frameClassName: string;
|
||||
measureClassName: string;
|
||||
wordClassName: string;
|
||||
}
|
||||
|
||||
interface WelcomeMotionState {
|
||||
initial: {opacity: number; y?: number; scale: number};
|
||||
animate: {opacity: number; y?: number; scale: number};
|
||||
exit: {opacity: number; y?: number; scale: number};
|
||||
}
|
||||
|
||||
function resolveMotionState(scale: number, prefersReducedMotion: boolean): WelcomeMotionState {
|
||||
if (prefersReducedMotion) {
|
||||
return {
|
||||
initial: {opacity: 0, scale},
|
||||
animate: {opacity: 1, scale},
|
||||
exit: {opacity: 0, scale},
|
||||
};
|
||||
}
|
||||
return {
|
||||
initial: {opacity: 0, y: 14, scale: scale * 0.98},
|
||||
animate: {opacity: 1, y: 0, scale},
|
||||
exit: {opacity: 0, y: -14, scale: scale * 0.98},
|
||||
};
|
||||
}
|
||||
|
||||
export function SetupWelcomeRoll({
|
||||
entry,
|
||||
upcoming,
|
||||
frameClassName,
|
||||
measureClassName,
|
||||
wordClassName,
|
||||
}: SetupWelcomeRollProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const frameRef = useRef<HTMLElement | null>(null);
|
||||
const measureRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const measureScale = useCallback(() => {
|
||||
const frame = frameRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (frame == null || measure == null) return;
|
||||
const availableWidth = Math.max(0, frame.clientWidth - WELCOME_WORD_INLINE_PADDING_PX);
|
||||
const measuredWidth = Math.max(measure.scrollWidth, measure.getBoundingClientRect().width);
|
||||
const nextScale = measuredWidth > 0 && availableWidth > 0 ? Math.min(1, availableWidth / measuredWidth) : 1;
|
||||
setScale((currentScale) => (Math.abs(currentScale - nextScale) > WELCOME_SCALE_EPSILON ? nextScale : currentScale));
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
measureScale();
|
||||
const frame = frameRef.current;
|
||||
if (frame == null) return undefined;
|
||||
const ownerWindow = frame.ownerDocument.defaultView;
|
||||
const resizeObserver =
|
||||
typeof ownerWindow?.ResizeObserver === 'function' ? new ownerWindow.ResizeObserver(measureScale) : null;
|
||||
resizeObserver?.observe(frame);
|
||||
ownerWindow?.addEventListener('resize', measureScale);
|
||||
void frame.ownerDocument.fonts?.ready.then(measureScale);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
ownerWindow?.removeEventListener('resize', measureScale);
|
||||
};
|
||||
}, [measureScale, entry]);
|
||||
const motionState = resolveMotionState(scale, Boolean(prefersReducedMotion));
|
||||
return (
|
||||
<flx-app-setup-welcome-roll
|
||||
ref={frameRef}
|
||||
className={flxElementClassName(frameClassName)}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-word-frame"
|
||||
>
|
||||
<span
|
||||
ref={measureRef}
|
||||
className={measureClassName}
|
||||
aria-hidden="true"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-word-measure"
|
||||
>
|
||||
{entry.text}
|
||||
</span>
|
||||
<span
|
||||
className={measureClassName}
|
||||
aria-hidden="true"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-word-upcoming"
|
||||
>
|
||||
{upcoming.text}
|
||||
</span>
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
initial={false}
|
||||
data-flx="app.setup.setup-wizard-steps.welcome-step.animate-presence"
|
||||
>
|
||||
<motion.h2
|
||||
key={entry.code}
|
||||
className={wordClassName}
|
||||
initial={motionState.initial}
|
||||
animate={motionState.animate}
|
||||
exit={motionState.exit}
|
||||
transition={{duration: prefersReducedMotion ? 0.22 : 0.42, ease: [0.22, 1, 0.36, 1]}}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-word"
|
||||
>
|
||||
{entry.text}
|
||||
</motion.h2>
|
||||
</AnimatePresence>
|
||||
</flx-app-setup-welcome-roll>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
|
||||
import {SetupWelcomeGate} from '@app/features/app/components/setup/SetupWelcomeGate';
|
||||
import type {SetupBrandingAssetKind} from '@app/features/app/components/setup/SetupWizardClient';
|
||||
import {
|
||||
createRandomWelcomeRotationState,
|
||||
createWelcomeRotationState,
|
||||
WELCOME_ROTATION,
|
||||
} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerField';
|
||||
@@ -33,7 +30,7 @@ import {ImageIcon, TrashIcon, UploadSimpleIcon} from '@phosphor-icons/react';
|
||||
import {AnimatePresence, motion, type Transition, useReducedMotion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
|
||||
|
||||
@@ -46,15 +43,6 @@ export interface BrandingAssetState {
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
const WELCOME_AUTHED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Welcome to {productName}',
|
||||
comment: 'Setup wizard title shown once the operator is signed in as the instance admin.',
|
||||
});
|
||||
const WELCOME_AUTHED_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
'You are signed in as the instance administrator. The next steps configure branding, registration, and core policy for everyone on this instance.',
|
||||
comment: 'Setup wizard body shown to the signed-in instance administrator.',
|
||||
});
|
||||
const WELCOME_UNAUTHED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Create the administrator account',
|
||||
comment: 'Setup wizard title prompting the operator to create the first account.',
|
||||
@@ -511,7 +499,6 @@ const ASSET_DESCRIPTORS: Record<SetupBrandingAssetKind, MessageDescriptor> = {
|
||||
favicon: ASSET_FAVICON_DESCRIPTOR,
|
||||
};
|
||||
|
||||
const WELCOME_WORD_INLINE_PADDING_PX = 8;
|
||||
const SETUP_LANGUAGE_MENU_MAX_HEIGHT = 220;
|
||||
|
||||
interface StepHeaderProps {
|
||||
@@ -541,65 +528,7 @@ export const WelcomeStep = observer(
|
||||
initialFocusRef?: React.Ref<HTMLElement>;
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const currentLocale = LocaleUtils.getCurrentOrDetectedLocale();
|
||||
const welcomeFrameRef = useRef<HTMLDivElement | null>(null);
|
||||
const welcomeMeasureRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [welcomeScale, setWelcomeScale] = useState(1);
|
||||
const [welcomeRotation, setWelcomeRotation] = useState(() => createWelcomeRotationState(currentLocale));
|
||||
const measureWelcomeScale = useCallback(() => {
|
||||
const frame = welcomeFrameRef.current;
|
||||
const measure = welcomeMeasureRef.current;
|
||||
if (!frame || !measure) return;
|
||||
const availableWidth = Math.max(0, frame.clientWidth - WELCOME_WORD_INLINE_PADDING_PX);
|
||||
const measuredWidth = Math.max(measure.scrollWidth, measure.getBoundingClientRect().width);
|
||||
const nextScale = measuredWidth > 0 && availableWidth > 0 ? Math.min(1, availableWidth / measuredWidth) : 1;
|
||||
setWelcomeScale((currentScale) => (Math.abs(currentScale - nextScale) > 0.005 ? nextScale : currentScale));
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
measureWelcomeScale();
|
||||
const frame = welcomeFrameRef.current;
|
||||
if (!frame) return undefined;
|
||||
const ownerWindow = frame.ownerDocument.defaultView;
|
||||
const resizeObserver =
|
||||
typeof ownerWindow?.ResizeObserver === 'function' ? new ownerWindow.ResizeObserver(measureWelcomeScale) : null;
|
||||
resizeObserver?.observe(frame);
|
||||
ownerWindow?.addEventListener('resize', measureWelcomeScale);
|
||||
void frame.ownerDocument.fonts?.ready.then(measureWelcomeScale);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
ownerWindow?.removeEventListener('resize', measureWelcomeScale);
|
||||
};
|
||||
}, [measureWelcomeScale, welcomeRotation]);
|
||||
useEffect(() => {
|
||||
setWelcomeRotation(createWelcomeRotationState(currentLocale));
|
||||
}, [currentLocale]);
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setWelcomeRotation((rotation) => {
|
||||
if (rotation.position + 1 < rotation.order.length) {
|
||||
return {
|
||||
...rotation,
|
||||
position: rotation.position + 1,
|
||||
};
|
||||
}
|
||||
return createRandomWelcomeRotationState(rotation.order[rotation.position]);
|
||||
});
|
||||
}, 1800);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
const welcome = WELCOME_ROTATION[welcomeRotation.order[welcomeRotation.position] ?? 0];
|
||||
const motionState = prefersReducedMotion
|
||||
? {
|
||||
initial: {opacity: 0, scale: welcomeScale},
|
||||
animate: {opacity: 1, scale: welcomeScale},
|
||||
exit: {opacity: 0, scale: welcomeScale},
|
||||
}
|
||||
: {
|
||||
initial: {opacity: 0, y: 14, scale: welcomeScale * 0.98},
|
||||
animate: {opacity: 1, y: 0, scale: welcomeScale},
|
||||
exit: {opacity: 0, y: -14, scale: welcomeScale * 0.98},
|
||||
};
|
||||
return (
|
||||
<section
|
||||
ref={initialFocusRef}
|
||||
@@ -607,49 +536,12 @@ export const WelcomeStep = observer(
|
||||
tabIndex={-1}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-step"
|
||||
>
|
||||
<div className={styles.welcomeHero} data-flx="app.self-hosted-setup-wizard-gate.welcome-hero">
|
||||
<div
|
||||
ref={welcomeFrameRef}
|
||||
className={styles.welcomeWordFrame}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-word-frame"
|
||||
>
|
||||
<span
|
||||
ref={welcomeMeasureRef}
|
||||
className={styles.welcomeWordMeasure}
|
||||
aria-hidden="true"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-word-measure"
|
||||
>
|
||||
{welcome.text}
|
||||
</span>
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
initial={false}
|
||||
data-flx="app.setup.setup-wizard-steps.welcome-step.animate-presence"
|
||||
>
|
||||
<motion.h2
|
||||
key={welcome.code}
|
||||
className={styles.welcomeWord}
|
||||
initial={motionState.initial}
|
||||
animate={motionState.animate}
|
||||
exit={motionState.exit}
|
||||
transition={{duration: prefersReducedMotion ? 0.22 : 0.42, ease: [0.22, 1, 0.36, 1]}}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.welcome-word"
|
||||
>
|
||||
{welcome.text}
|
||||
</motion.h2>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<h3 className={styles.welcomeTitle} data-flx="app.self-hosted-setup-wizard-gate.welcome-title">
|
||||
{i18n._(WELCOME_AUTHED_TITLE_DESCRIPTOR, {productName})}
|
||||
</h3>
|
||||
<p className={styles.body} data-flx="app.self-hosted-setup-wizard-gate.welcome-body">
|
||||
{i18n._(WELCOME_AUTHED_BODY_DESCRIPTOR)}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<SetupWelcomeGate
|
||||
localeCode={currentLocale}
|
||||
productName={productName}
|
||||
isAuthenticated={isAuthenticated}
|
||||
data-flx="app.setup.setup-wizard-steps.welcome-step.setup-welcome-gate"
|
||||
/>
|
||||
<div className={styles.localeBlock} data-flx="app.self-hosted-setup-wizard-gate.locale-block">
|
||||
<div className={styles.fieldLabel} data-flx="app.self-hosted-setup-wizard-gate.locale-label">
|
||||
{i18n._(WELCOME_LANGUAGE_LABEL_DESCRIPTOR)}
|
||||
@@ -811,6 +703,7 @@ export const AdminAccountStep = observer(({theme, usernameSignIn}: {theme: Theme
|
||||
}}
|
||||
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
|
||||
redirectPath=""
|
||||
runtimeSnapshot={RuntimeConfig.getSnapshot()}
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
theme={theme}
|
||||
showLegalConsent={false}
|
||||
|
||||
@@ -128,6 +128,10 @@ function createWelcomeOrder(
|
||||
return [options.firstIndex, ...order];
|
||||
}
|
||||
|
||||
export function welcomeTextForLocale(localeCode: string): string {
|
||||
return WELCOME_ROTATION[findWelcomeLocaleIndex(localeCode)]?.text ?? WELCOME_ROTATION[0].text;
|
||||
}
|
||||
|
||||
export function createWelcomeRotationState(
|
||||
localeCode: string,
|
||||
random: RandomSource = Math.random,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createRandomWelcomeRotationState,
|
||||
createWelcomeRotationState,
|
||||
WELCOME_ROTATION,
|
||||
type WelcomeRotationEntry,
|
||||
type WelcomeRotationState,
|
||||
} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const WELCOME_STEP_MS = 1800;
|
||||
|
||||
function advanceWelcomeRotation(rotation: WelcomeRotationState): WelcomeRotationState {
|
||||
if (rotation.position + 1 < rotation.order.length) {
|
||||
return {...rotation, position: rotation.position + 1};
|
||||
}
|
||||
return createRandomWelcomeRotationState(rotation.order[rotation.position]);
|
||||
}
|
||||
|
||||
interface WelcomeRotationSchedule {
|
||||
readonly current: WelcomeRotationState;
|
||||
readonly upcoming: WelcomeRotationState;
|
||||
}
|
||||
|
||||
export interface SetupWelcomeRotation {
|
||||
readonly entry: WelcomeRotationEntry;
|
||||
readonly upcoming: WelcomeRotationEntry;
|
||||
}
|
||||
|
||||
function scheduleFrom(current: WelcomeRotationState): WelcomeRotationSchedule {
|
||||
return {current, upcoming: advanceWelcomeRotation(current)};
|
||||
}
|
||||
|
||||
function advanceWelcomeSchedule(schedule: WelcomeRotationSchedule): WelcomeRotationSchedule {
|
||||
return scheduleFrom(schedule.upcoming);
|
||||
}
|
||||
|
||||
function rotationEntry(rotation: WelcomeRotationState): WelcomeRotationEntry {
|
||||
return WELCOME_ROTATION[rotation.order[rotation.position] ?? 0] ?? WELCOME_ROTATION[0];
|
||||
}
|
||||
|
||||
export function useSetupWelcomeRotation(localeCode: string): SetupWelcomeRotation {
|
||||
const [schedule, setSchedule] = useState<WelcomeRotationSchedule>(() =>
|
||||
scheduleFrom(createWelcomeRotationState(localeCode)),
|
||||
);
|
||||
useEffect(() => {
|
||||
setSchedule(scheduleFrom(createWelcomeRotationState(localeCode)));
|
||||
}, [localeCode]);
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => setSchedule(advanceWelcomeSchedule), WELCOME_STEP_MS);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
return {entry: rotationEntry(schedule.current), upcoming: rotationEntry(schedule.upcoming)};
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import {useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
const SIDEBAR_WIDTH_VARIABLE = '--layout-sidebar-width';
|
||||
const VOICE_CONNECTION_HEIGHT_VARIABLE = '--layout-voice-connection-height';
|
||||
const SKELETON_SHIMMER_DELAY_VARIABLE = '--skeleton-shimmer-delay';
|
||||
const SKELETON_SHIMMER_DURATION_MS = 3200;
|
||||
const GUILD_ID_PATH_INDEX = 2;
|
||||
|
||||
function resolveSkeletonGuildId(pathname: string): string | null {
|
||||
@@ -127,6 +129,7 @@ export const AppSkeleton = observer(({isExiting = false, onTransitionEnd, effect
|
||||
}),
|
||||
);
|
||||
const voicePanelHeightPx = rememberedChrome.voice.panelHeightPx;
|
||||
const [shimmerDelayMs] = useState(() => -(performance.now() % SKELETON_SHIMMER_DURATION_MS));
|
||||
const canFitMemberList = useCanFitMemberList();
|
||||
const frozenChatPresentationRef = useRef<FrozenChatSkeletonPresentation | null>(null);
|
||||
let frozenChatPresentation = frozenChatPresentationRef.current;
|
||||
@@ -176,6 +179,7 @@ export const AppSkeleton = observer(({isExiting = false, onTransitionEnd, effect
|
||||
const showUserArea = !isMobile && !isVoiceCallFullscreenActive;
|
||||
const sidebarWidth = SidebarWidth.cssValue;
|
||||
const shellStyle: React.CSSProperties = {};
|
||||
Object.assign(shellStyle, {[SKELETON_SHIMMER_DELAY_VARIABLE]: `${shimmerDelayMs}ms`});
|
||||
if (!isMobile && sidebarWidth != null && sidebarWidth !== '') {
|
||||
Object.assign(shellStyle, {[SIDEBAR_WIDTH_VARIABLE]: sidebarWidth});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user