Compare commits

..
Author SHA1 Message Date
HampusandGitHub 7a4aa42d4b feat(premium): native Nordic prices and subscribing during grace (#3274) 2026-10-08 04:21:54 +02:00
HampusandGitHub 0ef1cd14c3 feat(desktop): show download progress and diagnostics on splash (#3273) 2026-10-08 03:06:06 +02:00
HampusandGitHub 795e190bda fix(desktop): let the Theme Studio popout resolve its runtime (#3272) 2026-10-08 03:05:37 +02:00
HampusandGitHub d00389ab30 fix(desktop): reach split-horizon instances on their LAN IP (#3271) 2026-10-08 03:05:15 +02:00
HampusandGitHub 3071bc7525 fix(desktop): honour the system proxy in main-process requests (#3270) 2026-10-08 03:04:55 +02:00
HampusandGitHub 6de6be2358 fix(threads): open the thread menu on thread chip right-click (#3269) 2026-10-08 03:04:35 +02:00
HampusandGitHub c51c222c47 fix(voice): stop stale mute echoes from toggling mute (#3268) 2026-10-08 03:04:10 +02:00
HampusandGitHub e218ba21de fix(app): load saved account avatars from their own instance (#3267) 2026-10-08 03:03:47 +02:00
HampusandGitHub de358c0def fix(app): let action toolbar overrides win over its defaults (#3266) 2026-10-08 03:03:26 +02:00
HampusandGitHub 4b88d64b2d fix(app): show the official badge and a visible account menu (#3265) 2026-10-08 03:03:05 +02:00
HampusandGitHub ee1c861eb2 perf(desktop): cache display media and retain shown images (#3264) 2026-10-08 03:02:41 +02:00
HampusandGitHub 6a24ac3f4e fix(desktop): fetch media bytes via the local resource proxy (#3263) 2026-10-08 03:02:15 +02:00
HampusandGitHub 8b312c610e fix(app): tag only desktop proxy uploads with a local upload id (#3262) 2026-10-08 00:40:51 +02:00
HampusandGitHub 2e3e3a1536 fix(desktop): fix manual update wording and retry Velopack (#3261) 2026-10-08 00:04:58 +02:00
HampusandGitHub 5c63d81ffd fix(app): show the setup wizard on new instances, not sign-in (#3260) 2026-10-07 23:28:04 +02:00
HampusandGitHub c402c52a8a fix(app): read instance config safely in startup skeletons (#3259) 2026-10-07 23:27:59 +02:00
HampusandGitHub 317fedeef9 fix(ci): resolve a bare drive WORKDIR to the drive root (#3258) 2026-10-07 21:44:18 +02:00
HampusandGitHub 002502a7fa feat(admin): one threads toggle, hide hosted-only rollouts (#3257) 2026-10-07 21:32:45 +02:00
HampusandGitHub 1997850d55 fix(threads): let administrators grant thread permissions (#3256) 2026-10-07 21:32:39 +02:00
HampusandGitHub d346eb0e88 fix(desktop): generate build channel in tests, bump yoke-derive (#3255) 2026-10-07 19:57:20 +02:00
HampusandGitHub 7c5fa2180a feat(desktop): bundled renderer, modules and instance accounts (#3254) 2026-10-07 16:48:07 +02:00
1352 changed files with 221619 additions and 83317 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}");
}
}
+4
View File
@@ -330,6 +330,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
monthlyInr: master.integrations.stripe.prices.monthly_inr,
monthlyIsk: master.integrations.stripe.prices.monthly_isk,
monthlyNok: master.integrations.stripe.prices.monthly_nok,
monthlyPln: master.integrations.stripe.prices.monthly_pln,
monthlySek: master.integrations.stripe.prices.monthly_sek,
@@ -339,6 +340,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
yearlyInr: master.integrations.stripe.prices.yearly_inr,
yearlyIsk: master.integrations.stripe.prices.yearly_isk,
yearlyNok: master.integrations.stripe.prices.yearly_nok,
yearlyPln: master.integrations.stripe.prices.yearly_pln,
yearlySek: master.integrations.stripe.prices.yearly_sek,
@@ -351,6 +353,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
gift1MonthIsk: master.integrations.stripe.prices.gift_1_month_isk,
gift1YearIsk: master.integrations.stripe.prices.gift_1_year_isk,
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
+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],
},
+4
View File
@@ -203,6 +203,7 @@ export interface APIConfig {
monthlyBrl?: string;
monthlyDkk?: string;
monthlyInr?: string;
monthlyIsk?: string;
monthlyNok?: string;
monthlyPln?: string;
monthlySek?: string;
@@ -212,6 +213,7 @@ export interface APIConfig {
yearlyBrl?: string;
yearlyDkk?: string;
yearlyInr?: string;
yearlyIsk?: string;
yearlyNok?: string;
yearlyPln?: string;
yearlySek?: string;
@@ -224,6 +226,8 @@ export interface APIConfig {
gift1YearDkk?: string;
gift1MonthNok?: string;
gift1YearNok?: string;
gift1MonthIsk?: string;
gift1YearIsk?: string;
gift1MonthBrl?: string;
gift1MonthInr?: string;
gift1MonthPln?: string;
@@ -6,6 +6,7 @@ import type {IDonationRepository} from '@app/api/donation/IDonationRepository';
import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDnsValidationService';
import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {shouldDisableAdaptivePricing} from '@app/api/utils/CurrencyUtils';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {DonationAmountInvalidError} from '@fluxer/errors/src/domains/donation/DonationAmountInvalidError';
@@ -173,6 +174,7 @@ export class DonationCheckoutService {
enabled: true,
},
...(isBusiness ? {billing_address_collection: 'required' as const} : {}),
...(shouldDisableAdaptivePricing(params.currency) ? {adaptive_pricing: {enabled: false}} : {}),
...(mode === 'payment'
? {
customer_creation: 'always' as const,
@@ -131,6 +131,8 @@ describe('POST /donations/checkout', () => {
}
| undefined;
expect(lineItem?.price_data?.currency).toBe(currency);
const nordic = ['sek', 'dkk', 'nok'].includes(currency);
expect(session?.adaptive_pricing).toEqual(nordic ? {enabled: 'false'} : undefined);
});
test('accepts monthly interval', async () => {
const response = await createDonationCheckoutBuilder(harness)
@@ -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,
@@ -10,6 +10,7 @@ import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers';
import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository';
import {shiftGiftExtensionEnd} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
@@ -77,9 +78,7 @@ function buildGrantPatch(user: User, row: StorePurchaseRow, now: Date): Partial<
user.premiumGraceEndsAt.getTime() > now.getTime();
const anchorMs = inStoreGrace ? oldUntil.getTime() : Math.max(now.getTime(), oldUntil?.getTime() ?? 0);
const giftEnd = user.premiumGiftExtensionEndsAt;
const shiftMs = expiresAt.getTime() - anchorMs;
const shiftedGiftEnd =
giftEnd && shiftMs > 0 && giftEnd.getTime() > anchorMs ? new Date(giftEnd.getTime() + shiftMs) : giftEnd;
const shiftedGiftEnd = shiftGiftExtensionEnd(giftEnd, anchorMs, expiresAt);
const startedAt = row.started_at ?? now;
const premiumSince = user.premiumSince && user.premiumSince <= startedAt ? user.premiumSince : startedAt;
setIfChanged(patch, 'premium_type', user.premiumType, UserPremiumTypes.SUBSCRIPTION);
@@ -74,6 +74,13 @@ const ENV_CATALOG: ReadonlyArray<{
gift_1_month: 'gift1MonthInr',
gift_1_year: 'gift1YearInr',
},
{
currency: 'ISK',
monthly: 'monthlyIsk',
yearly: 'yearlyIsk',
gift_1_month: 'gift1MonthIsk',
gift_1_year: 'gift1YearIsk',
},
{
currency: 'NOK',
monthly: 'monthlyNok',
@@ -64,6 +64,7 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
brl: 'BRL',
dkk: 'DKK',
inr: 'INR',
isk: 'ISK',
nok: 'NOK',
pln: 'PLN',
sek: 'SEK',
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type Stripe from 'stripe';
const GIFT_TRIAL_KEY_PREFIX = 'gtrial_';
const GIFT_TRIAL_PAID_UNTIL_KEY = `${GIFT_TRIAL_KEY_PREFIX}paid_until`;
export function getGiftTrialMetadataKey(giftCode: string): string {
return `${GIFT_TRIAL_KEY_PREFIX}${giftCode}`;
}
function isGiftTrialRunning(subscription: Stripe.Subscription, nowMs: number): boolean {
return subscription.trial_end != null && subscription.trial_end * 1000 > nowMs;
}
export function buildGiftTrialMetadata(
subscription: Stripe.Subscription,
giftCode: string,
baseUnix: number,
newTrialEndUnix: number,
nowMs: number,
): Record<string, string> {
const metadata: Record<string, string> = {};
if (!isGiftTrialRunning(subscription, nowMs)) {
for (const key of Object.keys(subscription.metadata ?? {})) {
if (key.startsWith(GIFT_TRIAL_KEY_PREFIX)) {
metadata[key] = '';
}
}
metadata[GIFT_TRIAL_PAID_UNTIL_KEY] = String(baseUnix);
}
metadata[getGiftTrialMetadataKey(giftCode)] = String(newTrialEndUnix - baseUnix);
return metadata;
}
export function getGiftTrialSeconds(subscription: Stripe.Subscription, giftCode: string): number | null {
const seconds = Number(subscription.metadata?.[getGiftTrialMetadataKey(giftCode)]);
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
export function getGiftTrialPaidUntil(subscription: Stripe.Subscription, nowMs: number): Date | null {
if (!isGiftTrialRunning(subscription, nowMs)) {
return null;
}
const paidUntilUnix = Number(subscription.metadata?.[GIFT_TRIAL_PAID_UNTIL_KEY]);
return Number.isFinite(paidUntilUnix) && paidUntilUnix > 0 ? new Date(paidUntilUnix * 1000) : null;
}
+1 -1
View File
@@ -192,7 +192,7 @@ export class StripeService {
durationType: GiftCodeDurationType,
durationQuantity: number,
idempotencyKey: string,
): Promise<void> {
): Promise<Stripe.Subscription | null> {
return this.subscriptionService.extendSubscriptionWithGiftTrialDuration(
user,
durationType,
@@ -63,6 +63,19 @@ function getSubscriptionCurrentPeriodEnd(subscription: Stripe.Subscription): Dat
return latestPeriodEnd == null ? null : new Date(latestPeriodEnd * 1000);
}
export function getSubscriptionCurrentPeriodStart(subscription: Stripe.Subscription): Date | null {
let latestPeriodStart: number | null = null;
for (const item of subscription.items?.data ?? []) {
if (item.current_period_start == null) {
continue;
}
if (latestPeriodStart == null || item.current_period_start > latestPeriodStart) {
latestPeriodStart = item.current_period_start;
}
}
return latestPeriodStart == null ? null : new Date(latestPeriodStart * 1000);
}
export function getSubscriptionPremiumPeriodEnd(subscription: Stripe.Subscription): Date | null {
if (subscription.cancel_at) {
return new Date(subscription.cancel_at * 1000);
@@ -79,6 +92,17 @@ export function getSubscriptionStartDate(subscription: Stripe.Subscription): Dat
return new Date((subscription.start_date ?? subscription.created) * 1000);
}
export function getInvoiceLatestLinePeriodStart(invoice: Stripe.Invoice): Date | null {
let latestPeriodStartUnix: number | null = null;
for (const line of invoice.lines?.data ?? []) {
const periodStart = line.period?.start ?? null;
if (periodStart && (latestPeriodStartUnix === null || periodStart > latestPeriodStartUnix)) {
latestPeriodStartUnix = periodStart;
}
}
return latestPeriodStartUnix ? new Date(latestPeriodStartUnix * 1000) : null;
}
export function getInvoiceLatestLinePeriodEnd(invoice: Stripe.Invoice): Date | null {
const lines = invoice.lines?.data ?? [];
let latestPeriodEndUnix: number | null = null;
@@ -19,16 +19,19 @@ import {
} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
import {
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {extractId} from '@app/api/stripe/StripeUtils';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {
type Currency,
getCurrencyPreferences,
getGiftCurrencyPreferences,
isLocalizedCurrency,
shouldDisableAdaptivePricing,
} from '@app/api/utils/CurrencyUtils';
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
@@ -232,6 +235,7 @@ export class StripeCheckoutService {
},
billing_address_collection: isBusiness ? 'required' : 'auto',
allow_promotion_codes: true,
...(shouldDisableAdaptivePricing(productInfo.currency) ? {adaptive_pricing: {enabled: false}} : {}),
...(checkoutMode === 'subscription'
? {
subscription_data: {
@@ -539,6 +543,15 @@ export class StripeCheckoutService {
}
if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) {
patch.premium_until = premiumUntil;
const giftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
premiumUntil,
new Date(),
getSubscriptionCurrentPeriodStart(subscription),
);
if (giftEnd !== user.premiumGiftExtensionEndsAt) {
patch.premium_gift_extension_ends_at = giftEnd;
}
}
if (user.premiumWillCancel !== premiumWillCancel) {
patch.premium_will_cancel = premiumWillCancel;
@@ -14,6 +14,7 @@ import type {ProductInfo, ProductRegistry} from '@app/api/stripe/ProductRegistry
import {
getFirstInvoicePaymentIntentId,
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionItemPeriodEnd,
getSubscriptionPremiumPeriodEnd,
} from '@app/api/stripe/StripeSubscriptionPeriod';
@@ -370,14 +371,16 @@ export class StripeCheckoutWebhookHandler {
return 'granted';
}
if (this.productRegistry.isRecurringSubscription(productInfo)) {
const periodEnd = await this.resolveCheckoutSubscriptionPeriodEnd(session, productInfo);
recovery.expectedPremiumUntil = periodEnd;
const period = await this.resolveCheckoutSubscriptionPeriod(session, productInfo);
recovery.expectedPremiumUntil = period.end;
await this.premiumService.setPremiumFromSubscriptionPeriod(
payment.userId,
productInfo.premiumType,
periodEnd,
period.end,
productInfo.billingCycle || null,
true,
null,
period.start,
);
return 'granted';
}
@@ -522,17 +525,17 @@ export class StripeCheckoutWebhookHandler {
}
}
private async resolveCheckoutSubscriptionPeriodEnd(
private async resolveCheckoutSubscriptionPeriod(
session: Stripe.Checkout.Session,
productInfo: ProductInfo,
): Promise<Date> {
): Promise<{start: Date | null; end: Date}> {
const subscriptionId = extractId(session.subscription);
if (subscriptionId && this.stripe) {
try {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
const candidate = getSubscriptionPremiumPeriodEnd(subscription);
if (candidate) {
return candidate;
return {start: getSubscriptionCurrentPeriodStart(subscription), end: candidate};
}
} catch (error) {
Logger.warn(
@@ -546,7 +549,7 @@ export class StripeCheckoutWebhookHandler {
{sessionId: session.id, subscriptionId, durationMonths: productInfo.durationMonths},
'Using product duration fallback for subscription period_end on checkout fulfilment',
);
return fallback;
return {start: null, end: fallback};
}
private didCheckoutPremiumGrantApply(
@@ -5,19 +5,37 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {addGiftCodeDuration, type GiftCode} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {
getGiftTrialMetadataKey,
getGiftTrialPaidUntil,
getGiftTrialSeconds,
} from '@app/api/stripe/StripeGiftTrialMetadata';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import Stripe from 'stripe';
interface RemainingGiftEntitlement {
hasLifetimeGift: boolean;
giftExtensionEndsAt: Date | null;
}
interface GiftTrialCut {
subscriptionId: string;
trialEnd: Date | null;
cancel: boolean;
premiumUntil: Date;
}
const MIN_REMAINING_TRIAL_MS = 60_000;
export class StripeGiftReversalHandler {
constructor(
private userRepository: IUserRepository,
@@ -48,20 +66,26 @@ export class StripeGiftReversalHandler {
let newGiftEnd: Date | null;
let needsAdjustment: boolean;
let reapplyIfMarked = true;
let trialCut: GiftTrialCut | null = null;
if (await this.storeEntitlementService?.getActiveStoreEntitlement(redeemer.id)) {
const reduced = this.reduceStackedGiftExtension(redeemer, giftCode, redeemedGifts, new Date());
newGiftEnd = reduced.giftExtensionEndsAt;
needsAdjustment = reduced.changed;
reapplyIfMarked = false;
} else {
newGiftEnd = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code).giftExtensionEndsAt;
newGiftEnd = this.removeGiftKeepingPaidShift(currentGiftEnd, giftCode, redeemedGifts);
needsAdjustment =
(newGiftEnd?.getTime() ?? 0) !== (currentGiftEnd?.getTime() ?? 0) &&
(currentGiftEnd == null || newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime());
currentGiftEnd != null && (newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime());
trialCut = await this.planGiftTrialCut(redeemer, giftCode, new Date());
}
if (
needsAdjustment &&
(await this.commitReversal(redeemer, giftCode, {premium_gift_extension_ends_at: newGiftEnd}, {reapplyIfMarked}))
(needsAdjustment || trialCut) &&
(await this.commitReversal(
redeemer,
giftCode,
needsAdjustment ? {premium_gift_extension_ends_at: newGiftEnd} : {},
{reapplyIfMarked, trialCut},
))
) {
Logger.info(
{
@@ -69,8 +93,10 @@ export class StripeGiftReversalHandler {
redeemerId: redeemer.id,
chargeId: context.chargeId,
reason: context.reason,
adjustedGiftEnd: newGiftEnd?.toISOString() ?? null,
adjustedGiftEnd: needsAdjustment ? (newGiftEnd?.toISOString() ?? null) : undefined,
previousGiftEnd: currentGiftEnd?.toISOString() ?? null,
shortenedTrialEnd: trialCut ? (trialCut.trialEnd?.toISOString() ?? 'now') : undefined,
cancelledSubscription: trialCut?.cancel,
},
'Reduced gift extension after gift reversal for user with Stripe identity',
);
@@ -223,6 +249,20 @@ export class StripeGiftReversalHandler {
return reduced.giftExtensionEndsAt;
}
private removeGiftKeepingPaidShift(
currentGiftEnd: Date | null,
giftCode: GiftCode,
redeemedGifts: Array<GiftCode>,
): Date | null {
const remainingEnd = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code).giftExtensionEndsAt;
if (!currentGiftEnd || !remainingEnd) {
return null;
}
const rebuiltEnd = this.computeRemainingGiftEntitlement(redeemedGifts, null).giftExtensionEndsAt;
const paidShiftMs = rebuiltEnd ? Math.max(0, currentGiftEnd.getTime() - rebuiltEnd.getTime()) : 0;
return new Date(remainingEnd.getTime() + paidShiftMs);
}
private reduceStackedGiftExtension(
redeemer: User,
giftCode: GiftCode,
@@ -287,15 +327,91 @@ export class StripeGiftReversalHandler {
await this.dispatchUser(updatedUser);
}
private async planGiftTrialCut(redeemer: User, giftCode: GiftCode, now: Date): Promise<GiftTrialCut | null> {
const stripe = getStripeClient();
if (!stripe || !redeemer.stripeSubscriptionId) {
return null;
}
let subscription: Stripe.Subscription;
try {
subscription = await stripe.subscriptions.retrieve(redeemer.stripeSubscriptionId);
} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
return null;
}
throw error;
}
if (subscription.status === 'canceled' || subscription.status === 'incomplete_expired') {
return null;
}
if (!subscription.trial_end || subscription.trial_end * 1000 <= now.getTime()) {
return null;
}
const giftSeconds = getGiftTrialSeconds(subscription, giftCode.code);
if (giftSeconds === null) {
return null;
}
const cutEndMs = Math.max(
subscription.trial_end * 1000 - giftSeconds * 1000,
getGiftTrialPaidUntil(subscription, now.getTime())?.getTime() ?? 0,
);
const endsNow = cutEndMs <= now.getTime() + MIN_REMAINING_TRIAL_MS;
const premiumUntilMs = Math.min(
endsNow ? now.getTime() : cutEndMs,
redeemer.premiumUntil?.getTime() ?? Number.POSITIVE_INFINITY,
);
return {
subscriptionId: subscription.id,
trialEnd: endsNow ? null : new Date(cutEndMs),
cancel: endsNow && (subscription.cancel_at_period_end || subscription.cancel_at != null),
premiumUntil: new Date(premiumUntilMs),
};
}
private async applyGiftTrialCut(redeemer: User, giftCode: GiftCode, cut: GiftTrialCut): Promise<void> {
const stripe = getStripeClient();
if (!stripe) {
throw new StripeError('Stripe client not available for gift trial reversal');
}
const idempotencyKey = `gift_trial_reverse:${redeemer.id}:${giftCode.code}`;
const subscription = cut.cancel
? await stripe.subscriptions.cancel(
cut.subscriptionId,
{invoice_now: false, prorate: false, cancellation_details: {comment: 'gift_reversal'}},
{idempotencyKey},
)
: await stripe.subscriptions.update(
cut.subscriptionId,
{
trial_end: cut.trialEnd ? Math.floor(cut.trialEnd.getTime() / 1000) : 'now',
proration_behavior: 'none',
metadata: {[getGiftTrialMetadataKey(giftCode.code)]: ''},
},
{idempotencyKey},
);
try {
await getBillingRepository().subscriptions.upsertFromStripe(subscription, {
knownUserId: redeemer.id,
snapshotCapturedAt: new Date(),
});
} catch (mirrorErr) {
Logger.error(
{mirrorErr, subId: subscription.id},
'Mirror upsert failed after Stripe write; reconciler will heal',
);
}
}
private async commitReversal(
redeemer: User,
giftCode: GiftCode,
patch: Partial<UserRow>,
{reapplyIfMarked}: {reapplyIfMarked: boolean},
{reapplyIfMarked, trialCut = null}: {reapplyIfMarked: boolean; trialCut?: GiftTrialCut | null},
): Promise<boolean> {
const seconds = this.computeRemovedSeconds(redeemer, patch, Date.now());
const fullPatch: Partial<UserRow> = trialCut ? {...patch, premium_until: trialCut.premiumUntil} : patch;
const seconds = this.computeRemovedSeconds(redeemer, fullPatch, Date.now());
if (!(await this.userRepository.markGiftPremiumReversed(giftCode, seconds))) {
if (!reapplyIfMarked) {
if (!reapplyIfMarked || Object.keys(patch).length === 0) {
Logger.info(
{giftCode: giftCode.code, redeemerId: redeemer.id},
'Skipped a gift premium reversal that was already applied',
@@ -305,17 +421,20 @@ export class StripeGiftReversalHandler {
await this.dispatchUser(await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow()));
return true;
}
if (trialCut) {
try {
await this.applyGiftTrialCut(redeemer, giftCode, trialCut);
} catch (error) {
await this.releaseReversalClaim(redeemer, giftCode, seconds);
throw error;
}
}
let updatedUser: User;
try {
updatedUser = await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow());
updatedUser = await this.userRepository.patchUpsert(redeemer.id, fullPatch, redeemer.toRow());
} catch (error) {
try {
await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds);
} catch (clearError) {
Logger.error(
{giftCode: giftCode.code, redeemerId: redeemer.id, clearError},
'Failed to release gift reversal marker',
);
if (!trialCut) {
await this.releaseReversalClaim(redeemer, giftCode, seconds);
}
throw error;
}
@@ -323,6 +442,17 @@ export class StripeGiftReversalHandler {
return true;
}
private async releaseReversalClaim(redeemer: User, giftCode: GiftCode, seconds: number): Promise<void> {
try {
await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds);
} catch (clearError) {
Logger.error(
{giftCode: giftCode.code, redeemerId: redeemer.id, clearError},
'Failed to release gift reversal marker',
);
}
}
private computeRemovedSeconds(redeemer: User, patch: Partial<UserRow>, nowMs: number): number {
const nextUntil = 'premium_until' in patch ? patch.premium_until : redeemer.premiumUntil;
const nextGiftEnd =
@@ -337,7 +467,10 @@ export class StripeGiftReversalHandler {
return Math.max(0, Math.ceil((beforeMs - afterMs) / 1000));
}
computeRemainingGiftEntitlement(redeemedGifts: Array<GiftCode>, excludedCode: string): RemainingGiftEntitlement {
computeRemainingGiftEntitlement(
redeemedGifts: Array<GiftCode>,
excludedCode: string | null,
): RemainingGiftEntitlement {
const sortedGifts = redeemedGifts
.filter(
(giftCode) =>
@@ -140,23 +140,19 @@ export class StripeGiftService {
);
await this.cancelStripeSubscriptionImmediately(user);
}
let stackedOntoStripeSubscription = false;
if (premiumType === UserPremiumTypes.SUBSCRIPTION) {
stackedOntoStripeSubscription = await this.tryStackSubscriptionGiftOntoStripeSubscription(
user,
giftCode,
code,
);
}
const stackedSubscription =
premiumType === UserPremiumTypes.SUBSCRIPTION
? await this.tryStackSubscriptionGiftOntoStripeSubscription(user, giftCode, code)
: null;
Logger.debug(
{
userId,
giftCode: code,
stackedOntoStripeSubscription,
stackedOntoStripeSubscription: stackedSubscription !== null,
},
'Gift redemption stacking decision completed',
);
if (stackedOntoStripeSubscription) {
if (stackedSubscription) {
Logger.debug(
{
userId,
@@ -166,13 +162,9 @@ export class StripeGiftService {
},
'Recording gift extension in gift bucket alongside Stripe trial stacking',
);
await this.premiumService.extendPremiumByGift(
userId,
premiumType,
giftCode.durationType,
giftCode.durationQuantity,
true,
);
if (stackedSubscription.subscription) {
await this.premiumService.recordGiftTrialExtension(userId, stackedSubscription.subscription);
}
} else if (premiumType === UserPremiumTypes.LIFETIME && giftCode.visionarySequenceNumber != null) {
const GIFT_CODE_SENTINEL_USER_ID = createUserID(-1n);
Logger.debug(
@@ -368,7 +360,7 @@ export class StripeGiftService {
user: User,
giftCode: GiftCode,
code: string,
): Promise<boolean> {
): Promise<{subscription: Stripe.Subscription | null} | null> {
const hasCurrentSubscriptionState = this.hasCurrentSubscriptionState(user);
if (!hasCurrentSubscriptionState || !user.stripeSubscriptionId || !this.stripe) {
Logger.debug(
@@ -381,7 +373,7 @@ export class StripeGiftService {
},
'Skipping Stripe subscription stacking for gift redemption',
);
return false;
return null;
}
Logger.debug(
{
@@ -394,14 +386,14 @@ export class StripeGiftService {
'Attempting to stack gift duration onto active Stripe subscription',
);
try {
await this.subscriptionService.extendSubscriptionWithGiftTrialDuration(
const subscription = await this.subscriptionService.extendSubscriptionWithGiftTrialDuration(
user,
giftCode.durationType,
giftCode.durationQuantity,
code,
);
Logger.debug({userId: user.id, giftCode: code}, 'Stacked gift duration onto Stripe subscription');
return true;
return {subscription};
} catch (error: unknown) {
if (!this.shouldFallbackToPremiumFieldGrant(error)) {
Logger.error(
@@ -431,7 +423,7 @@ export class StripeGiftService {
},
'Falling back to premium-field gift grant after missing or inactive Stripe subscription',
);
return false;
return null;
}
}
@@ -13,12 +13,18 @@ import {Logger} from '@app/api/Logger';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {addGiftCodeDuration} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import {
getSubscriptionCurrentPeriodStart,
getSubscriptionPremiumPeriodEnd,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import type Stripe from 'stripe';
export class StripePremiumService {
constructor(
@@ -35,6 +41,7 @@ export class StripePremiumService {
billingCycle: string | null = null,
hasEverPurchased: boolean = false,
premiumSinceAnchor: Date | null = null,
periodStart: Date | null = null,
): Promise<void> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -42,14 +49,15 @@ export class StripePremiumService {
}
const now = new Date();
let visionarySequence: number | null = user.premiumLifetimeSequence;
if (premiumType === UserPremiumTypes.LIFETIME && !visionarySequence) {
if (premiumType === UserPremiumTypes.LIFETIME && visionarySequence == null) {
visionarySequence = await this.allocateVisionarySequence(userId);
}
const shiftMs = user.premiumUntil ? periodEnd.getTime() - user.premiumUntil.getTime() : 0;
const adjustedGiftEnd =
user.premiumGiftExtensionEndsAt && shiftMs > 0
? new Date(user.premiumGiftExtensionEndsAt.getTime() + shiftMs)
: user.premiumGiftExtensionEndsAt;
const adjustedGiftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
periodEnd,
now,
periodStart,
);
const updatedUser = await this.userRepository.patchUpsert(
userId,
{
@@ -68,7 +76,7 @@ export class StripePremiumService {
);
await this.dispatchUser(updatedUser);
Logger.debug(
{userId, premiumType, periodEnd, shiftMs, adjustedGiftEnd, billingCycle},
{userId, premiumType, periodEnd, adjustedGiftEnd, billingCycle},
'Premium set from subscription period',
);
}
@@ -140,6 +148,39 @@ export class StripePremiumService {
Logger.debug({userId, premiumType, durationType, durationQuantity, newGiftEnd}, 'Premium extended by gift');
}
async recordGiftTrialExtension(userId: UserID, subscription: Stripe.Subscription): Promise<void> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new StripeError('User not found for premium grant');
}
const now = new Date();
const periodEnd = getSubscriptionPremiumPeriodEnd(subscription);
const premiumUntil =
periodEnd && (!user.premiumUntil || periodEnd > user.premiumUntil) ? periodEnd : user.premiumUntil;
const shiftedGiftEnd = premiumUntil
? shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
premiumUntil,
now,
getSubscriptionCurrentPeriodStart(subscription),
)
: user.premiumGiftExtensionEndsAt;
const trialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : null;
const giftEnd = trialEnd && (!shiftedGiftEnd || trialEnd > shiftedGiftEnd) ? trialEnd : shiftedGiftEnd;
const updatedUser = await this.userRepository.patchUpsert(
userId,
{
premium_until: premiumUntil,
premium_gift_extension_ends_at: giftEnd,
premium_grace_ends_at: null,
has_ever_purchased: true,
},
user.toRow(),
);
await this.dispatchUser(updatedUser);
Logger.debug({userId, premiumUntil, giftEnd}, 'Premium extended by gift stacked onto Stripe trial');
}
async grantPremiumFromGiftWithDuration(
userId: UserID,
premiumType: 1 | 2,
@@ -10,6 +10,7 @@ import {addGiftCodeDuration} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {ProductInfo, RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
import {buildGiftTrialMetadata, getGiftTrialPaidUntil} from '@app/api/stripe/StripeGiftTrialMetadata';
import {
getPrimarySubscriptionItem,
getSubscriptionEntitlementPeriodEndUnix,
@@ -233,12 +234,15 @@ export class StripeSubscriptionService {
premium_grace_ends_at: null,
};
if (user.premiumType !== UserPremiumTypes.LIFETIME) {
const hasActiveGift =
user.premiumGiftExtensionEndsAt != null && user.premiumGiftExtensionEndsAt.getTime() > Date.now();
const now = new Date();
const paidUntil = getGiftTrialPaidUntil(canceledSubscription, now.getTime()) ?? user.premiumUntil;
const giftEnd = this.pullGiftExtensionBackToNow(user.premiumGiftExtensionEndsAt, paidUntil, now);
const hasActiveGift = giftEnd != null && giftEnd.getTime() > now.getTime();
Object.assign(patch, {
premium_type: hasActiveGift ? user.premiumType : UserPremiumTypes.NONE,
premium_since: hasActiveGift ? user.premiumSince : null,
premium_until: new Date(),
premium_until: now,
premium_gift_extension_ends_at: giftEnd,
});
}
const updatedUser = await this.userRepository.patchUpsert(userId, patch, user.toRow());
@@ -255,6 +259,13 @@ export class StripeSubscriptionService {
await this.storeEntitlementService?.reapplyAfterStripeChange(userId);
}
private pullGiftExtensionBackToNow(giftEnd: Date | null, paidUntil: Date | null, now: Date): Date | null {
if (!giftEnd || !paidUntil || paidUntil.getTime() <= now.getTime() || giftEnd.getTime() < paidUntil.getTime()) {
return giftEnd;
}
return new Date(giftEnd.getTime() - (paidUntil.getTime() - now.getTime()));
}
async reactivateSubscription(userId: UserID): Promise<void> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
@@ -1215,7 +1226,7 @@ export class StripeSubscriptionService {
durationType: GiftCodeDurationType,
durationQuantity: number,
idempotencyKey: string,
): Promise<void> {
): Promise<Stripe.Subscription | null> {
if (!this.stripe || !user.stripeSubscriptionId) {
Logger.debug(
{
@@ -1246,11 +1257,11 @@ export class StripeSubscriptionService {
);
if (await this.cacheService.get<boolean>(appliedKey)) {
Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension already applied (idempotent hit)');
return;
return null;
}
if (await this.cacheService.get<boolean>(inflightKey)) {
Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension in-flight; skipping duplicate');
return;
return null;
}
await this.cacheService.set(inflightKey, true, seconds('1 minute'));
Logger.debug({userId: user.id, idempotencyKey, inflightKey}, 'Gift trial inflight sentinel set');
@@ -1268,7 +1279,7 @@ export class StripeSubscriptionService {
try {
if (await this.cacheService.get<boolean>(appliedKey)) {
Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension already applied after lock acquisition');
return;
return null;
}
const subscription = await this.stripe.subscriptions.retrieve(user.stripeSubscriptionId);
Logger.debug(
@@ -1320,6 +1331,7 @@ export class StripeSubscriptionService {
{
trial_end: newTrialEndUnix,
proration_behavior: 'none',
metadata: buildGiftTrialMetadata(subscription, idempotencyKey, baseUnix, newTrialEndUnix, Date.now()),
},
{idempotencyKey: stripeIdempotencyKey},
);
@@ -1357,6 +1369,7 @@ export class StripeSubscriptionService {
},
'Extended subscription with gift trial period',
);
return trialExtendedSubscription;
} catch (error: unknown) {
Logger.error(
{error, userId: user.id, subscriptionId: user.stripeSubscriptionId, idempotencyKey},
@@ -20,7 +20,9 @@ import {
} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
import {
getInvoiceLatestLinePeriodEnd,
getInvoiceLatestLinePeriodStart,
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionItemPeriodEnd,
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
@@ -28,6 +30,7 @@ import {
import {extractId} from '@app/api/stripe/StripeUtils';
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import type {StripeSubscriptionReconciler} from '@app/api/stripe/services/StripeSubscriptionReconciler';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
import {getPremiumPaymentRecoveryGraceMs} from '@app/api/user/UserHelpers';
@@ -151,6 +154,8 @@ export class StripeSubscriptionWebhookHandler {
renewalContext.productInfo.billingCycle || null,
true,
premiumSinceAnchor,
(subscriptionSnapshot ? getSubscriptionCurrentPeriodStart(subscriptionSnapshot) : null) ??
getInvoiceLatestLinePeriodStart(invoice),
);
} catch (error) {
const latestUser = await this.userRepository.findUnique(renewalContext.userId);
@@ -223,6 +228,15 @@ export class StripeSubscriptionWebhookHandler {
}
if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) {
patch.premium_until = premiumUntil;
const giftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
premiumUntil,
new Date(),
getSubscriptionCurrentPeriodStart(subscription),
);
if (giftEnd !== user.premiumGiftExtensionEndsAt) {
patch.premium_gift_extension_ends_at = giftEnd;
}
}
if (user.premiumWillCancel !== willCancel) {
patch.premium_will_cancel = willCancel;
@@ -556,6 +570,7 @@ export class StripeSubscriptionWebhookHandler {
const result = await this.userRepository.updateSubscriptionStatus(targetUser.id, {
premiumWillCancel: willCancel,
computedPremiumUntil,
periodStart: getSubscriptionCurrentPeriodStart(canonicalSubscription),
});
if (result.finalVersion === null) {
Logger.error(
@@ -38,6 +38,10 @@ const ENV_PRICES = {
yearlyInr: 'price_yearly_inr',
gift1MonthInr: 'price_gift_month_inr',
gift1YearInr: 'price_gift_year_inr',
monthlyIsk: 'price_monthly_isk',
yearlyIsk: 'price_yearly_isk',
gift1MonthIsk: 'price_gift_month_isk',
gift1YearIsk: 'price_gift_year_isk',
monthlyNok: 'price_monthly_nok',
yearlyNok: 'price_yearly_nok',
gift1MonthNok: 'price_gift_month_nok',
@@ -58,7 +62,7 @@ const ENV_PRICES = {
function expectedEnvCatalog(): Record<string, Record<string, string>> {
const catalog: Record<string, Record<string, string>> = {};
for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']) {
for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY']) {
const suffix = currency.toLowerCase();
catalog[currency] = {
monthly: `price_monthly_${suffix}`,
@@ -37,7 +37,7 @@ import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'] as const;
const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY'] as const;
function buildFullEnvPrices(): NonNullable<typeof Config.stripe.prices> {
const prices: Record<string, string> = {};
@@ -479,6 +479,18 @@ describe('operator billing catalog', () => {
.execute();
});
test('leaves adaptive pricing unset for an operator SEK checkout', async () => {
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opsekmonthly', country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
const session = stripeHandlers.spies.createdCheckoutSessions[0];
expect(session?.line_items?.[0]?.price).toBe('price_opsekmonthly');
expect(session?.adaptive_pricing).toBeUndefined();
});
test('accepts an operator price that does not match the buyer country', async () => {
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
@@ -121,6 +121,7 @@ describe('ProductRegistry - legacy prices', () => {
monthly_brl: ['legacy_brl'],
monthly_dkk: ['legacy_dkk'],
monthly_inr: ['legacy_inr'],
monthly_isk: ['legacy_isk'],
monthly_nok: ['legacy_nok'],
monthly_pln: ['legacy_pln'],
monthly_sek: ['legacy_sek'],
@@ -132,6 +133,7 @@ describe('ProductRegistry - legacy prices', () => {
expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL');
expect(registry.getProduct('legacy_dkk')?.currency).toBe('DKK');
expect(registry.getProduct('legacy_inr')?.currency).toBe('INR');
expect(registry.getProduct('legacy_isk')?.currency).toBe('ISK');
expect(registry.getProduct('legacy_nok')?.currency).toBe('NOK');
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
expect(registry.getProduct('legacy_sek')?.currency).toBe('SEK');
@@ -211,7 +213,7 @@ describe('ProductRegistry - legacy prices', () => {
test('no legacy price id is ever returned by either price getter, for any cycle or currency', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
const legacyIds = new Set(Object.values(MOCK_LEGACY_PRICES).flatMap((ids) => ids ?? []));
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'];
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY'];
const offered: Array<string> = [];
for (const currency of currencies) {
@@ -0,0 +1,753 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@app/api/guild/services/GuildService';
import {getCacheService, getPremiumStateReconciliationQueueService} from '@app/api/middleware/ServiceSingletons';
import {findUser} from '@app/api/store_billing/tests/StoreBillingTestUtils';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import {StripeSubscriptionService} from '@app/api/stripe/services/StripeSubscriptionService';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers';
import {server} from '@app/api/test/msw/server';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
import {ms} from 'itty-time';
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
const {lookupGeoipMock} = vi.hoisted(() => ({
lookupGeoipMock: vi.fn(),
}));
vi.mock('@app/api/utils/IpUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('@app/api/utils/IpUtils')>()),
lookupGeoip: lookupGeoipMock,
}));
const MOCK_PRICES = {
monthlyUsd: 'price_shift_monthly_usd',
yearlyUsd: 'price_shift_yearly_usd',
gift1MonthUsd: 'price_shift_gift_1_month_usd',
gift1YearUsd: 'price_shift_gift_1_year_usd',
};
const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
};
const CLOCK_TOLERANCE_MS = ms('10 seconds');
type WriteOrder = 'checkout first' | 'webhook first';
const WRITE_ORDERS: Array<WriteOrder> = ['checkout first', 'webhook first'];
function geoipCountry(countryCode: string | null): GeoipResult {
return {countryCode, normalizedIp: '203.0.113.10', city: null, region: null, countryName: null};
}
function giftTrialMetadata(code: string, days: number, paidUntilUnix?: number): Record<string, string> {
return {
[`gtrial_${code}`]: String(days * 24 * 60 * 60),
...(paidUntilUnix ? {gtrial_paid_until: String(paidUntilUnix)} : {}),
};
}
function expectNear(actual: Date | null | undefined, expectedMs: number): void {
expect(actual).not.toBeNull();
expect(Math.abs(actual!.getTime() - expectedMs)).toBeLessThanOrEqual(CLOCK_TOLERANCE_MS);
}
describe('gift time around a Stripe subscription', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
const users = new UserRepository();
const premiumService = new StripePremiumService(
users,
new NoopGatewayService(),
{} as IGuildRepositoryAggregate,
{} as GuildService,
);
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(async () => {
await harness.resetData();
Config.stripe.prices = MOCK_PRICES;
lookupGeoipMock.mockReset();
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
useStripe();
});
afterEach(() => {
server.resetHandlers();
});
function useStripe(config: Parameters<typeof createStripeApiHandlers>[0] = {}): void {
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, ...config});
server.use(...stripeHandlers.handlers);
}
async function createUser(patch: Partial<UserRow>): Promise<{account: TestAccount; userId: UserID}> {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const user = await findUser(account.userId);
await users.patchUpsert(userId, patch, user.toRow());
return {account, userId};
}
async function checkoutWrite(userId: UserID, periodEnd: Date, periodStart: Date | null = null): Promise<void> {
await premiumService.setPremiumFromSubscriptionPeriod(
userId,
UserPremiumTypes.SUBSCRIPTION,
periodEnd,
'monthly',
true,
null,
periodStart,
);
}
async function webhookWrite(userId: UserID, periodEnd: Date, periodStart: Date | null = null): Promise<void> {
await users.updateSubscriptionStatus(userId, {
premiumWillCancel: false,
computedPremiumUntil: periodEnd,
periodStart,
});
}
async function writeBoth(
order: WriteOrder,
userId: UserID,
periodEnd: Date,
periodStart: Date | null = null,
): Promise<void> {
if (order === 'checkout first') {
await checkoutWrite(userId, periodEnd, periodStart);
await webhookWrite(userId, periodEnd, periodStart);
return;
}
await webhookWrite(userId, periodEnd, periodStart);
await checkoutWrite(userId, periodEnd, periodStart);
}
function newGiftCode(): string {
return `giftshift${randomUUID().replaceAll('-', '').slice(0, 20)}`;
}
async function createRedeemedGift(
redeemerId: UserID,
redeemedAt: Date,
duration: {type: 'days' | 'months'; quantity: number},
code: string = newGiftCode(),
): Promise<string> {
const gifter = await createTestAccount(harness);
await users.createGiftCode({
code,
duration_months: null,
duration_type: duration.type,
duration_quantity: duration.quantity,
created_at: redeemedAt,
created_by_user_id: createUserID(BigInt(gifter.userId)),
redeemed_at: redeemedAt,
redeemed_by_user_id: redeemerId,
stripe_payment_intent_id: `pi_${code}`,
visionary_sequence_number: null,
checkout_session_id: null,
version: 1,
});
return code;
}
async function reverseGift(code: string): Promise<void> {
const handler = new StripeGiftReversalHandler(
users,
new NoopGatewayService(),
getPremiumStateReconciliationQueueService(),
);
const gift = await users.findGiftCode(code);
await handler.handleGiftPremiumReversal(gift!, {reason: 'gift_chargeback'});
}
describe('subscribing while gift time is left', () => {
test.each(WRITE_ORDERS)(
'moves an active gift past the paid period when premium_until is empty (%s)',
async (order) => {
const giftEnd = new Date(Date.now() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: null,
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await writeBoth(order, userId, periodEnd);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expectNear(after.premiumGiftExtensionEndsAt, periodEnd.getTime() + ms('10 days'));
},
);
test.each(WRITE_ORDERS)('anchors at now when premium_until is a stale past date (%s)', async (order) => {
const giftEnd = new Date(Date.now() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: new Date(Date.now() - ms('60 days')),
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await writeBoth(order, userId, periodEnd);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expectNear(after.premiumGiftExtensionEndsAt, periodEnd.getTime() + ms('10 days'));
});
test.each(WRITE_ORDERS)('leaves an expired gift alone when premium_until is stale (%s)', async (order) => {
const giftEnd = new Date(Date.now() - ms('5 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: new Date(Date.now() - ms('60 days')),
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await writeBoth(order, userId, periodEnd);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime());
});
test('shifts the gift once when checkout.session.completed is replayed', async () => {
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')),
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await checkoutWrite(userId, periodEnd);
const first = await findUser(account.userId);
await checkoutWrite(userId, periodEnd);
const replayed = await findUser(account.userId);
expect(replayed.premiumGiftExtensionEndsAt?.getTime()).toBe(first.premiumGiftExtensionEndsAt?.getTime());
});
test.each(WRITE_ORDERS)('moves a stacked gift along on renewal (%s)', async (order) => {
const premiumUntil = new Date(Date.now() + ms('1 hour'));
const giftEnd = new Date(premiumUntil.getTime() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: premiumUntil,
premium_gift_extension_ends_at: giftEnd,
});
const renewedUntil = new Date(premiumUntil.getTime() + ms('30 days'));
await writeBoth(order, userId, renewedUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(renewedUntil.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days'));
});
test.each(WRITE_ORDERS)('keeps every gift day when a renewal is paid late (%s)', async (order) => {
const premiumUntil = new Date(Date.now() - ms('6 days'));
const giftEnd = new Date(premiumUntil.getTime() + ms('60 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: premiumUntil,
premium_gift_extension_ends_at: giftEnd,
premium_grace_ends_at: new Date(premiumUntil.getTime() + ms('14 days')),
});
const renewedUntil = new Date(premiumUntil.getTime() + ms('30 days'));
await writeBoth(order, userId, renewedUntil, premiumUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(renewedUntil.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days'));
});
test.each(WRITE_ORDERS)(
'moves the gift by the whole period when the first payment lands late (%s)',
async (order) => {
const periodStart = new Date(Date.now() - ms('2 days'));
const giftEnd = new Date(Date.now() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: null,
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(periodStart.getTime() + ms('30 days'));
await writeBoth(order, userId, periodEnd, periodStart);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days'));
},
);
test('lets a user with gift time left start a subscription checkout', async () => {
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: new Date(Date.now() - ms('60 days')),
premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')),
});
useStripe({subscriptionsListEmpty: true});
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
await createBuilder(harness, account.token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('still refuses a recurring checkout for a Visionary', async () => {
const {account} = await createUser({premium_type: UserPremiumTypes.LIFETIME, premium_lifetime_sequence: 7});
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
await createBuilder(harness, account.token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED)
.execute();
});
test('still refuses a recurring checkout while a past_due subscription exists', async () => {
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_customer_id: 'cus_shift_past_due',
premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')),
});
useStripe({subscriptions: {sub_shift_past_due: {customer: 'cus_shift_past_due', status: 'past_due'}}});
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
const error = await createBuilder<{data?: {reason?: string}}>(harness, account.token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED)
.execute();
expect(JSON.stringify(error)).toContain('existing_subscription');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
});
describe('refunds', () => {
test('a refund keeps only the gift part of a gift trial', async () => {
const subscriptionId = 'sub_shift_refund_trial';
const paidUntil = Math.floor((Date.now() + ms('28 days')) / 1000);
const trialEnd = paidUntil + 30 * 24 * 60 * 60;
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(newGiftCode(), 30, paidUntil),
},
},
});
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
const subscriptionService = new StripeSubscriptionService(
getStripeClient(),
users,
{getRecurringSubscriptionPriceId: () => null, getProduct: () => null},
getCacheService(),
new NoopGatewayService(),
);
await subscriptionService.cancelSubscriptionImmediately(
createUserID(BigInt(account.userId)),
'self_serve_refund',
);
const after = await findUser(account.userId);
expectNear(after.premiumUntil, Date.now());
expectNear(after.premiumGiftExtensionEndsAt, Date.now() + ms('30 days'));
});
test('a refund right after subscribing keeps only the real gift remainder', async () => {
const subscriptionId = 'sub_shift_refund';
const premiumUntil = new Date(Date.now() + ms('28 days'));
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: premiumUntil,
premium_gift_extension_ends_at: new Date(premiumUntil.getTime() + ms('10 days')),
});
const subscriptionService = new StripeSubscriptionService(
getStripeClient(),
users,
{getRecurringSubscriptionPriceId: () => null, getProduct: () => null},
getCacheService(),
new NoopGatewayService(),
);
await subscriptionService.cancelSubscriptionImmediately(
createUserID(BigInt(account.userId)),
'self_serve_refund',
);
const after = await findUser(account.userId);
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(subscriptionId);
expect(after.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION);
expectNear(after.premiumUntil, Date.now());
expectNear(after.premiumGiftExtensionEndsAt, Date.now() + ms('10 days'));
});
});
describe('gift reversals', () => {
test('removes a gift that was moved past the paid period', async () => {
const premiumUntil = new Date(Date.now() + ms('30 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: 'sub_shift_charge_now',
stripe_customer_id: 'cus_test_1',
premium_until: premiumUntil,
premium_gift_extension_ends_at: new Date(premiumUntil.getTime() + ms('20 days')),
});
const code = await createRedeemedGift(userId, new Date(Date.now() - ms('10 days')), {type: 'days', quantity: 30});
await reverseGift(code);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(premiumUntil.getTime());
expect(after.premiumGiftExtensionEndsAt).toBeNull();
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
});
test('shortens the Stripe trial by the reversed gift once', async () => {
const subscriptionId = 'sub_shift_gift_trial';
const trialEnd = Math.floor((Date.now() + ms('50 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
await reverseGift(code);
const shortenedEnd = trialEnd - 30 * 24 * 60 * 60;
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.id).toBe(subscriptionId);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(shortenedEnd);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.metadata).toEqual({[`gtrial_${code}`]: ''});
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(shortenedEnd * 1000);
expect(after.premiumGiftExtensionEndsAt).toBeNull();
const gift = await users.findGiftCode(code);
expect(gift?.premiumReversedSeconds).toBe(30 * 24 * 60 * 60);
});
test('ends the trial now when the reversed gift is already being used', async () => {
const subscriptionId = 'sub_shift_gift_trial_used';
const trialEnd = Math.floor((Date.now() + ms('10 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('20 days')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end).toBe('now');
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
const after = await findUser(account.userId);
expectNear(after.premiumUntil, Date.now());
});
test('cancels a cancelling subscription instead of charging when the gift is already being used', async () => {
const subscriptionId = 'sub_shift_gift_trial_cancelling';
const trialEnd = Math.floor((Date.now() + ms('10 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
cancel_at_period_end: true,
metadata: giftTrialMetadata(code, 30),
},
},
});
const {userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_will_cancel: true,
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('20 days')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
expect(stripeHandlers.spies.cancelledSubscriptions).toEqual([subscriptionId]);
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
});
test('releases the reversal claim when Stripe refuses the trial change', async () => {
const subscriptionId = 'sub_shift_gift_trial_fail';
const trialEnd = Math.floor((Date.now() + ms('50 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)},
},
});
server.use(
http.post('https://api.stripe.com/v1/subscriptions/:id', () =>
HttpResponse.json(
{error: {type: 'invalid_request_error', message: 'Mock trial update failure'}},
{status: 400},
),
),
);
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code);
await expect(reverseGift(code)).rejects.toThrow();
expect((await users.findGiftCode(code))?.premiumReversedSeconds).toBeNull();
expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(trialEnd * 1000);
});
test('cuts a month gift back to exactly the paid period end', async () => {
const subscriptionId = 'sub_shift_gift_trial_month';
const paidUntil = Math.floor((Date.now() + ms('15 days')) / 1000);
const trialEnd = paidUntil + 28 * 24 * 60 * 60;
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(code, 28, paidUntil),
},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'months', quantity: 1}, code);
await reverseGift(code);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(paidUntil * 1000);
expect(after.premiumGiftExtensionEndsAt).toBeNull();
});
test('never cuts the trial below the paid period end', async () => {
const subscriptionId = 'sub_shift_gift_trial_floor';
const paidUntil = Math.floor((Date.now() + ms('15 days')) / 1000);
const trialEnd = paidUntil + 20 * 24 * 60 * 60;
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(code, 30, paidUntil),
},
},
});
const {userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil);
});
test('leaves the trial alone when the reversed gift was not stacked onto it', async () => {
const subscriptionId = 'sub_shift_gift_trial_other';
const paidUntil = Math.floor((Date.now() + ms('10 days')) / 1000);
const trialEnd = paidUntil + 30 * 24 * 60 * 60;
const stackedCode = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(stackedCode, 30, paidUntil),
},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(Date.now() + ms('55 days')),
});
const plainCode = await createRedeemedGift(userId, new Date(Date.now() - ms('5 days')), {
type: 'days',
quantity: 30,
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, stackedCode);
await reverseGift(plainCode);
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(trialEnd * 1000);
expect(after.premiumGiftExtensionEndsAt!.getTime()).toBeLessThanOrEqual(trialEnd * 1000);
});
test("keeps the paid shift of the user's other gifts", async () => {
const subscriptionId = 'sub_shift_gift_trial_keep_shift';
const now = Date.now();
const paidUntil = Math.floor((now + ms('10 days')) / 1000);
const trialEnd = paidUntil + 30 * 24 * 60 * 60;
const stackedCode = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(stackedCode, 30, paidUntil),
},
},
});
const firstRedeemedAt = new Date(now - ms('150 days'));
const firstGiftBaseEnd = firstRedeemedAt.getTime() + ms('365 days');
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(firstGiftBaseEnd + ms('120 days') + ms('30 days')),
});
await createRedeemedGift(userId, firstRedeemedAt, {type: 'days', quantity: 365});
await createRedeemedGift(userId, new Date(now - ms('1 hour')), {type: 'days', quantity: 30}, stackedCode);
await reverseGift(stackedCode);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(paidUntil * 1000);
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(firstGiftBaseEnd + ms('120 days'));
});
});
describe('redeeming a gift onto a running subscription', () => {
test('records the stacked trial once whichever write lands first', async () => {
const subscriptionId = 'sub_shift_stack';
const periodEnd = Math.floor((Date.now() + ms('20 days')) / 1000);
useStripe({
subscriptions: {
[subscriptionId]: {current_period_start: periodEnd - 30 * 24 * 60 * 60, current_period_end: periodEnd},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: new Date(periodEnd * 1000),
});
const gifter = await createTestAccount(harness);
await createBuilder(harness, gifter.token)
.post('/test/gifts/SHIFTSTACKGIFT')
.body({duration_type: 'days', duration_quantity: 30, created_by_user_id: gifter.userId})
.execute();
await createBuilder(harness, account.token).post('/gifts/SHIFTSTACKGIFT/redeem').expect(204).execute();
const trialEnd = new Date((periodEnd + 30 * 24 * 60 * 60) * 1000);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.metadata).toEqual({
gtrial_SHIFTSTACKGIFT: String(30 * 24 * 60 * 60),
gtrial_paid_until: String(periodEnd),
});
const redeemed = await findUser(account.userId);
expect(redeemed.premiumUntil?.getTime()).toBe(trialEnd.getTime());
expect(redeemed.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime());
await webhookWrite(userId, trialEnd);
const afterWebhook = await findUser(account.userId);
expect(afterWebhook.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime());
});
});
});
@@ -37,10 +37,20 @@ const MOCK_PRICES = {
yearlyPln: 'price_nordic_yearly_pln',
monthlySek: 'price_nordic_monthly_sek',
yearlySek: 'price_nordic_yearly_sek',
monthlyIsk: 'price_nordic_monthly_isk',
yearlyIsk: 'price_nordic_yearly_isk',
gift1MonthUsd: 'price_nordic_gift_1_month_usd',
gift1YearUsd: 'price_nordic_gift_1_year_usd',
gift1MonthEur: 'price_nordic_gift_1_month_eur',
gift1YearEur: 'price_nordic_gift_1_year_eur',
gift1MonthDkk: 'price_nordic_gift_1_month_dkk',
gift1YearDkk: 'price_nordic_gift_1_year_dkk',
gift1MonthNok: 'price_nordic_gift_1_month_nok',
gift1YearNok: 'price_nordic_gift_1_year_nok',
gift1MonthSek: 'price_nordic_gift_1_month_sek',
gift1YearSek: 'price_nordic_gift_1_year_sek',
gift1MonthIsk: 'price_nordic_gift_1_month_isk',
gift1YearIsk: 'price_nordic_gift_1_year_isk',
};
const MOCK_PRICE_SEEDS = {
@@ -48,14 +58,24 @@ const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.monthlyEur]: {unit_amount: 499, currency: 'eur', interval: 'month' as const},
[MOCK_PRICES.yearlyEur]: {unit_amount: 4999, currency: 'eur', interval: 'year' as const},
[MOCK_PRICES.monthlyDkk]: {unit_amount: 3700, currency: 'dkk', interval: 'month' as const},
[MOCK_PRICES.yearlyDkk]: {unit_amount: 37000, currency: 'dkk', interval: 'year' as const},
[MOCK_PRICES.monthlyNok]: {unit_amount: 5400, currency: 'nok', interval: 'month' as const},
[MOCK_PRICES.yearlyNok]: {unit_amount: 54000, currency: 'nok', interval: 'year' as const},
[MOCK_PRICES.monthlyDkk]: {unit_amount: 3500, currency: 'dkk', interval: 'month' as const},
[MOCK_PRICES.yearlyDkk]: {unit_amount: 35000, currency: 'dkk', interval: 'year' as const},
[MOCK_PRICES.monthlyNok]: {unit_amount: 4900, currency: 'nok', interval: 'month' as const},
[MOCK_PRICES.yearlyNok]: {unit_amount: 49000, currency: 'nok', interval: 'year' as const},
[MOCK_PRICES.monthlyPln]: {unit_amount: 1900, currency: 'pln', interval: 'month' as const},
[MOCK_PRICES.yearlyPln]: {unit_amount: 19000, currency: 'pln', interval: 'year' as const},
[MOCK_PRICES.monthlySek]: {unit_amount: 5400, currency: 'sek', interval: 'month' as const},
[MOCK_PRICES.yearlySek]: {unit_amount: 54000, currency: 'sek', interval: 'year' as const},
[MOCK_PRICES.monthlySek]: {unit_amount: 4900, currency: 'sek', interval: 'month' as const},
[MOCK_PRICES.yearlySek]: {unit_amount: 49000, currency: 'sek', interval: 'year' as const},
[MOCK_PRICES.monthlyIsk]: {unit_amount: 59000, currency: 'isk', interval: 'month' as const},
[MOCK_PRICES.yearlyIsk]: {unit_amount: 590000, currency: 'isk', interval: 'year' as const},
[MOCK_PRICES.gift1MonthDkk]: {unit_amount: 3500, currency: 'dkk'},
[MOCK_PRICES.gift1YearDkk]: {unit_amount: 35000, currency: 'dkk'},
[MOCK_PRICES.gift1MonthNok]: {unit_amount: 4900, currency: 'nok'},
[MOCK_PRICES.gift1YearNok]: {unit_amount: 49000, currency: 'nok'},
[MOCK_PRICES.gift1MonthSek]: {unit_amount: 4900, currency: 'sek'},
[MOCK_PRICES.gift1YearSek]: {unit_amount: 49000, currency: 'sek'},
[MOCK_PRICES.gift1MonthIsk]: {unit_amount: 59000, currency: 'isk'},
[MOCK_PRICES.gift1YearIsk]: {unit_amount: 590000, currency: 'isk'},
};
const EUR_SUBSCRIPTION_ID = 'sub_nordic_legacy_eur';
@@ -125,20 +145,35 @@ describe('Nordic localized currencies', () => {
expect(priceIds.currency).toBe('SEK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlySek);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlySek);
expect(priceIds.monthly_amount_minor).toBe(4900);
expect(priceIds.yearly_amount_minor).toBe(49000);
});
test('Denmark resolves to the DKK subscription catalog', async () => {
const priceIds = await getPriceIds('DK');
test.each(['DK', 'FO', 'GL'])('%s resolves to the DKK subscription catalog', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.currency).toBe('DKK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyDkk);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyDkk);
expect(priceIds.monthly_amount_minor).toBe(3500);
expect(priceIds.yearly_amount_minor).toBe(35000);
});
test('Norway resolves to the NOK subscription catalog', async () => {
const priceIds = await getPriceIds('NO');
test.each(['NO', 'SJ'])('%s resolves to the NOK subscription catalog', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.currency).toBe('NOK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyNok);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyNok);
expect(priceIds.monthly_amount_minor).toBe(4900);
expect(priceIds.yearly_amount_minor).toBe(49000);
});
test('Iceland resolves to the ISK subscription catalog', async () => {
const priceIds = await getPriceIds('IS');
expect(priceIds.currency).toBe('ISK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyIsk);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyIsk);
expect(priceIds.monthly_amount_minor).toBe(59000);
expect(priceIds.yearly_amount_minor).toBe(590000);
});
test('Poland still resolves to the PLN subscription catalog', async () => {
@@ -147,23 +182,45 @@ describe('Nordic localized currencies', () => {
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyPln);
});
test.each(['DE', 'NL', 'FI', 'IE', 'IS'])('%s still resolves to the EUR subscription catalog', async (country) => {
test.each(['DE', 'NL', 'FI', 'IE', 'AX'])('%s still resolves to the EUR subscription catalog', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur);
});
test.each(['SE', 'DK', 'NO'])('%s keeps gifts on the base currency', async (country) => {
test.each([
['SE', 'SEK', MOCK_PRICES.gift1MonthSek, MOCK_PRICES.gift1YearSek, 4900],
['DK', 'DKK', MOCK_PRICES.gift1MonthDkk, MOCK_PRICES.gift1YearDkk, 3500],
['NO', 'NOK', MOCK_PRICES.gift1MonthNok, MOCK_PRICES.gift1YearNok, 4900],
['IS', 'ISK', MOCK_PRICES.gift1MonthIsk, MOCK_PRICES.gift1YearIsk, 59000],
])('%s sells gifts in %s at the subscription price', async (country, currency, giftMonth, giftYear, amount) => {
const priceIds = await getPriceIds(country);
expect(priceIds.gift_currency).toBe('EUR');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
expect(priceIds.gift_1_year).toBe(MOCK_PRICES.gift1YearEur);
expect(priceIds.gift_currency).toBe(currency);
expect(priceIds.gift_1_month).toBe(giftMonth);
expect(priceIds.gift_1_year).toBe(giftYear);
expect(priceIds.gift_1_month_amount_minor).toBe(amount);
expect(priceIds.gift_1_month_amount_minor).toBe(priceIds.monthly_amount_minor);
expect(priceIds.gift_1_year_amount_minor).toBe(priceIds.yearly_amount_minor);
});
test('a Nordic gift falls through to USD when no EUR gift price is configured', async () => {
test('a Nordic gift falls back to EUR while the native gift prices are unconfigured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
gift1MonthSek: undefined,
gift1YearSek: undefined,
};
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('SEK');
expect(priceIds.gift_currency).toBe('EUR');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
});
test('a Nordic gift falls through to USD when no native or EUR gift price is configured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
gift1MonthSek: undefined,
gift1YearSek: undefined,
gift1MonthEur: undefined,
gift1YearEur: undefined,
};
@@ -182,7 +239,23 @@ describe('Nordic localized currencies', () => {
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.gift_currency).toBe('SEK');
});
test('Iceland falls back to EUR while the ISK prices are still unconfigured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
monthlyIsk: undefined,
yearlyIsk: undefined,
gift1MonthIsk: undefined,
gift1YearIsk: undefined,
};
const priceIds = await getPriceIds('IS');
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur);
expect(priceIds.gift_currency).toBe('EUR');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
});
});
@@ -232,18 +305,52 @@ describe('Nordic localized currencies', () => {
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts the base gift price from inside Sweden', async () => {
test('accepts the SEK gift price from inside Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'})
.body({price_id: MOCK_PRICES.gift1MonthSek, country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('rejects the EUR gift price from inside Sweden once SEK gifts exist', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts the ISK subscription price for a purchase that geolocates to Iceland', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('IS'));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.yearlyIsk, country_code: 'IS'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.line_items?.[0]?.price).toBe(MOCK_PRICES.yearlyIsk);
});
test('marks Åland as EEA so the withdrawal waiver applies', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('AX'));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyEur, country_code: 'AX'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.metadata?.eu_withdrawal_waiver_required).toBe('true');
});
test('rejects the SEK subscription price on the gift endpoint', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
@@ -256,6 +363,43 @@ describe('Nordic localized currencies', () => {
});
});
describe('adaptive pricing', () => {
async function checkout(path: 'subscription' | 'gift', priceId: string, country: string) {
lookupGeoipMock.mockResolvedValue(geoipCountry(country));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post(`/stripe/checkout/${path}`)
.body({price_id: priceId, country_code: country})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
return stripeHandlers.spies.createdCheckoutSessions[0];
}
test.each([
['subscription', MOCK_PRICES.monthlySek, 'SE'],
['subscription', MOCK_PRICES.yearlySek, 'SE'],
['gift', MOCK_PRICES.gift1MonthSek, 'SE'],
['subscription', MOCK_PRICES.monthlyNok, 'NO'],
['subscription', MOCK_PRICES.monthlyDkk, 'DK'],
['subscription', MOCK_PRICES.monthlyIsk, 'IS'],
['gift', MOCK_PRICES.gift1YearIsk, 'IS'],
] as const)('turns adaptive pricing off for a %s checkout with %s', async (path, priceId, country) => {
const session = await checkout(path, priceId, country);
expect(session?.adaptive_pricing).toEqual({enabled: 'false'});
});
test.each([
['subscription', MOCK_PRICES.monthlyEur, 'DE'],
['gift', MOCK_PRICES.gift1MonthEur, 'FI'],
['subscription', MOCK_PRICES.monthlyUsd, 'US'],
['subscription', MOCK_PRICES.monthlyPln, 'PL'],
] as const)('leaves adaptive pricing unset for a %s checkout with %s', async (path, priceId, country) => {
const session = await checkout(path, priceId, country);
expect(session?.adaptive_pricing).toBeUndefined();
});
});
describe('existing subscribers', () => {
test('a Swedish customer billed in EUR keeps the EUR price and is not repriced to SEK', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
@@ -9,6 +9,7 @@ import {createGuild, createRole, getMember} from '@app/api/guild/tests/GuildTest
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -159,5 +160,30 @@ describe('StripePremiumService', () => {
expect(me.premium_type).toBe(UserPremiumTypes.LIFETIME);
expect(me.premium_lifetime_sequence).toBe(5);
});
test('keeps Visionary number 0 when a lifetime grant comes from a subscription period', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
premium_type: UserPremiumTypes.LIFETIME,
premium_lifetime_sequence: 0,
})
.execute();
const premiumService = new StripePremiumService(
new UserRepository(),
new NoopGatewayService(),
{} as IGuildRepositoryAggregate,
{} as GuildService,
);
await premiumService.setPremiumFromSubscriptionPeriod(
createUserID(BigInt(account.userId)),
UserPremiumTypes.LIFETIME,
new Date(Date.now() + 86_400_000),
);
const me = await createBuilder<{premium_lifetime_sequence: number | null}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_lifetime_sequence).toBe(0);
});
});
});
@@ -147,7 +147,7 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(user.premium_will_cancel).toBe(true);
expect(user.premium_until).not.toBeNull();
});
test('preserves gifted extension when updating subscription', async () => {
test('moves the gifted extension along when the subscription period moves forward', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const subscriptionId = 'sub_test_gifted';
@@ -207,7 +207,9 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(premiumUntil.getTime()).toBeGreaterThan(new Date(currentPeriodEnd * 1000).getTime());
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser?.premiumUntil?.toISOString()).toBe(new Date(currentPeriodEnd * 1000).toISOString());
expect(updatedUser?.premiumGiftExtensionEndsAt?.toISOString()).toBe(giftExtensionEndsAt.toISOString());
expect(updatedUser?.premiumGiftExtensionEndsAt?.getTime()).toBe(
giftExtensionEndsAt.getTime() + (currentPeriodEnd * 1000 - initialPremiumUntil.getTime()),
);
});
test('does not grant unpaid future time for past_due subscriptions and disables grace', async () => {
const account = await createTestAccount(harness);
@@ -6,6 +6,9 @@ import {HttpResponse, http, type RequestHandler} from 'msw';
const STRIPE_API_BASE = 'https://api.stripe.com';
interface CheckoutSessionParams {
adaptive_pricing?: {
enabled?: string;
};
billing_address_collection?: string;
customer?: string;
customer_email?: string;
@@ -322,6 +325,7 @@ interface MockStripeSubscriptionState {
latest_invoice: string | null;
status: 'active' | 'canceled' | 'incomplete' | 'past_due' | 'trialing';
schedule_id: string | null;
metadata: Record<string, string>;
}
interface MockStripeSubscriptionSchedule {
@@ -355,7 +359,12 @@ interface MockStripeSubscriptionSchedule {
const PRICE_ID_CURRENCY_MARKERS = ['eur', 'brl', 'dkk', 'inr', 'nok', 'pln', 'sek', 'try'] as const;
const ISK_PRICE_ID_PATTERN = /(?:^|_)isk(?:_|$)/;
function inferPriceIdCurrency(normalizedPriceId: string): string {
if (ISK_PRICE_ID_PATTERN.test(normalizedPriceId)) {
return 'isk';
}
return PRICE_ID_CURRENCY_MARKERS.find((marker) => normalizedPriceId.includes(marker)) ?? 'usd';
}
@@ -610,6 +619,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
subscriptionStore.set(subscriptionId, {
...createDefaultSubscriptionState(),
...overrides,
metadata: {...overrides.metadata},
});
}
}
@@ -829,6 +839,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
latest_invoice: null,
status: 'active',
schedule_id: null,
metadata: {},
};
}
function getOrCreateSubscriptionState(subscriptionId: string): MockStripeSubscriptionState {
@@ -893,7 +904,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
? getPaymentMethod(subState.default_payment_method)
: null,
livemode: false,
metadata: {},
metadata: {...subState.metadata},
schedule: subState.schedule_id,
start_date: Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60,
};
@@ -1389,7 +1400,12 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
}
spies.updatedSubscriptions.push({id: id as string, params: updateParams});
const subState = getOrCreateSubscriptionState(id as string);
if (updateParams.trial_end) {
if (updateParams.trial_end === 'now') {
const nowUnix = Math.floor(Date.now() / 1000);
subState.trial_end = null;
subState.current_period_start = nowUnix;
subState.current_period_end = nowUnix + (subState.interval === 'year' ? 365 : 30) * 24 * 60 * 60;
} else if (updateParams.trial_end) {
subState.trial_end = Number(updateParams.trial_end);
}
if ('cancel_at' in updateParams) {
@@ -1404,6 +1420,15 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
if (typeof updateParams.default_payment_method === 'string') {
subState.default_payment_method = updateParams.default_payment_method;
}
if (updateParams.metadata && typeof updateParams.metadata === 'object') {
for (const [key, value] of Object.entries(updateParams.metadata as Record<string, string>)) {
if (value === '') {
delete subState.metadata[key];
} else {
subState.metadata[key] = value;
}
}
}
const updatedItem = Array.isArray(updateParams.items) ? updateParams.items[0] : null;
if (updatedItem && typeof updatedItem === 'object') {
if (typeof updatedItem.id === 'string') {
@@ -1421,10 +1446,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
}
}
subscriptionStore.set(id as string, subState);
return HttpResponse.json({
...mapSubscriptionStateToStripeSubscription(id as string, subState),
metadata: updateParams.metadata || {},
});
return HttpResponse.json(mapSubscriptionStateToStripeSubscription(id as string, subState));
}),
http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
const {id} = params;
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export function shiftGiftExtensionEnd(
giftEnd: Date | null | undefined,
anchorMs: number,
nextPremiumUntil: Date,
): Date | null {
if (!giftEnd) {
return null;
}
const shiftMs = nextPremiumUntil.getTime() - anchorMs;
if (shiftMs <= 0 || giftEnd.getTime() <= anchorMs) {
return giftEnd;
}
return new Date(giftEnd.getTime() + shiftMs);
}
export function shiftGiftExtensionPastPremiumUntil(
current: {premiumUntil: Date | null | undefined; giftEnd: Date | null | undefined},
nextPremiumUntil: Date,
now: Date,
periodStart: Date | null,
): Date | null {
const paidFromMs = Math.min(now.getTime(), periodStart?.getTime() ?? now.getTime());
const anchorMs = Math.max(paidFromMs, current.premiumUntil?.getTime() ?? 0);
return shiftGiftExtensionEnd(current.giftEnd, anchorMs, nextPremiumUntil);
}
@@ -71,6 +71,7 @@ export interface IUserAccountRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -188,6 +188,7 @@ export class UserAccountRepository implements IUserAccountRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -225,6 +225,7 @@ export class UserRepository implements IUserRepositoryAggregate {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -252,6 +252,7 @@ export class UserAccountRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -10,6 +10,7 @@ import {EMPTY_USER_ROW, USER_COLUMNS} from '@app/api/database/types/UserTypes';
import {emitAccountChangedIfRelevant} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {User} from '@app/api/models/User';
import {Users} from '@app/api/Tables';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import {isPendingDeletionBlocked} from '@app/api/user/services/PendingDeletionCoordinator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants';
@@ -320,6 +321,7 @@ export class UserDataRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -329,12 +331,24 @@ export class UserDataRepository {
async () => {
return fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
},
(_current) => {
(current) => {
const computedPremiumUntil = updates.computedPremiumUntil;
const patch: UserPatch = {
premium_will_cancel: Db.set(updates.premiumWillCancel),
premium_until: computedPremiumUntil ? Db.set(computedPremiumUntil) : Db.clear(),
};
const giftEnd = current?.premium_gift_extension_ends_at ?? null;
if (computedPremiumUntil && giftEnd) {
const shiftedGiftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: current?.premium_until, giftEnd},
computedPremiumUntil,
new Date(),
updates.periodStart,
);
if (shiftedGiftEnd && shiftedGiftEnd.getTime() !== giftEnd.getTime()) {
patch.premium_gift_extension_ends_at = Db.set(shiftedGiftEnd);
}
}
return {
pk: {user_id: userId},
patch,
+25 -4
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {
type EffectiveBillingConfig,
getEffectiveBillingConfig,
@@ -32,13 +33,16 @@ function getEnvCurrencyPreferences(countryCode: string | null | undefined): Arra
if (upperCode === 'BR') {
return ['BRL', 'USD', 'EUR'];
}
if (upperCode === 'DK') {
if (upperCode === 'DK' || upperCode === 'FO' || upperCode === 'GL') {
return ['DKK', 'EUR', 'USD'];
}
if (upperCode === 'IN') {
return ['INR', 'USD', 'EUR'];
}
if (upperCode === 'NO') {
if (upperCode === 'IS') {
return ['ISK', 'EUR', 'USD'];
}
if (upperCode === 'NO' || upperCode === 'SJ') {
return ['NOK', 'EUR', 'USD'];
}
if (upperCode === 'PL') {
@@ -56,9 +60,22 @@ function getEnvCurrencyPreferences(countryCode: string | null | undefined): Arra
return ['USD', 'EUR'];
}
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'ISK', 'NOK', 'SEK']);
const ENV_CATALOG_CURRENCIES = new Set<Currency>(['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']);
const ADAPTIVE_PRICING_DISABLED_CURRENCIES = new Set<Currency>(['DKK', 'ISK', 'NOK', 'SEK']);
const ENV_CATALOG_CURRENCIES = new Set<Currency>([
'USD',
'EUR',
'BRL',
'DKK',
'INR',
'ISK',
'NOK',
'PLN',
'SEK',
'TRY',
]);
const OPERATOR_CURRENCY_PATTERN = /^[A-Z]{3}$/;
@@ -77,6 +94,10 @@ export function getGiftCurrencyPreferences(
);
}
export function shouldDisableAdaptivePricing(currency: string): boolean {
return !Config.instance.selfHosted && ADAPTIVE_PRICING_DISABLED_CURRENCIES.has(currency.toUpperCase());
}
export function isLocalizedCurrency(
currency: Currency,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
@@ -1,6 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '@app/api/utils/CurrencyUtils';
import {Config} from '@app/api/Config';
import {
getCurrency,
getCurrencyPreferences,
getGiftCurrencyPreferences,
shouldDisableAdaptivePricing,
} from '@app/api/utils/CurrencyUtils';
import {describe, expect, it} from 'vitest';
describe('getCurrency', () => {
@@ -79,8 +85,8 @@ describe('getCurrency', () => {
it('returns NOK for Norway (EEA but not EU)', () => {
expect(getCurrency('NO')).toBe('NOK');
});
it('returns EUR for Iceland (EEA but not EU)', () => {
expect(getCurrency('IS')).toBe('EUR');
it('returns ISK for Iceland (EEA but not EU)', () => {
expect(getCurrency('IS')).toBe('ISK');
});
it('returns EUR for Liechtenstein (EEA but not EU)', () => {
expect(getCurrency('LI')).toBe('EUR');
@@ -146,8 +152,8 @@ describe('getCurrency', () => {
'SK',
'SI',
'ES',
'IS',
'LI',
'AX',
];
for (const country of eeaCountries) {
it(`returns EUR for ${country}`, () => {
@@ -166,19 +172,62 @@ describe('getCurrency', () => {
it('uses local currency for Norway', () => {
expect(getCurrency('NO')).toBe('NOK');
});
it('uses local currency for Iceland', () => {
expect(getCurrency('IS')).toBe('ISK');
});
});
describe('maps Nordic territories to their home currency', () => {
it('returns DKK for the Faroe Islands and Greenland', () => {
expect(getCurrencyPreferences('FO')).toEqual(['DKK', 'EUR', 'USD']);
expect(getCurrencyPreferences('GL')).toEqual(['DKK', 'EUR', 'USD']);
});
it('returns NOK for Svalbard and Jan Mayen', () => {
expect(getCurrencyPreferences('SJ')).toEqual(['NOK', 'EUR', 'USD']);
});
it('returns ISK with EUR as the fallback for Iceland', () => {
expect(getCurrencyPreferences('IS')).toEqual(['ISK', 'EUR', 'USD']);
});
it('returns EUR for Åland', () => {
expect(getCurrencyPreferences('AX')).toEqual(['EUR', 'USD']);
});
});
});
describe('shouldDisableAdaptivePricing', () => {
it('disables adaptive pricing for the native Nordic currencies', () => {
for (const currency of ['SEK', 'NOK', 'DKK', 'ISK', 'sek']) {
expect(shouldDisableAdaptivePricing(currency)).toBe(true);
}
});
it('leaves adaptive pricing alone for every other currency', () => {
for (const currency of ['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY']) {
expect(shouldDisableAdaptivePricing(currency)).toBe(false);
}
});
it('leaves adaptive pricing alone on a self-hosted instance', () => {
const originalSelfHosted = Config.instance.selfHosted;
Config.instance.selfHosted = true;
try {
expect(shouldDisableAdaptivePricing('SEK')).toBe(false);
} finally {
Config.instance.selfHosted = originalSelfHosted;
}
});
});
describe('getGiftCurrencyPreferences', () => {
it('never offers a localized currency that is cheaper than the base price', () => {
it('never offers BRL, INR, PLN or TRY gifts', () => {
for (const country of ['BR', 'IN', 'PL', 'TR']) {
expect(getGiftCurrencyPreferences(country)).not.toContain(getCurrencyPreferences(country)[0]);
}
});
it('offers the localized currency where it is not cheaper than the base price', () => {
it('offers the Nordic localized currencies for gifts', () => {
expect(getGiftCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('DK')).toEqual(['DKK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('NO')).toEqual(['NOK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('IS')).toEqual(['ISK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('FO')).toEqual(['DKK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('SJ')).toEqual(['NOK', 'EUR', 'USD']);
});
it('uses EUR for other EEA countries', () => {
expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']);
@@ -11,9 +11,11 @@ import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeS
import {
getInvoiceLatestLinePeriodEnd,
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
@@ -71,6 +73,15 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip
}
if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) {
patch.premium_until = premiumUntil;
const giftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
premiumUntil,
new Date(),
getSubscriptionCurrentPeriodStart(subscription),
);
if (giftEnd !== user.premiumGiftExtensionEndsAt) {
patch.premium_gift_extension_ends_at = giftEnd;
}
}
if (user.premiumWillCancel !== premiumWillCancel) {
patch.premium_will_cancel = premiumWillCancel;
+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,146 @@
// 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 ||
RuntimeConfig.getSnapshotOrNull() === null ||
RuntimeConfig.requiresSelfHostedSetup()
? 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;
@@ -60,6 +62,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const [isGiftMode, setIsGiftMode] = useState(defaultGiftMode);
const giftSectionRef = useRef<HTMLDivElement | null>(null);
const perksSectionRef = useRef<HTMLDivElement | null>(null);
const pricingSectionRef = useRef<HTMLDivElement | null>(null);
const countryCode = GeoIP.countryCode;
const guilds = Guilds.getGuilds();
const visionaryGuild = useMemo(() => {
@@ -92,12 +95,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
handleCancelPendingSubscriptionChange,
} = useSubscriptionActions(countryCode);
const {loadingRejoinCommunity, handleCommunityButtonClick} = useCommunityActions(visionaryGuild);
const {loadingCheckout, handleSelectPlan} = useCheckoutActions(
priceIds,
countryCode,
subscriptionStatus.isGiftSubscription,
mobileLayoutState.enabled,
);
const {loadingCheckout, handleSelectPlan} = useCheckoutActions(priceIds, countryCode, mobileLayoutState.enabled, {
hasGiftTime: subscriptionStatus.isGiftSubscription,
});
useEffect(() => {
if (!currentUser?.id) return;
void PremiumCommands.refreshPremiumState(countryCode ?? undefined);
@@ -112,9 +112,10 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const billingUnavailable =
!shouldShowPremiumFeatures() ||
(!arePremiumPurchasesAvailable() && !(hasBillingRelationship && canServiceStripeSubscriptions()));
const canSubscribe = purchasesAvailable && subscriptionStatus.canStartSubscription;
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;
@@ -125,6 +126,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const scrollToPerks = useCallback(() => {
perksSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'});
}, []);
const scrollToPricing = useCallback(() => {
pricingSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'});
}, []);
const navigateToRedeemGift = useCallback(() => {
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'});
}, []);
@@ -191,7 +195,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 +214,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>
@@ -225,6 +229,8 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
isVisionary={subscriptionStatus.isVisionary}
perksDisabled={subscriptionStatus.perksDisabled}
isGiftSubscription={subscriptionStatus.isGiftSubscription}
isGiftGrace={subscriptionStatus.isGiftGrace}
canSubscribe={canSubscribe}
storeSubscription={subscriptionStatus.storeSubscription}
premiumUntil={subscriptionStatus.actualPremiumUntil}
billingCycle={subscriptionStatus.billingCycle}
@@ -254,6 +260,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
loadingRejoinCommunity={loadingRejoinCommunity}
scrollToPerks={scrollToPerks}
navigateToRedeemGift={navigateToRedeemGift}
handleStartSubscription={scrollToPricing}
handleOpenCustomerPortal={handleOpenCustomerPortal}
handleReactivateSubscription={handleReactivateSubscription}
handleCancelSubscription={handleCancelSubscriptionConfirmed}
@@ -310,21 +317,23 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
</Button>
</section>
)
) : !subscriptionStatus.shouldShowPremiumCard ? (
<PricingSection
isGiftMode={isGiftMode && giftPurchasesAvailable}
giftPurchasesAvailable={giftPurchasesAvailable}
setIsGiftMode={setIsGiftMode}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.pricing-section"
/>
) : canSubscribe ? (
<div ref={pricingSectionRef} data-flx="app.plutonium-content.pricing-anchor">
<PricingSection
isGiftMode={isGiftMode && giftPurchasesAvailable}
giftPurchasesAvailable={giftPurchasesAvailable}
setIsGiftMode={setIsGiftMode}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.pricing-section"
/>
</div>
) : (
giftPurchasesAvailable && (
<GiftSection
@@ -342,7 +351,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
@@ -353,7 +362,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
</div>
</section>
</div>
{!subscriptionStatus.isPremium && purchasesAvailable && (
{canSubscribe && (
<BottomCTASection
isGiftMode={isGiftMode && giftPurchasesAvailable}
monthlyPrice={monthlyPrice}
@@ -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';
@@ -79,6 +75,8 @@ interface SubscriptionCardProps {
isVisionary: boolean;
perksDisabled: boolean;
isGiftSubscription: boolean;
isGiftGrace: boolean;
canSubscribe: boolean;
storeSubscription: PremiumStoreSubscriptionState | null;
premiumUntil: Date | null;
billingCycle: string | null;
@@ -108,6 +106,7 @@ interface SubscriptionCardProps {
loadingRejoinCommunity: boolean;
scrollToPerks: () => void;
navigateToRedeemGift: () => void;
handleStartSubscription: () => void;
handleOpenCustomerPortal: () => void;
handleReactivateSubscription: () => void;
handleCancelSubscription: () => void;
@@ -148,6 +147,8 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
isVisionary,
perksDisabled,
isGiftSubscription,
isGiftGrace,
canSubscribe,
storeSubscription,
premiumUntil,
billingCycle,
@@ -177,6 +178,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
loadingRejoinCommunity,
scrollToPerks,
navigateToRedeemGift,
handleStartSubscription,
handleOpenCustomerPortal,
handleReactivateSubscription,
handleCancelSubscription,
@@ -195,7 +197,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 +424,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">
@@ -501,6 +503,22 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
</Trans>
);
})()
) : isGiftGrace ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
return (
<Trans comment="Plutonium subscription card text shown in the short grace period after gifted premium time ends. {graceDate} is a date already formatted and localized by code; never write a date into the translation.">
Your gift time ended but{' '}
<PerksButton
onClick={scrollToPerks}
data-flx="app.plutonium.subscription-card.perks-button.scroll-to-perks--11"
/>{' '}
stay active until{' '}
<strong data-flx="app.plutonium.subscription-card.strong--19">{graceDate}</strong>. Subscribe
before then to keep them.
</Trans>
);
})()
) : isInGracePeriod ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
@@ -777,55 +795,102 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
>
{i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)}
</Button>
) : isGiftSubscription || billingUnavailable ? (
wrapIfDisabled(
<Button
variant="primary"
onClick={navigateToRedeemGift}
small
className={styles.actionButton}
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.navigate-to-redeem-gift"
>
<Trans comment="Billing button for entering a premium gift code.">Redeem gift code</Trans>
</Button>,
'redeem-gift',
purchaseDisabled,
)
) : (
) : isGiftSubscription || isGiftGrace || billingUnavailable ? (
<>
{hasEverPurchased &&
{canSubscribe &&
!billingUnavailable &&
wrapIfDisabled(
<Button
variant={isFullyExpired || isInGracePeriod || premiumWillCancel ? 'primary' : 'secondary'}
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
variant="primary"
onClick={handleStartSubscription}
small
className={styles.actionButton}
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
data-flx="app.plutonium.subscription-card.action-button.reactivate-subscription"
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.start-subscription"
>
{isFullyExpired ? (
<Trans comment="Billing button for starting a new subscription after premium expired.">
Resubscribe
</Trans>
) : isInGracePeriod ? (
<Trans comment="Billing button for restarting a subscription during the grace period.">
Resubscribe
</Trans>
) : premiumWillCancel ? (
<Trans comment="Billing button that cancels a scheduled subscription cancellation.">
Reactivate
</Trans>
) : isVisionary ? (
<Trans comment="Billing button that opens the external customer portal.">Customer portal</Trans>
) : (
i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)
)}
<Trans comment="Billing button that starts a recurring subscription for someone on gifted premium time.">
Subscribe
</Trans>
</Button>,
'manage-reactivate',
purchaseDisabled && shouldUseReactivateQuickAction,
'start-subscription',
purchaseDisabled,
)}
{wrapIfDisabled(
<Button
variant={canSubscribe && !billingUnavailable ? 'secondary' : 'primary'}
onClick={navigateToRedeemGift}
small
className={styles.actionButton}
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.navigate-to-redeem-gift"
>
<Trans comment="Billing button for entering a premium gift code.">Redeem gift code</Trans>
</Button>,
'redeem-gift',
purchaseDisabled,
)}
</>
) : (
<>
{(isFullyExpired || isInGracePeriod) && canSubscribe
? wrapIfDisabled(
<Button
variant="primary"
onClick={handleStartSubscription}
small
className={styles.actionButton}
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.resubscribe"
>
{hasEverPurchased ? (
<Trans comment="Billing button for starting a new subscription after premium ended.">
Resubscribe
</Trans>
) : (
<Trans comment="Billing button that starts a recurring subscription for someone on gifted premium time.">
Subscribe
</Trans>
)}
</Button>,
'resubscribe',
purchaseDisabled,
)
: hasEverPurchased &&
wrapIfDisabled(
<Button
variant={isFullyExpired || isInGracePeriod || premiumWillCancel ? 'primary' : 'secondary'}
onClick={
shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal
}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
small
className={styles.actionButton}
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
data-flx="app.plutonium.subscription-card.action-button.reactivate-subscription"
>
{isFullyExpired ? (
<Trans comment="Billing button for starting a new subscription after premium expired.">
Resubscribe
</Trans>
) : isInGracePeriod ? (
<Trans comment="Billing button for restarting a subscription during the grace period.">
Resubscribe
</Trans>
) : premiumWillCancel ? (
<Trans comment="Billing button that cancels a scheduled subscription cancellation.">
Reactivate
</Trans>
) : isVisionary ? (
<Trans comment="Billing button that opens the external customer portal.">
Customer portal
</Trans>
) : (
i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)
)}
</Button>,
'manage-reactivate',
purchaseDisabled && shouldUseReactivateQuickAction,
)}
{isVisionary && (
<Button
variant="secondary"
@@ -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';
@@ -133,11 +132,19 @@ const CHECKOUT_START_FAILED_BODY_DESCRIPTOR = msg({
message: 'Something went wrong while starting checkout. Please try again in a moment.',
comment: 'Body of the generic fallback error modal shown when creating a checkout session fails unexpectedly.',
});
const GIFT_SUBSCRIPTION_BLOCKS_RECURRING_TOAST_DESCRIPTOR = msg({
message:
"You're currently on a gift subscription. It won't renew. You can redeem more gift codes to extend it. Recurring subscriptions can be started after your gift time ends.",
const GIFT_TIME_KEPT_TITLE_DESCRIPTOR = msg({
message: 'Your gift time is kept',
comment:
'Error modal body shown when a gift-subscription user tries to start a recurring subscription. Explains the extension/redemption path.',
'Title of the confirmation shown before checkout when a user with gifted premium time starts a recurring subscription.',
});
const GIFT_TIME_KEPT_BODY_DESCRIPTOR = msg({
message: "You'll be charged now. Your remaining gift time is added after your paid period, so none of it is lost.",
comment:
'Body of the confirmation shown before checkout when a user with gifted premium time starts a recurring subscription. The subscription is charged immediately and the unused gift time is appended after the paid period.',
});
const CONTINUE_TO_CHECKOUT_DESCRIPTOR = msg({
message: 'Continue to checkout',
comment: 'Button in the gift time confirmation that proceeds to subscription checkout.',
});
const PRICING_NOT_LOADED_TOAST_DESCRIPTOR = msg({
message: 'Pricing is still loading.',
@@ -209,8 +216,8 @@ function alternativePaymentMethodForCurrency(
export const useCheckoutActions = (
priceIds: PriceIds | null,
countryCode: string | null,
isGiftSubscription: boolean,
mobileEnabled: boolean,
{hasGiftTime = false}: {hasGiftTime?: boolean} = {},
) => {
const {i18n} = useLingui();
const [loadingCheckout, setLoadingCheckout] = useState(false);
@@ -276,7 +283,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 +311,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 +344,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"
@@ -399,7 +406,7 @@ export const useCheckoutActions = (
const handleSelectPlan = useCallback(
async (plan: Plan) => {
if (loadingCheckout) return;
logger.info('Plan selected', {plan, isGiftSubscription});
logger.info('Plan selected', {plan, hasGiftTime});
const showCheckoutPlanErrorModal = (message: string, flxKey: string) => {
ModalCommands.push(
modal(() => (
@@ -411,13 +418,6 @@ export const useCheckoutActions = (
)),
);
};
if (isGiftSubscription && (plan === 'monthly' || plan === 'yearly')) {
showCheckoutPlanErrorModal(
i18n._(GIFT_SUBSCRIPTION_BLOCKS_RECURRING_TOAST_DESCRIPTOR),
'app.plutonium.use-checkout-actions.gift-subscription-blocked.generic-error-modal',
);
return;
}
if (!priceIds) {
logger.error('Price IDs not loaded yet');
showCheckoutPlanErrorModal(
@@ -499,34 +499,55 @@ export const useCheckoutActions = (
setLoadingCheckout(false);
}
};
const altPaymentMethod = alternativePaymentMethodForCurrency(priceIds.currency, isGift, plan);
if (altPaymentMethod && MANDATORY_LOCAL_PAYMENT_CURRENCIES.has(priceIds.currency ?? '')) {
await startCheckout({paymentMethod: altPaymentMethod});
return;
}
if (altPaymentMethod) {
const altPrompt = getAlternativePaymentMethodPrompt(priceIds.currency, altPaymentMethod);
const proceedToCheckout = async ({afterConfirm = false}: {afterConfirm?: boolean} = {}) => {
const altPaymentMethod = alternativePaymentMethodForCurrency(priceIds.currency, isGift, plan);
if (altPaymentMethod && MANDATORY_LOCAL_PAYMENT_CURRENCIES.has(priceIds.currency ?? '')) {
await startCheckout({skipMobilePrompt: afterConfirm, paymentMethod: altPaymentMethod});
return;
}
if (altPaymentMethod) {
const altPrompt = getAlternativePaymentMethodPrompt(priceIds.currency, altPaymentMethod);
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(CHOOSE_PAYMENT_METHOD_MODAL_TITLE_DESCRIPTOR)}
description={altPrompt.description}
primaryText={altPrompt.primaryText}
primaryVariant="primary"
secondaryText={i18n._(USE_CARD_BUTTON_DESCRIPTOR)}
onPrimary={() => {
void startCheckout({skipMobilePrompt: true, paymentMethod: altPaymentMethod});
}}
onSecondary={() => {
void startCheckout({skipMobilePrompt: true});
}}
data-flx="app.plutonium.use-checkout-actions.handle-select-plan.confirm-modal--2"
/>
)),
);
return;
}
await startCheckout({skipMobilePrompt: afterConfirm});
};
if (hasGiftTime && !isGift) {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(CHOOSE_PAYMENT_METHOD_MODAL_TITLE_DESCRIPTOR)}
description={altPrompt.description}
primaryText={altPrompt.primaryText}
title={i18n._(GIFT_TIME_KEPT_TITLE_DESCRIPTOR)}
description={i18n._(GIFT_TIME_KEPT_BODY_DESCRIPTOR)}
primaryText={i18n._(CONTINUE_TO_CHECKOUT_DESCRIPTOR)}
primaryVariant="primary"
secondaryText={i18n._(USE_CARD_BUTTON_DESCRIPTOR)}
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
onPrimary={() => {
void startCheckout({skipMobilePrompt: true, paymentMethod: altPaymentMethod});
void proceedToCheckout({afterConfirm: true});
}}
onSecondary={() => {
void startCheckout({skipMobilePrompt: true});
}}
data-flx="app.plutonium.use-checkout-actions.handle-select-plan.confirm-modal--2"
data-flx="app.plutonium.use-checkout-actions.handle-select-plan.gift-time-confirm-modal"
/>
)),
);
return;
}
await startCheckout();
await proceedToCheckout();
},
[
handleCheckoutError,
@@ -534,7 +555,7 @@ export const useCheckoutActions = (
priceIds,
countryCode,
getAlternativePaymentMethodPrompt,
isGiftSubscription,
hasGiftTime,
mobileEnabled,
i18n,
],
@@ -2,6 +2,7 @@
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import {classifyPremiumSubscription} from '@app/features/premium/utils/PremiumSubscriptionClassification';
import {getStoreOwnedSubscription} from '@app/features/premium/utils/PremiumUtils';
import type {User} from '@app/features/user/models/User';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
@@ -27,6 +28,8 @@ export interface SubscriptionStatusInfo {
billingCycle: string | null;
actualPremiumUntil: Date | null;
isGiftSubscription: boolean;
isGiftGrace: boolean;
canStartSubscription: boolean;
storeSubscription: PremiumStoreSubscriptionState | null;
gracePeriodInfo: GracePeriodInfo;
shouldShowPremiumCard: boolean;
@@ -91,7 +94,6 @@ export const useSubscriptionStatus = (
: storeSubscription
? storeSubscription.billing_cycle
: (actual?.premium_billing_cycle ?? currentUser?.premiumBillingCycle ?? null);
const isGiftSubscription = Boolean(!billingCycle && hasPaidPremium && !isVisionary && premiumUntil);
const isStoreGrace = storeSubscription?.state === 'grace' && premiumGraceEndsAt != null;
const gracePeriodInfo = useMemo((): GracePeriodInfo => {
if (isVisionary) {
@@ -125,6 +127,18 @@ export const useSubscriptionStatus = (
return {isInGracePeriod, isExpired, graceEndDate, showExpiredState, isPaymentRecovery};
}, [premiumUntil, premiumGraceEndsAt, isVisionary, billingCycle, isStoreGrace]);
const {isInGracePeriod, isExpired: isFullyExpired, showExpiredState} = gracePeriodInfo;
const {isGiftSubscription, isGiftGrace, canStartSubscription} = classifyPremiumSubscription({
hasPaidPremium,
isVisionary,
hasStoreSubscription: storeSubscription != null,
billingCycle,
premiumUntil,
isInGracePeriod,
isFullyExpired,
billingKnown: !useScenarioOverride && premiumState != null,
storeOwnershipKnown: useScenarioOverride || premiumState != null,
billingSubscription: useScenarioOverride ? null : (premiumState?.billing.subscription ?? null),
});
const isPremium = useDeveloperOverride
? hasPaidPremium && !isFullyExpired && !perksDisabled
: (effective?.is_premium ?? currentUser?.isPremium() ?? false);
@@ -163,6 +177,8 @@ export const useSubscriptionStatus = (
billingCycle,
actualPremiumUntil: premiumUntil,
isGiftSubscription,
isGiftGrace,
canStartSubscription,
storeSubscription,
gracePeriodInfo,
shouldShowPremiumCard,
@@ -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;

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