Compare commits

...
1281 changed files with 215176 additions and 81609 deletions
+2
View File
@@ -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 }}
+1
View File
@@ -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
+164
View File
@@ -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
+2
View File
@@ -44,6 +44,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
Generated
+33 -1
View File
@@ -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"
+1
View File
@@ -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
+36 -141
View File
@@ -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]
+11 -18
View File
@@ -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}");
}
}
+9 -2
View File
@@ -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,
+75 -2
View File
@@ -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
View File
@@ -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>
+6 -2
View File
@@ -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
View File
@@ -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}));
}
+22 -1
View File
@@ -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({
+86 -27
View File
@@ -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
+613
View File
@@ -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));
});
+6 -2
View File
@@ -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);
});
}
+16
View File
@@ -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;
})();
}
+87 -9
View File
@@ -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}),
),
};
}
+38 -27
View File
@@ -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" />
+109 -218
View File
@@ -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,
+1 -14
View File
@@ -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}
@@ -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"
/>
@@ -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));
@@ -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>
@@ -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
@@ -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}
@@ -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>
);
});
@@ -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}
@@ -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">
@@ -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
@@ -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()}
</>
);
@@ -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 (
@@ -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(() => {
@@ -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"
/>
)),
@@ -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),
@@ -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}
@@ -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={
@@ -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"
@@ -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,
})}
@@ -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