mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 19:52:13 +09:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a4aa42d4b | ||
|
|
0ef1cd14c3 | ||
|
|
795e190bda | ||
|
|
d00389ab30 | ||
|
|
3071bc7525 | ||
|
|
6de6be2358 | ||
|
|
c51c222c47 | ||
|
|
e218ba21de | ||
|
|
de358c0def | ||
|
|
4b88d64b2d | ||
|
|
ee1c861eb2 | ||
|
|
6a24ac3f4e | ||
|
|
8b312c610e | ||
|
|
2e3e3a1536 | ||
|
|
5c63d81ffd | ||
|
|
c402c52a8a | ||
|
|
317fedeef9 | ||
|
|
002502a7fa | ||
|
|
1997850d55 | ||
|
|
d346eb0e88 | ||
|
|
7c5fa2180a |
@@ -50,6 +50,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ jobs:
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
FLUXER_SELF_HOSTED=true
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||
|
||||
@@ -200,6 +200,7 @@ jobs:
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||
|
||||
@@ -103,11 +103,150 @@ jobs:
|
||||
--step set_matrix
|
||||
--skip-targets "${{ inputs.skip_targets }}"
|
||||
|
||||
shared_assets:
|
||||
name: Build shared renderer assets
|
||||
needs:
|
||||
- meta
|
||||
runs-on: ubuntu-24.04
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.channel }}
|
||||
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout CI helpers
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: _ci
|
||||
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.source_sha }}
|
||||
path: source
|
||||
|
||||
- name: Set up Rust toolchain (renderer wasm)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Set workdir (Unix)
|
||||
env:
|
||||
SUBST_TARGET: ${{ github.workspace }}/source
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_workdir_unix
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Set up pnpm
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step setup_pnpm
|
||||
|
||||
- name: Resolve pnpm store path (Unix)
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step resolve_pnpm_store_unix
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-pnpm-store-
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-shared-renderer-cargo-registry-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step install_dependencies
|
||||
|
||||
- name: Update version
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step update_version
|
||||
|
||||
- name: Set build channel
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_build_channel
|
||||
|
||||
- name: Build shared renderer assets
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_shared_assets
|
||||
|
||||
- name: Prepare shared renderer artifact
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_shared_assets
|
||||
|
||||
- name: Upload shared renderer artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: source/desktop-shared-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
- name: Split renderer into desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step split_modules
|
||||
|
||||
- name: Pack desktop modules
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step pack_modules
|
||||
|
||||
- name: Upload desktop module packages
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: |
|
||||
source/desktop-modules/classification.json
|
||||
source/desktop-modules/*/module.json
|
||||
source/desktop-modules/*/package.br
|
||||
source/desktop-modules/*/package.br.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||
needs:
|
||||
- meta
|
||||
- matrix
|
||||
- shared_assets
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
@@ -130,6 +269,7 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
FLUXER_MODULES: "1"
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
ELECTRON_ARCH: ${{ matrix.electron_arch }}
|
||||
@@ -276,6 +416,17 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_build_channel
|
||||
|
||||
- name: Download shared renderer artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: source/desktop-shared-assets
|
||||
|
||||
- name: Restore shared renderer assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step restore_shared_assets
|
||||
|
||||
- name: Build Electron main process
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
@@ -505,11 +656,13 @@ jobs:
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- shared_assets
|
||||
- build
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
@@ -547,6 +700,17 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- name: Download desktop module packages
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: desktop-modules
|
||||
|
||||
- name: Build desktop module manifest
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_module_manifest
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
Generated
+33
-1
@@ -1710,6 +1710,16 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1741,6 +1751,7 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"base64 0.23.1",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -1750,6 +1761,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
@@ -2042,7 +2054,6 @@ dependencies = [
|
||||
"hex",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
@@ -4790,6 +4801,17 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5818,6 +5840,16 @@ dependencies = [
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
|
||||
@@ -671,6 +671,7 @@ services:
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
|
||||
@@ -229,10 +229,11 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_channel_threads" => match build_channel_threads_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_channel_threads" => instance_config_result(
|
||||
client
|
||||
.update_instance_config(&build_channel_threads_update(&form))
|
||||
.await,
|
||||
),
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -676,51 +677,26 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
|
||||
})
|
||||
}
|
||||
|
||||
fn build_channel_threads_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("channel_threads_enabled")),
|
||||
guild_basis_points: parse_form_number(
|
||||
form,
|
||||
"channel_threads_guild_basis_points",
|
||||
"Guild rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?,
|
||||
enabled_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_enabled_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Enabled guild IDs",
|
||||
)?),
|
||||
disabled_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_disabled_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Disabled guild IDs",
|
||||
)?),
|
||||
user_basis_points: parse_form_number(
|
||||
form,
|
||||
"channel_threads_user_basis_points",
|
||||
"User rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
user_salt: parse_experiment_rollout_salt(form, "channel_threads_user_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("channel_threads_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
fn build_channel_threads_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let channel_threads = if form.bool_value("channel_threads_everyone") {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
|
||||
disabled_guild_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
ChannelThreadsConfigUpdateRequest {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
InstanceConfigUpdateRequest {
|
||||
channel_threads: Some(channel_threads),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
@@ -1489,108 +1465,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_reads_both_rollout_dimensions() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"channel_threads_enabled=true&channel_threads_guild_basis_points=%2010%20&channel_threads_guild_salt=%20channel-threads-guild-v2%20&channel_threads_enabled_guild_ids=1600000000000000001%0A1600000000000000002%0A1600000000000000001&channel_threads_disabled_guild_ids=1600000000000000003&channel_threads_user_basis_points=10000&channel_threads_user_salt=channel-threads-user-v2&channel_threads_included_user_ids=1500000000000000001&channel_threads_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_channel_threads_update(&form)
|
||||
.expect("valid form")
|
||||
.channel_threads
|
||||
.expect("channel threads update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.guild_basis_points, Some(10));
|
||||
fn build_channel_threads_update_turns_threads_on_for_everyone() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true");
|
||||
assert_eq!(
|
||||
update.guild_salt,
|
||||
Some("channel-threads-guild-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_guild_ids,
|
||||
Some(vec![
|
||||
"1600000000000000001".to_owned(),
|
||||
"1600000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.disabled_guild_ids,
|
||||
Some(vec!["1600000000000000003".to_owned()])
|
||||
);
|
||||
assert_eq!(update.user_basis_points, Some(10000));
|
||||
assert_eq!(update.user_salt, Some("channel-threads-user-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_leaves_the_experiment_off_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_channel_threads_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {
|
||||
"enabled": false,
|
||||
"enabled_guild_ids": [],
|
||||
"enabled": true,
|
||||
"guild_basis_points": 10000,
|
||||
"user_basis_points": 10000,
|
||||
"disabled_guild_ids": [],
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_threads_update_rejects_invalid_targeting() {
|
||||
let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("%2C");
|
||||
for (form, message) in [
|
||||
(
|
||||
"channel_threads_guild_basis_points=10001".to_owned(),
|
||||
"Guild rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"channel_threads_user_basis_points=-1".to_owned(),
|
||||
"User rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"channel_threads_guild_salt=%20%20".to_owned(),
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"channel_threads_user_salt=caf%C3%A9".to_owned(),
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"channel_threads_enabled_guild_ids=123%2Cinvalid".to_owned(),
|
||||
"Enabled guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"channel_threads_disabled_guild_ids=123456789012345678901".to_owned(),
|
||||
"Disabled guild IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"channel_threads_excluded_user_ids=abc".to_owned(),
|
||||
"Excluded user IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
format!("channel_threads_enabled_guild_ids={too_many_guilds}"),
|
||||
"Enabled guild IDs must contain at most 1000 unique IDs",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_channel_threads_update(&form).expect_err("invalid targeting"),
|
||||
message
|
||||
);
|
||||
}
|
||||
fn build_channel_threads_update_only_turns_threads_off_when_unchecked() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
assert_eq!(
|
||||
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
|
||||
serde_json::json!({"channel_threads": {"enabled": false}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
use crate::{
|
||||
api::types::{
|
||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT,
|
||||
CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
||||
ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
@@ -17,7 +16,6 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
alert::alert_warning,
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
@@ -192,9 +190,13 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
@if !instance_config.self_hosted {
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
}
|
||||
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
|
||||
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
|
||||
@if !instance_config.self_hosted {
|
||||
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
|
||||
}
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1487,182 +1489,41 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
|
||||
)
|
||||
}
|
||||
|
||||
fn channel_threads_available_to_everyone(channel_threads: &ChannelThreadsConfigResponse) -> bool {
|
||||
channel_threads.enabled
|
||||
&& channel_threads.guild_basis_points >= 10_000
|
||||
&& channel_threads.user_basis_points >= 10_000
|
||||
&& channel_threads.disabled_guild_ids.is_empty()
|
||||
&& channel_threads.excluded_user_ids.is_empty()
|
||||
}
|
||||
|
||||
fn channel_threads_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
channel_threads: &ChannelThreadsConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if channel_threads.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let enabled_guild_ids = channel_threads.enabled_guild_ids.join("\n");
|
||||
let disabled_guild_ids = channel_threads.disabled_guild_ids.join("\n");
|
||||
let included_user_ids = channel_threads.included_user_ids.join("\n");
|
||||
let excluded_user_ids = channel_threads.excluded_user_ids.join("\n");
|
||||
let everyone = channel_threads_available_to_everyone(channel_threads);
|
||||
section_card_with_description(
|
||||
"Channel threads",
|
||||
"Threads, forum channels and media channels. A guild gets the feature only when the guild \
|
||||
is selected, and a member sees it only when they are also selected and use a client \
|
||||
that supports threads. Bots follow the guild selection.",
|
||||
"Threads and forums",
|
||||
"Threads, forum channels and media channels in every community.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
(alert_warning("Before enabling", html! {
|
||||
p class="text-sm" {
|
||||
"Enable only after every gateway role, the api, the workers and the \
|
||||
messages service run the gate build and the bit 34-38 overwrite audit \
|
||||
is clean."
|
||||
}
|
||||
}))
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
@if channel_threads.ever_enabled {
|
||||
(badge("Ever enabled", BadgeVariant::Warning))
|
||||
}
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (channel_threads.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"channel_threads_enabled",
|
||||
"channel_threads_everyone",
|
||||
"true",
|
||||
"Turn on threads for the selected guilds and users",
|
||||
channel_threads.enabled,
|
||||
"Available to everyone",
|
||||
everyone,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked no guild \
|
||||
has threads, and existing threads and forums stay stored but hidden \
|
||||
until it is turned back on."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Guilds" }
|
||||
(number_field(
|
||||
"channel_threads_guild_basis_points",
|
||||
"Guild rollout (basis points)",
|
||||
&channel_threads.guild_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of guilds bucketed into the experiment, in basis points: 0 is nobody, 100 is 1%, 10000 is every guild."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"channel_threads_guild_salt",
|
||||
"Guild rollout salt",
|
||||
&channel_threads.guild_salt,
|
||||
CHANNEL_THREADS_DEFAULT_GUILD_SALT,
|
||||
))
|
||||
@if channel_threads.enabled && !everyone {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the guild bucketing hash. Printable ASCII only. Changing it \
|
||||
reshuffles which guilds fall inside the percentage above."
|
||||
"Currently on for part of this instance. Saving applies the setting above to everyone."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_enabled_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1600000000000000001\n1600000000000000002",
|
||||
&enabled_guild_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.enabled_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These guilds are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_disabled_guild_ids",
|
||||
"Never-on Guild IDs",
|
||||
"1600000000000000003\n1600000000000000004",
|
||||
&disabled_guild_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.disabled_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage. This is the per-guild kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Users" }
|
||||
(number_field(
|
||||
"channel_threads_user_basis_points",
|
||||
"User rollout (basis points)",
|
||||
&channel_threads.user_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the experiment, in basis points. Set 10000 before enrolling any guild outside staff, so every member of that guild, moderators included, sees its threads."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"channel_threads_user_salt",
|
||||
"User rollout salt",
|
||||
&channel_threads.user_salt,
|
||||
CHANNEL_THREADS_DEFAULT_USER_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the user bucketing hash. Printable ASCII only. Changing it \
|
||||
reshuffles which users fall inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format as the guild lists. These users are targeted regardless \
|
||||
of the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"channel_threads_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
channel_threads.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, and it also applies to bots."
|
||||
}
|
||||
}
|
||||
(alert_warning("Excluded bots", html! {
|
||||
p class="text-sm" {
|
||||
"Excluded bots are blind to threads, including moderation bots."
|
||||
}
|
||||
}))
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save channel threads configuration"))
|
||||
(submit_button("Save"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -2414,49 +2275,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_threads_section_shows_both_dimensions_and_the_warnings() {
|
||||
let channel_threads = ChannelThreadsConfigResponse {
|
||||
fn channel_threads_section_is_a_single_everyone_toggle() {
|
||||
let everyone = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
config_version: 7,
|
||||
ever_enabled: true,
|
||||
guild_basis_points: 25,
|
||||
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
guild_basis_points: 10_000,
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string();
|
||||
assert!(markup.contains("action=update_channel_threads"));
|
||||
for name in [
|
||||
"channel_threads_enabled",
|
||||
"channel_threads_guild_basis_points",
|
||||
"channel_threads_guild_salt",
|
||||
"channel_threads_enabled_guild_ids",
|
||||
"channel_threads_disabled_guild_ids",
|
||||
"channel_threads_user_basis_points",
|
||||
"channel_threads_user_salt",
|
||||
"channel_threads_included_user_ids",
|
||||
"channel_threads_excluded_user_ids",
|
||||
] {
|
||||
assert!(markup.contains(&format!("name=\"{name}\"")), "{name}");
|
||||
}
|
||||
assert!(markup.contains("value=\"25\""));
|
||||
assert!(markup.contains("Config version 7"));
|
||||
assert!(markup.contains("Ever enabled"));
|
||||
assert!(markup.contains("bit 34-38 overwrite audit is clean"));
|
||||
assert!(markup.contains("Excluded bots are blind to threads, including moderation bots."));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
let on = channel_threads_section("/admin", "csrf", &everyone).into_string();
|
||||
assert!(on.contains("action=update_channel_threads"));
|
||||
assert!(on.contains("name=\"channel_threads_everyone\""));
|
||||
assert!(on.contains("checked"));
|
||||
assert!(!on.contains("basis_points"));
|
||||
assert!(!on.contains("part of this instance"));
|
||||
|
||||
let partial = ChannelThreadsConfigResponse {
|
||||
enabled: true,
|
||||
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
|
||||
user_basis_points: 10_000,
|
||||
..ChannelThreadsConfigResponse::default()
|
||||
};
|
||||
let partial = channel_threads_section("/admin", "csrf", &partial).into_string();
|
||||
assert!(!partial.contains("checked"));
|
||||
assert!(partial.contains("part of this instance"));
|
||||
|
||||
let off =
|
||||
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
|
||||
.into_string();
|
||||
assert!(off.contains("Inert"));
|
||||
assert!(!off.contains("Ever enabled"));
|
||||
assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT));
|
||||
assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT));
|
||||
assert!(!off.contains("checked"));
|
||||
assert!(!off.contains("part of this instance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -491,13 +491,14 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
|
||||
let app = setup().await;
|
||||
let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await;
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Channel threads"), "{body}");
|
||||
assert!(body.contains("Config version 3"), "{body}");
|
||||
assert!(body.contains("Ever enabled"), "{body}");
|
||||
assert!(body.contains("1600000000000000001"), "{body}");
|
||||
assert!(body.contains("1500000000000000009"), "{body}");
|
||||
assert!(body.contains("Threads and forums"), "{body}");
|
||||
assert!(body.contains("Available to everyone"), "{body}");
|
||||
assert!(
|
||||
body.contains("Excluded bots are blind to threads, including moderation bots."),
|
||||
body.contains(r#"name="channel_threads_everyone""#),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("channel_threads_guild_basis_points"),
|
||||
"{body}"
|
||||
);
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
@@ -509,17 +510,9 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
|
||||
("Cookie", cookie.as_str()),
|
||||
];
|
||||
|
||||
for (form, expected) in [
|
||||
(
|
||||
format!(
|
||||
"_csrf={csrf_token}&channel_threads_enabled=true&channel_threads_guild_basis_points=0&channel_threads_enabled_guild_ids=1600000000000000001&channel_threads_user_basis_points=10000"
|
||||
),
|
||||
"Instance config updated",
|
||||
),
|
||||
(
|
||||
format!("_csrf={csrf_token}&channel_threads_enabled_guild_ids=not-a-guild"),
|
||||
"Enabled guild IDs entry 1 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
for form in [
|
||||
format!("_csrf={csrf_token}&channel_threads_everyone=true"),
|
||||
format!("_csrf={csrf_token}"),
|
||||
] {
|
||||
let (status, response_headers, response_body) = post_form_with_headers(
|
||||
&app,
|
||||
@@ -533,7 +526,7 @@ async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_else(|| panic!("missing toast header\n{response_body}"));
|
||||
assert!(toast.contains(expected), "{toast}");
|
||||
assert!(toast.contains("Instance config updated"), "{toast}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -22,13 +22,301 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="display" content="standalone">
|
||||
<link rel="license" href="/assets/fonts-NOTICE.txt">
|
||||
<!--{{FLUXER_BOOTSTRAP}}-->
|
||||
<script>(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script>(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script>(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0||window.location.pathname.indexOf('/oauth2/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||
<link rel="expect" href="#fluxer-boot-end" blocking="render">
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var z=localStorage.getItem('fluxer:accessibility:zoom-preboot');if(z){var p=parseInt(z,10);if(p>=50&&p<=200){document.documentElement.style.setProperty('--custom-zoom',String(p))}}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(window.electron||typeof WebSocket!=='function'||typeof fetch!=='function')return;var P=location.pathname;if(P==='/'||P.indexOf('/migrate/')===0||P.indexOf('/oauth2/')===0)return;var LS=localStorage;if(LS.getItem('fluxer:gateway:preboot:session')!=='1')return;var h=JSON.parse(LS.getItem('fluxer:preboot:network')||'null');if(!h||h.v!==1||typeof h.a!=='string'||h.a!==LS.getItem('fluxer:auth:active-account-key'))return;var X={startedAt:Date.now(),discovery:null,gateway:null};window.__FLUXER_PREBOOT_NETWORK__=X;if(typeof h.d==='string'&&/^https?:\/\//.test(h.d)){var r=fetch(h.d,{method:'GET',headers:{Accept:'application/json'},redirect:'error',credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer'});r.catch(function(){});X.discovery={url:h.d,response:r}}if(typeof h.g==='string'&&/^wss?:\/\//.test(h.g)){var u=new URL(h.g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var ws=new WebSocket(u.toString());ws.binaryType='arraybuffer';var g={url:u.toString(),socket:ws,open:false,messages:[]};ws.onopen=function(){g.open=true};ws.onmessage=function(e){g.messages.push(e)};ws.onclose=ws.onerror=function(){if(X.gateway===g)X.gateway=null};X.gateway=g;setTimeout(function(){if(X.gateway===g){X.gateway=null;try{ws.close(1000,'Preboot gateway unclaimed')}catch(e){}}},30000)}}catch(e){}})()</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{
|
||||
var LS=localStorage,root=document.getElementById('root');if(!root)return;
|
||||
var P=location.pathname,now=Date.now(),UAM=/iPad|iPhone|iPod|Android/i.test(navigator.userAgent||''),MO=UAM||window.innerWidth<640;
|
||||
var rem=parseFloat(getComputedStyle(document.documentElement).fontSize)/16;if(!(rem>0))rem=1;
|
||||
function n(v,a,b){var x=typeof v==='number'&&isFinite(v)?v:a;return x<a?a:x>b?b:x}
|
||||
function AR(v,m){return Array.isArray(v)?v.slice(0,m):null}
|
||||
function RX(p){return Math.round(p/16*1e5)/1e5+'rem'}
|
||||
function JS(k){var v=LS.getItem(k);if(!v)return null;try{return JSON.parse(v)}catch(e){return null}}
|
||||
function FIT(){try{return window.matchMedia('(min-width: 1024px)').matches}catch(e){return window.innerWidth>=1024}}
|
||||
function PWA(){try{return window.matchMedia('(display-mode: standalone)').matches||navigator.standalone===true}catch(e){return false}}
|
||||
function DEF(p,acct){
|
||||
var ch0=p.indexOf('/channels/')===0,sp=p==='/bookmarks'||p==='/mentions';
|
||||
if(!ch0&&!sp)return null;if(p.indexOf('/channels/@discover')===0||p.indexOf('/channels/@premium')===0)return null;
|
||||
var seg=p.split('/'),dmr=p.indexOf('/channels/@me')===0,fav=p.indexOf('/channels/@favorites')===0,r3=ch0&&seg.length===3;
|
||||
var ck='chat',cb=0;
|
||||
if(sp||p==='/channels/@favorites')ck='other';else if(p==='/channels/@me'){ck='other';cb=1}else if(!dmr&&!fav&&/\/members$/.test(p))ck='other';
|
||||
var cid=ch0?(seg[3]||''):'';if(cid==='members')cid='';cid=cid.slice(0,32);
|
||||
var kind=dmr?2:0,dl=!window.electron&&!PWA(),ri=[],i;for(i=0;i<6;i++)ri.push([0,0]);
|
||||
return {v:4,t:now,p:p,a:acct,mo:MO?1:0,ua:UAM?1:0,fs:0,sw:0,sv:(dmr||sp)?'dm':'guild',ck:ck,cb:cb,rf:1,rv:1,ru:[],rti:(dmr||sp)?0:fav?1:-1,ro:0,ri:ri,rgi:-1,rbm:11|(dl?4:0),rbi:-1,rs:0,gb:0,ga:250,gn:0,gd:0,gm:0,sr:0,sam:MO?2:7,ss:1,cid:cid,ch:kind,ct:0,cnw:0,ctw:0,cda:-1,cma:-1,cfv:1,cst:0,cuv:0,ml:(!MO&&FIT()&&cid!==''&&kind===0)?1:0,co:3,com:2,cdv:0,pc:0,pg:16,pf:16,ps:16,pa:0,pt:56,pv:Math.min(20000,Math.max(0,Math.round(window.innerHeight/rem))),nr:[],vh:0,vc:0,mr:(MO&&(p==='/channels/@me'||r3))?1:0,ms:(MO&&(p==='/channels/@me'||r3))?1:0,mn:(MO&&(p==='/channels/@me'||p==='/channels/@favorites'||(r3&&!dmr&&!fav)))?1:0}}
|
||||
function HINT(){
|
||||
var acct=LS.getItem('fluxer:auth:active-account-key')||'',s=JS('fluxer:ui:shell-hint'),k,i;
|
||||
if(!s||s.v!==4||s.a!==acct||typeof s.t!=='number'||!(s.t>now-2592000000)||!(s.t<now+300000)||(s.sv!=='dm'&&s.sv!=='guild')||(s.ck!=='chat'&&s.ck!=='other')||typeof s.p!=='string'||(s.mo===1)!==(s.ua===1||window.innerWidth<640))s=null;
|
||||
var p=P;if(s&&p==='/channels/@me')p=s.p;
|
||||
if(s&&s.p===p){s.src='hint';return s}
|
||||
var d=DEF(p,acct);if(!d)return null;d.src='default';if(!s)return d;
|
||||
d.src='derived';
|
||||
var G=['sw','fs','rf','rv','ru','ro','ri','rbm','rs','vh','vc','nr','co','com','cdv','pc','pg','pf','ps','pa','pt','pv','cfv','cst','cuv','fa','fn'];
|
||||
for(i=0;i<G.length;i++){k=G[i];if(s[k]!==undefined)d[k]=s[k]}
|
||||
var sp=p==='/bookmarks'||p==='/mentions',dmr=p.indexOf('/channels/@me')===0,fav=p.indexOf('/channels/@favorites')===0;
|
||||
d.rti=(dmr||sp)?(d.rf===1?0:-1):fav?(d.rv===1?(d.rf===1?1:0):-1):-1;
|
||||
if(d.sv==='dm'&&s.sv==='dm'){d.sr=s.sr;d.sam=s.sam;d.ss=s.ss;if(s.sb!==undefined)d.sb=s.sb}
|
||||
var gid=p.split('/')[2]||'';
|
||||
if(d.sv==='guild'&&s.sv==='guild'&&gid!==''&&s.p.split('/')[2]===gid){G=['gb','ga','gn','gd','gm','gl','rgi'];for(i=0;i<G.length;i++){k=G[i];if(s[k]!==undefined)d[k]=s[k]}}
|
||||
if(d.ck==='chat'&&s.ck==='chat'&&d.cid!==''&&s.cid===d.cid){G=['ch','ct','cnw','ctw','cda','cma','ml','mem'];for(i=0;i<G.length;i++){k=G[i];if(s[k]!==undefined)d[k]=s[k]}}
|
||||
return d}
|
||||
function ACAT(p){
|
||||
if(p==='/register')return 'register';
|
||||
if(p==='/'||p==='/app'||p==='/login'||p.indexOf('/channels/')===0||p==='/bookmarks'||p==='/mentions'||p==='/notifications'||p==='/you')return 'login';
|
||||
var m=/^\/(invite|gift|theme)\/[^/]+(\/login)?\/?$/.exec(p);if(m)return m[1]+(m[2]?'-login':'');
|
||||
m=/^\/(forgot|reset|verify|authorize-ip|wasntme)(\/|$)/.exec(p);return m?m[1]:null}
|
||||
function AUTH(){
|
||||
var c=ACAT(P);if(c==null)return null;
|
||||
var a=JS('fluxer:ui:auth-shell-hint'),e=a&&a.v===1&&a.c&&typeof a.c==='object'?a.c[c]:null;
|
||||
if(e&&(e.mo===1)===MO&&(e.k==='card'||e.k==='full')&&typeof e.h==='number'&&e.h>0)return {c:c,k:e.k,h:n(e.h,48,4000),w:n(e.w,0,1200),r:-1,src:'hint'};
|
||||
if(c==='login'&&!MO&&document.documentElement.classList.contains('platform-native'))return {c:c,k:'full',h:0,w:0,r:-1,src:'default'};
|
||||
var host=location.hostname||'',pk=c==='login'&&typeof window.PublicKeyCredential==='function'&&host.split('.').length>=2&&!/^[\d.]+$/.test(host)&&host.indexOf(':')<0;
|
||||
if(c==='login')return {c:c,k:'card',h:(MO?132:214)+(pk?46:0),w:0,r:pk?3:2,src:'default'};
|
||||
if(c==='register')return {c:c,k:'card',h:MO?592:674,w:0,r:-1,src:'default'};
|
||||
return {c:c,k:'card',h:MO?320:402,w:0,r:-1,src:'default'}}
|
||||
function WW(){
|
||||
var t={'ar':'مرحبا','bg':'Добре дошли','cs':'Vítejte','da':'Velkommen','de':'Willkommen','el':'Καλώς ήρθατε','en-GB':'Welcome','en-US':'Welcome','es-ES':'Bienvenido','es-419':'Bienvenido','fi':'Tervetuloa','fr':'Bienvenue','he':'ברוכים הבאים','hi':'स्वागत है','hr':'Dobrodošli','hu':'Üdvözöljük','id':'Selamat datang','it':'Benvenuto','ja':'ようこそ','ko':'환영합니다','lt':'Sveiki','nl':'Welkom','no':'Velkommen','pl':'Witaj','pt-BR':'Boas-vindas','ro':'Bine ați venit','ru':'Добро пожаловать','sv-SE':'Välkommen','th':'ยินดีต้อนรับ','tr':'Hoş geldiniz','uk':'Ласкаво просимо','vi':'Chào mừng','zh-CN':'欢迎','zh-TW':'歡迎'};
|
||||
var l=[LS.getItem('locale')].concat(navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language]),i,c,k;
|
||||
for(i=0;i<l.length;i++){c=String(l[i]||'').replace(/"/g,'').replace('_','-').toLowerCase();if(!c)continue;
|
||||
for(k in t)if(k.toLowerCase()===c)return t[k];c=c.split('-')[0];for(k in t)if(k.split('-')[0].toLowerCase()===c)return t[k]}
|
||||
return t['en-US']}
|
||||
function AUTHM(au){
|
||||
var BC='fluxer-boot-',E='</div>',B=function(s,c){return '<div class="'+BC+'block'+(c||'')+'" style="'+s+'"></div>'},i,body='';
|
||||
var head='<div id="fluxer-boot-shell" class="'+BC+'auth" aria-hidden="true" data-auth="'+au.k+'" data-mobile="'+(MO?1:0)+'" data-hint="'+au.src+'" data-category="'+au.c+'" data-height="'+au.h+'">';
|
||||
if(au.k==='full'){
|
||||
return head+'<div class="'+BC+'auth-full"><div class="'+BC+'auth-word"><div class="'+BC+'auth-wtext">'+WW()+E+E+'<div class="'+BC+'auth-foot">'+B('width:10rem;height:2.25rem;',' '+BC+'rm')+E+E+E}
|
||||
var pad=MO?0:Math.max(32*rem,Math.min(0.04*window.innerWidth,44*rem))*2/rem+2,rows=au.r>0?au.r:Math.max(1,Math.min(12,Math.floor((au.h-pad-40)/46)));head=head.slice(0,-1)+' data-rows="'+rows+'">';
|
||||
body+='<div class="'+BC+'auth-title">'+B('width:'+(au.c==='register'?'11rem':'8.5rem')+';height:1.125rem;',' '+BC+'rp '+BC+'s')+E+'<div class="'+BC+'auth-list">';
|
||||
for(i=0;i<rows;i++)body+=B('width:100%;height:2.25rem;',' '+BC+'rm'+(i===0?' '+BC+'auth-primary':' '+BC+'m'));
|
||||
body+=E;
|
||||
var hs='height:'+RX(au.h)+';';
|
||||
if(MO)return head+'<div class="'+BC+'auth-mobile"><div class="'+BC+'auth-mmain" style="'+hs+'">'+body+E+E+E;
|
||||
return head+'<div class="'+BC+'auth-center"><div class="'+BC+'auth-cont"><div class="'+BC+'auth-card" style="'+hs+(au.w>0?'max-width:'+RX(au.w)+';':'')+'"><div class="'+BC+'auth-form">'+body+E+E+E+E+E}
|
||||
var h=null,au=null;
|
||||
var BS=document.documentElement.getAttribute('data-boot-session');
|
||||
var SM=LS.getItem('fluxer:gateway:preboot:session');
|
||||
function SV(k){var v=LS.getItem(k);return !!v&&v!=='undefined'&&v!=='null'}
|
||||
if(BS!=='0'&&(BS==='1'||SM==='1'||(SM===null&&SV('token')&&SV('userId'))))h=HINT();else au=AUTH();
|
||||
if(h==null&&au==null)return;
|
||||
function APP(h){
|
||||
var mo=h.mo===1;
|
||||
function SEED(k){var s=2166136261,i;for(i=0;i<k.length;i++){s^=k.charCodeAt(i);s=Math.imul(s,16777619)}s=s>>>0;var t=s||2654435769;return function(){t^=t<<13;t^=t>>>17;t^=t<<5;return(t>>>0)/4294967296}}
|
||||
var E='</div>',BC='fluxer-boot-';
|
||||
function B(s,c){return '<div class="'+BC+'block'+(c||'')+'" style="'+s+'"></div>'}
|
||||
function V(c,i,s){return '<div class="'+BC+c+'"'+(s?' style="'+s+'"':'')+'>'+i+E}
|
||||
function PZ(w,g){return 'width:'+w+';height:'+g+';'}
|
||||
var S=' '+BC+'s',M=' '+BC+'m',R0=' '+BC+'r0',RS=' '+BC+'rs',RM=' '+BC+'rm',RL=' '+BC+'rl',RP=' '+BC+'rp',RC=' '+BC+'rc',CI=' '+BC+'circle';
|
||||
var GI={
|
||||
c:'M232,128A104,104,0,0,1,79.12,219.82L45.07,231.17a16,16,0,0,1-20.24-20.24l11.35-34.05A104,104,0,1,1,232,128Z',
|
||||
s:'M234.29,114.85l-45,38.83L203,211.75a16.4,16.4,0,0,1-24.5,17.82L128,198.49,77.47,229.57A16.4,16.4,0,0,1,53,211.75l13.76-58.07-45-38.83A16.46,16.46,0,0,1,31.08,86l59-4.76,22.76-55.08a16.36,16.36,0,0,1,30.27,0l22.75,55.08,59,4.76a16.46,16.46,0,0,1,9.37,28.86Z',
|
||||
o:'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm51.58,57.79-32,64a4.08,4.08,0,0,1-1.79,1.79l-64,32a4,4,0,0,1-5.37-5.37l32-64a4.08,4.08,0,0,1,1.79-1.79l64-32A4,4,0,0,1,179.58,81.79Z',
|
||||
p:'M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z',
|
||||
d:'M228,144v64a12,12,0,0,1-12,12H40a12,12,0,0,1-12-12V144a12,12,0,0,1,24,0v52H204V144a12,12,0,0,1,24,0Zm-108.49,8.49a12,12,0,0,0,17,0l40-40a12,12,0,0,0-17-17L140,115V32a12,12,0,0,0-24,0v83L96.49,95.51a12,12,0,0,0-17,17Z',
|
||||
q:'M196,96c0,29.47-24.21,54.05-56,59.06V156a12,12,0,0,1-24,0V144a12,12,0,0,1,12-12c24.26,0,44-16.15,44-36s-19.74-36-44-36S84,76.15,84,96a12,12,0,0,1-24,0c0-33.08,30.5-60,68-60S196,62.92,196,96Zm-68,92a20,20,0,1,0,20,20A20,20,0,0,0,128,188Z'};
|
||||
function SVG(k,z){return '<svg class="'+BC+'glyph-'+z+'" viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="'+GI[k]+'"/></svg>'}
|
||||
var NT=[['#4e5058','#ffffff'],['#4285f4','#ffffff'],['#1d4ed8','#ffffff'],['#9a3412','#ffffff'],['#166534','#ffffff'],['var(--brand-primary)','var(--text-on-brand-primary)'],['var(--status-danger)','var(--text-on-brand-primary)'],['#ea580c','#ffffff'],['#f97316','#ffffff'],['#4338ca','#ffffff'],['#15803d','#ffffff'],['#b00000','#ffffff'],['#17231d','#72e6a2'],['#5865f2','#ffffff'],['#b45309','#ffffff']];
|
||||
var rail=!mo||h.mr===1,ua=!mo&&h.fs!==1,bn=mo&&h.mn===1,sd=!mo||h.ms===1,cc=!(mo&&h.ms===1),gv=h.sv==='guild';
|
||||
var canFit=FIT();
|
||||
var sw=n(h.sw,0,4096),vh=n(h.vh,0,512),vp=vh+'px';
|
||||
var sty='';if(!mo&&sw>=200&&sw<=480)sty+='--layout-sidebar-width:'+RX(sw)+';';if(ua)sty+='--layout-voice-connection-height:'+vp+';';
|
||||
var i,j,m='';
|
||||
function IND(sel){return V('indicator','<div class="'+BC+'indicator-bar" style="height:'+(sel?'2.5rem':'0.5rem')+';opacity:1"></div>')}
|
||||
function selAttr(k){return k?' data-selected="1"':''}
|
||||
if(rail){
|
||||
var rf=h.rf===1,rv=h.rv===1,ru=AR(h.ru,16)||[],idm=ru.length,rti=n(h.rti,-1,99);
|
||||
var ro=h.ro===1,ri=AR(h.ri,24)||[],rbm=n(h.rbm,0,15);
|
||||
var bD=(rbm&1)!==0,bA=(rbm&2)!==0,bW=(rbm&4)!==0,bH=(rbm&8)!==0;
|
||||
var rgi=n(h.rgi,-1,99),rbi=n(h.rbi,-1,99);
|
||||
var topRows=(rf?1:0)+(rv?1:0)+idm,botRows=(bD?1:0)+(bA?1:0)+(bW?1:0)+(bH?1:0);
|
||||
var hasItems=ro||ri.length>0,hasBottom=bD||bA||bW||bH;
|
||||
var gapAfterTop=rf&&!rv&&idm===0,flush=ro&&ri.length===0&&hasBottom;
|
||||
var gapAfterGuilds=bH?false:(bD||bA||bW)?true:ri.length>0;
|
||||
var vrows=topRows+(ro?1:0)+botRows,ci=0;
|
||||
for(i=0;i<ri.length;i++){vrows+=(ri[i]&&ri[i][0]===2)?n(ri[i][2],0,23)+1:1}
|
||||
var rs=n(h.rs,0,20000)*rem,cap=(vrows*3.375+0.8125)*rem-document.documentElement.clientHeight;
|
||||
rs=rs>0?Math.min(rs,Math.max(0,cap)):0;
|
||||
function ITEM(sel,inner){return '<div class="'+BC+'item"'+selAttr(sel)+'>'+(sel?IND(true):'')+inner+E}
|
||||
var top='';
|
||||
if(rf)top+=ITEM(rti===0,V('fluxer-icon',SVG('c','lg')));
|
||||
if(rv)top+=ITEM(rti===(rf?1:0),V('fluxer-icon',SVG('s','lg')));
|
||||
for(i=0;i<idm;i++){var di=(rf?1:0)+(rv?1:0)+i,dsel=rti===di,dun=ru[i]===1;
|
||||
top+='<div class="'+BC+'item"'+selAttr(dsel)+'>'+((dsel||dun)?IND(dsel):'')+B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+CI)+E}
|
||||
var items='';
|
||||
if(ro)items+=V('slot '+BC+'slot-outage',B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+M+CI));
|
||||
for(i=0;i<ri.length;i++){var it=ri[i]||[],kd=n(it[0],0,2),ind=n(it[1],0,2),sel=i===rgi,show=sel||ind!==0;
|
||||
if(kd===0){items+='<div class="'+BC+'slot"'+selAttr(sel)+'>'+(show?IND(sel):'')+B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+CI)+E}
|
||||
else if(kd===1){var cn=n(it[2],0,4),mini='';
|
||||
if(it[3]===1)mini=B(PZ('1.5rem','1.5rem'),RM);
|
||||
else{for(j=0;j<cn;j++)mini+=B(PZ('100%','100%'),R0+' '+BC+'folder-mini');mini=V('folder-grid',mini)}
|
||||
items+='<div class="'+BC+'slot '+BC+'slot-folder"'+selAttr(sel)+'>'+(show?IND(sel):'')+V('folder',B(PZ('var(--guild-list-item-box-size)','var(--guild-list-item-box-size)'),R0+M+' '+BC+'folder-bg')+mini)+E}
|
||||
else{var cn2=n(it[2],0,23),sc=n(it[3],-1,23),kids='';
|
||||
for(j=0;j<cn2;j++){var ki=n(it[4+j],0,2),ks=j===sc;kids+='<div class="'+BC+'folder-guild"'+selAttr(ks)+'>'+((ks||ki!==0)?IND(ks):'')+B(PZ('var(--guild-icon-size)','var(--guild-icon-size)'),RC+CI)+E}
|
||||
items+=V('folder-slot',V('folder-open',B('width:calc(var(--guild-folder-expanded-surface-size) + 0.25rem);height:0.75rem;',R0+M+' '+BC+'folder-open-bg')+V('folder-head',B(PZ('var(--guild-list-item-box-size)','var(--guild-list-item-box-size)'),R0+' '+BC+'folder-head-surface'))+V('folder-guilds',kids)))}}
|
||||
function ACT(k,sel){return '<div class="'+BC+'item"'+selAttr(sel)+'>'+V('action-icon',SVG(k,'sm'))+E}
|
||||
var bot='';
|
||||
if(hasItems)bot+='<div class="'+BC+'rail-items'+(flush?' '+BC+'rail-items-flush':'')+'">'+items+E;
|
||||
if(bD)bot+=ACT('o',rbi===0);if(bA)bot+=ACT('p',false);if(bW)bot+=ACT('d',false);if(bH)bot+=ACT('q',false);
|
||||
m+=V('rail',V('rail-scroller',V('rail-content',
|
||||
'<div class="'+BC+'rail-section'+(gapAfterTop?' '+BC+'rail-section-gap':'')+'">'+top+E+
|
||||
'<div class="'+BC+'rail-divider-slot"'+(hasItems?' data-extended="1"':'')+'>'+V('rail-divider','')+E+
|
||||
'<div class="'+BC+'rail-section'+(gapAfterGuilds?' '+BC+'rail-section-gap':'')+'">'+bot+E,
|
||||
rs>0?'margin-top:-'+rs+'px':null)))}
|
||||
var body='';
|
||||
if(sd){
|
||||
if(gv){
|
||||
var gb=n(h.gb,0,2),ab='aspect-ratio:'+n(h.ga,250,8000)/1000,gn=n(h.gn,0,4096),gd=h.gd===1;
|
||||
var hdr=(gd?B(PZ('1.25rem','1.25rem'),RS+M):'')+B(PZ(gn>0?RX(gn):'55%','0.75rem'),RP+S)+B(PZ(mo?'1.5rem':'1rem',mo?'1.5rem':'1rem'),RS+M+' '+BC+'gsb-caret');
|
||||
var frame='<div class="'+BC+'gsb-frame"'+(gb===1?' data-banner="1" style="'+ab+'"':'')+'>'+(gb===1?B('width:100%;height:100%;',R0+M+' '+BC+'gsb-banner'):'')+V('gsb-header',hdr)+E;
|
||||
var gl=AR(h.gl,16),cats=[],rnd=SEED('guild-sidebar-skeleton-channels');
|
||||
if(gl==null){var FB=[3,5,4,6];for(i=0;i<4;i++){var chs=[],cw=(28+rnd()*24)+'%';for(j=0;j<FB[i];j++)chs.push((34+rnd()*40)+'%');cats.push([true,cw,chs])}}
|
||||
else{for(i=0;i<gl.length;i++){var g=gl[i]||[],gw=(28+rnd()*24)+'%',gc=AR(g[2],64)||[],chs2=[];
|
||||
for(j=0;j<gc.length;j++){var fw=(34+rnd()*40)+'%',mw=n(gc[j],0,4096);chs2.push(mw>0?RX(mw):fw)}
|
||||
var cmw=n(g[1],0,4096);cats.push([g[0]===1,cmw>0?RX(cmw):gw,chs2])}}
|
||||
var nav='';
|
||||
if(gb===2)nav+=V('detached-banner',B('width:100%;height:100%;',R0+M),ab);
|
||||
nav+=V('top-spacer','');
|
||||
if(h.gm===1)nav+=V('members-section',V('grow '+BC+'grow-ch '+BC+'grow-mem',B(PZ('1.25rem','1.25rem'),RS+M+' '+BC+'grow-icon')+B(PZ('4rem','0.75rem'),RP)))+V('members-sep','');
|
||||
var groups='';
|
||||
for(i=0;i<cats.length;i++){var ct=cats[i],grp='';
|
||||
if(ct[0])grp+=V('grow '+BC+'grow-cat',B(PZ(ct[1],'0.625rem'),RP+M)+B(PZ('0.75rem','0.75rem'),RS+M+' '+BC+'grow-caret'));
|
||||
for(j=0;j<ct[2].length;j++)grp+=V('grow '+BC+'grow-ch',B(PZ('1.25rem','1.25rem'),RS+M+' '+BC+'grow-icon')+B(PZ(ct[2][j],'0.75rem'),RP));
|
||||
groups+=V('group',grp)}
|
||||
nav+=V('groups',groups);
|
||||
if(cats.length>0)nav+=V('drop-zone','');
|
||||
body+=V('gsb',frame+V('gsb-content',V('nav',nav)+V('sb-bottom-spacer','')))}
|
||||
else{
|
||||
var sr=n(h.sr,0,40),sb=AR(h.sb,40),sam=n(h.sam,0,7),drnd=SEED('dm-sidebar-skeleton-channels'),chans=[];
|
||||
for(i=0;i<sr;i++){var nw=(42+drnd()*42)+'%',hs=drnd()<0.45;if(sb!=null)hs=sb[i]===1;chans.push([nw,hs?(32+drnd()*38)+'%':null])}
|
||||
var acts=[];if((sam&1)!==0)acts.push('4.5rem');if((sam&2)!==0)acts.push('7.75rem');if((sam&4)!==0)acts.push('5.75rem');
|
||||
function AROW(w){return V(mo?'dmrow':'drow',V('drow-content',B(PZ(mo?'2.5rem':'2rem',mo?'2.5rem':'2rem'),RC+M)+B(PZ(w,'0.75rem'),RP)))}
|
||||
function CROW(c){return V(mo?'dmrow':'drow',V('drow-content',B(PZ(mo?'2.5rem':'2rem',mo?'2.5rem':'2rem'),RC)+V('chinfo',V('chname',B(PZ(c[0],'0.75rem'),RP))+(c[1]?V('chsub',B(PZ(c[1],'0.5rem'),RP+M)):''))+(mo?B(PZ('1.5rem','0.5rem'),RP+M):'')))}
|
||||
var rows='';
|
||||
if(mo){
|
||||
for(i=0;i<acts.length;i++)rows+=AROW(acts[i]);
|
||||
for(i=0;i<chans.length;i++)rows+=CROW(chans[i]);
|
||||
rows+=V('spacer','');
|
||||
body+=V('dsb',V('dsb-mheader',B(PZ('5.5rem','0.875rem'),RP+S)+V('dsb-mactions',B(PZ('2rem','2rem'),RC+M)+B(PZ('7.5rem','2.25rem'),RP+M)))+V('scroll',V('dsb-mcontent',rows))+B(PZ('3.5rem','3.5rem'),RC+S+' '+BC+'fab'))}
|
||||
else{
|
||||
rows+=V('spacer','');
|
||||
for(i=0;i<acts.length;i++)rows+=AROW(acts[i]);
|
||||
if(h.ss===1)rows+=V('dsb-sep','')+V('dsb-section',B(PZ('6rem','0.625rem'),RP+M)+B(PZ('1rem','1rem'),RS+M));
|
||||
var cl='';for(i=0;i<chans.length;i++)cl+=CROW(chans[i]);
|
||||
rows+=V('chlist',cl)+V('sb-bottom-spacer','');
|
||||
body+=V('dsb',V('dsb-header',B(PZ('100%','1.75rem'),RM+M))+V('scroll',V('dsb-content',rows)))}}}
|
||||
var content='';
|
||||
if(cc&&h.ck!=='chat'&&h.cb===1){
|
||||
var FT=['4.5rem','2.75rem','5rem','6.25rem'],tabs='';
|
||||
for(i=0;i<4;i++)tabs+=B(PZ(FT[i],'2rem'),RM);
|
||||
var fl=(mo?'':V('fr-title',B(PZ('1.5rem','1.5rem'),RS+S)+B(PZ('5.5rem','0.875rem'),RP+S))+V('fr-div',''))+V('fr-tabs',V('fr-tabsi',tabs));
|
||||
var fh=V('chat-header',V('identity',B(PZ('2rem','2rem'),RC+M+' '+BC+'identity-back'+(mo?'':' '+BC+'identity-back-desktop'))+V('fr-left',fl))+V('header-actions',mo?'':B(PZ('2rem','2rem'),RC+M)));
|
||||
var frn=Math.min(24,Math.max(1,Math.ceil(window.innerHeight/60))),frr=SEED('friends-skeleton-rows'),frows='';
|
||||
for(i=0;i<frn;i++)frows+=V('fr-row',V('fr-rinfo',B(PZ('2.25rem','2.25rem'),RC)+V('fr-rdet',V('fr-rname',B(PZ((28+frr()*34)+'%','0.75rem'),RP+S))+V('fr-rsub',B(PZ((18+frr()*26)+'%','0.625rem'),RP+M))))+V('fr-ract',B(PZ('2.25rem','2.25rem'),RC+M)+B(PZ('2.25rem','2.25rem'),RC+M)));
|
||||
var fa=h.fa!==0,fn=Array.isArray(h.fn)?h.fn.slice(0,24):[],anr=SEED('friends-skeleton-active-now'),side='';
|
||||
if(fa){var scn='';
|
||||
for(i=0;i<fn.length;i++){var fc=fn[i],pn=Array.isArray(fc)?n(fc[0],0,99):0,st=Array.isArray(fc)&&fc[1]===1,pcol=Math.min(pn,5)+(pn>5?1:0);
|
||||
scn+=V('fr-card',V('fr-cg',V('fr-cst',B(PZ(st?'4.75rem':'3.75rem','0.625rem'),RP+S))+V('fr-cctx',B(PZ((6+anr()*5)+'rem','0.625rem'),RP+M)))+(pn>0?V('fr-cp',B(PZ(RX(21+Math.max(0,pcol-1)*13),'1.25rem'),RP+M)):'')+V('fr-ca',B(PZ('1rem','1rem'),RS+M)+B(PZ('4.75rem','0.625rem'),RP+M)))}
|
||||
side=V('fr-side',V('fr-shd',B(PZ('6.5rem','1.125rem'),RP+S))+(fn.length?V('fr-scn',scn):V('fr-empty',B(PZ('3rem','3rem'),RM+M)+V('fr-etitle',B(PZ('9rem','0.8125rem'),RP+S))+V('fr-edesc',V('fr-eline',B(PZ('100%','0.6875rem'),RP+M))+V('fr-eline',B(PZ('68%','0.6875rem'),RP+M))))))}
|
||||
content=V('friends',V('fr-main',fh+V('fr-body',V('fr-srch',B(PZ('100%','2.75rem'),RL+M))+V('fr-tbody',V('fr-list',V('fr-sect',B(PZ('7rem','0.625rem'),RP+M))+V('fr-rows',frows)))))+side)}
|
||||
if(cc&&h.ck==='chat'){
|
||||
var ch=n(h.ch,0,4),isDM=ch===2,isGDM=ch===3,isNotes=ch===4;
|
||||
var cnw=n(h.cnw,0,4096),ctw=n(h.ctw,0,4096),topic=h.ct===1;
|
||||
var fav=h.cfv===1?1:0,dflt=isNotes?[0,0,false]:isDM?[4+fav,2+fav,true]:isGDM?[5+fav,2+fav,true]:[3+fav,1+fav,true];
|
||||
var lead=n(h.cda,-1,32),mob=n(h.cma,-1,32);
|
||||
if(lead<0)lead=dflt[0];if(mob<0)mob=dflt[1];
|
||||
var persist=(h.cst===1?1:0)+(h.cuv===1?1:0);
|
||||
var idn=(isDM||isGDM)?B(PZ('2rem','2rem'),RC):B(PZ('var(--channel-header-icon-size)','var(--channel-header-icon-size)'),RS+M);
|
||||
var tail='';
|
||||
if(mo)tail=B(PZ('var(--channel-header-caret-size)','var(--channel-header-caret-size)'),RS+M+' '+BC+'identity-caret');
|
||||
else if(topic)tail=B(PZ('0.25rem','0.25rem'),RC+M+' '+BC+'topic-divider')+B(PZ(ctw>0?RX(ctw):'18rem','0.6875rem'),RP+M);
|
||||
var ident=V('identity-back'+(mo?'':' '+BC+'identity-back-desktop'),B(PZ('var(--channel-header-back-icon-size)','var(--channel-header-back-icon-size)'),RS+M))+idn+B(PZ(cnw>0?RX(cnw):'7.5rem','0.875rem'),RP+S+' '+BC+'identity-name'+((!mo&&isGDM)?' '+BC+'identity-name-gdm':''))+tail;
|
||||
var acts2='';
|
||||
if(mo){for(i=0;i<mob;i++)acts2+=B(PZ('var(--channel-header-action-size-mobile)','var(--channel-header-action-size-mobile)'),RC+M)}
|
||||
else{
|
||||
function HSLOT(){return V('header-action',B(PZ('var(--channel-header-action-icon-size)','var(--channel-header-action-icon-size)'),RS+M))}
|
||||
for(i=0;i<lead;i++)acts2+=HSLOT();
|
||||
if(dflt[2])acts2+=V('header-search',B(PZ('var(--message-search-bar-icon-size)','var(--message-search-bar-icon-size)'),RS+M+' '+BC+'header-search-icon')+B(PZ('7rem','0.6875rem'),RP+M));
|
||||
for(i=0;i<persist;i++)acts2+=HSLOT();
|
||||
acts2+=HSLOT()}
|
||||
var chead=V('chat-header',V('identity',ident)+V('header-actions',acts2));
|
||||
var cbody='';
|
||||
{
|
||||
var pc=h.pc===1,pg=n(h.pg,0,256),pf=n(h.pf,8,64),ps=n(h.ps,0,256),pa=h.pa===1,pt=n(h.pt,0,256),pv=n(h.pv,0,20000);
|
||||
var seed=typeof h.cid==='string'&&h.cid?h.cid:'message-list-placeholder';
|
||||
var mr2=SEED(seed+'|'+(pc?'1':'0')+'|'+ps);
|
||||
var avg=pc?26*2.5:48+26*1.5,rws=Math.ceil(Math.max(pv,400)/avg);
|
||||
var gc2=Math.min(30,Math.max(10,Math.ceil(1.05*rws)));if(!pc)gc2=Math.round(0.87*gc2);
|
||||
var att=Math.min(gc2,Math.max(1,Math.round(gc2/30*8)));
|
||||
var gs=[],th=28;
|
||||
for(i=0;i<gc2;i++){var lc=Math.floor(mr2()*4)+1,lw=[];
|
||||
for(j=0;j<lc;j++)lw.push(75+mr2()*18);
|
||||
var gh=pc?26*lc:48+26*(lc-1);
|
||||
gs.push({w:lw,u:48+mr2()*36,s:8+mr2()*12,a:null,h:gh});
|
||||
if(i>0)th+=ps;th+=gh}
|
||||
var avail=[];for(i=0;i<gs.length;i++)avail.push(i);
|
||||
for(i=0;i<att&&avail.length>0;i++){var gx=avail.splice(Math.floor(mr2()*avail.length),1)[0];
|
||||
var aw=Math.floor(mr2()*261)+140,ah=Math.floor(mr2()*221)+100;
|
||||
gs[gx].a=[aw,ah];gs[gx].h+=ah+8;th+=ah+8}
|
||||
var fill='';
|
||||
for(i=0;i<gs.length;i++){var G2=gs[i],mb=i===gs.length-1?'0':RX(ps),inner='';
|
||||
if(pc){var tw=Math.min(78,55+G2.s)+'%',uw=Math.min(32,G2.u*0.45)+'%';
|
||||
var uline=B(PZ(uw,'0.75rem'),RP+S+' '+BC+'cuser');
|
||||
var pre=pa?V('cauthor',B(PZ('var(--message-avatar-size-compact)','var(--message-avatar-size-compact)'),RC+' '+BC+'cavatar')+uline):uline;
|
||||
var cm2='';
|
||||
for(j=0;j<G2.w.length;j++)cm2+=V('crow',V('cts',B(PZ(tw,'0.625rem'),RP+M))+V('cbody',(j===0?pre:'')+B(PZ(G2.w[j]+'%','0.75rem'),RP+' '+BC+'cline')));
|
||||
inner=V('cmsgs',cm2)+(G2.a?V('cattach',B(PZ(RX(G2.a[0]),RX(G2.a[1])),RL+M)):'')}
|
||||
else{var cont='';
|
||||
for(j=1;j<G2.w.length;j++)cont+=V('mcont',B(PZ(G2.w[j]+'%','0.75rem'),RP+' '+BC+'mcont-line'));
|
||||
inner=V('mrow',B(PZ('var(--message-avatar-size)','var(--message-avatar-size)'),RC+' '+BC+'mavatar')+V('mbody',V('mhead',B(PZ(G2.u+'%','0.75rem'),RP+S)+B(PZ(G2.s+'%','0.625rem'),RP+M))+V('mlead',B(PZ(G2.w[0]+'%','0.75rem'),RP))))+cont+(G2.a?V('mattach',B(PZ(RX(G2.a[0]),RX(G2.a[1])),RL+M)):'')}
|
||||
fill+=V('mgroup',inner,'margin-bottom:'+mb)}
|
||||
var mlist=V('messages','<div class="'+BC+'fill'+(pc?' '+BC+'fill-compact':'')+'" style="height:'+RX(th)+'">'+fill+E);
|
||||
var cnt=mo?n(h.com,0,8):n(h.co,0,8),dvi=(!mo&&h.cdv===1)?cnt-1:-1,cbtn='';
|
||||
for(i=0;i<cnt;i++){if(i===dvi)cbtn+=V('composer-divider','');
|
||||
cbtn+=V('composer-action',B(PZ('var(--textarea-button-icon-size)','var(--textarea-button-icon-size)'),RM+M))}
|
||||
var comp='<div class="'+BC+'composer'+(mo?' '+BC+'composer-mobile':' '+BC+'composer-row')+'">'+V('composer-section',V('composer-grid',
|
||||
V('composer-upload',V('composer-action',B(PZ('var(--textarea-button-icon-size)','var(--textarea-button-icon-size)'),RM+M)))+
|
||||
V('composer-input',V('composer-line',B(PZ('11rem','0.875rem'),RP+M)))+
|
||||
V('composer-buttons',cbtn)))+E;
|
||||
var showML=h.ml===1&&!mo&&canFit;
|
||||
cbody=(showML?V('member-divider',''):'')+V('chat-area',V('chat-column',mlist+comp));
|
||||
if(showML){
|
||||
var mem=AR(h.mem,8),gdm=isGDM,defs=gdm?[['4.5rem',5],['4rem',5]]:[['5rem',7],['6.25rem',9],['4.5rem',8]],reqs=[];
|
||||
if(mem==null)reqs=defs;
|
||||
else{for(i=0;i<mem.length;i++){var mg=mem[i]||[],hw=n(mg[1],0,4096);reqs.push([hw>0?RX(hw):defs[i%defs.length][0],n(mg[0],0,50),mg.slice(2)])}}
|
||||
var mlh='',ridx=0;
|
||||
for(i=0;i<reqs.length;i++){var rq=reqs[i],rr='';
|
||||
for(j=0;j<rq[1];j++){var rr2=SEED('member-list-skeleton|'+(gdm?'group-dm':'guild')+'|'+ridx),nw2=40+rr2()*40,sw2=30+rr2()*50;ridx++;
|
||||
var subv=gdm?(rq[2]&&rq[2][j]===1):true;
|
||||
rr+=V('mlrow'+(gdm?' '+BC+'mlrow-gdm':''),V('mlrc',B(PZ('2rem','2rem'),RC)+V('mlinfo',V('mlname',B(PZ(nw2+'%','0.625rem'),RP))+(subv?V('mlstatus',B(PZ(sw2+'%','0.5rem'),RP+M)):''))))}
|
||||
mlh+=V('mlgroup'+(gdm?' '+BC+'mlgroup-gdm':''),V('mlhead',B(PZ(rq[0],'0.625rem'),RP+M))+V('mlrows'+(gdm?' '+BC+'mlrows-gdm':''),rr))}
|
||||
cbody+=V('member-list'+(gdm?' '+BC+'member-list-gdm':''),V('member-scroller',V('mlgroups',mlh,'--member-list-item-height:2.75rem;--member-list-item-height-group-dm:2.5rem;--member-list-group-header-height:2rem')))}
|
||||
content=V('chat',chead+V('chat-body',cbody),'--chat-horizontal-padding:'+RX(pg)+';--font-size:'+RX(pf)+';--message-group-spacing:'+RX(ps)+';--message-compact-timestamp-width:'+RX(pt))}}
|
||||
var nr=AR(h.nr,3)||[],nag='';
|
||||
for(i=0;i<nr.length;i++){var nrow=nr[i]||[],tn=NT[n(nrow[0],0,14)],hact=nrow[1]===1,dis=nrow[2]===1;
|
||||
nag+='<div class="'+BC+'nagbar"'+(dis?' data-dismissible="1"':'')+(hact?' data-actions="1"':'')+' style="background-color:'+tn[0]+';color:'+tn[1]+'">'+
|
||||
B(PZ(mo?'min(12rem, 72%)':'min(18rem, 60%)','0.75rem'),RP+M+' '+BC+'tinted')+
|
||||
((mo&&hact)?B(PZ('6rem','2rem'),RM+M+' '+BC+'tinted'):'')+E}
|
||||
m+=V('content',V('frame',(nag?V('nagbars',nag):'')+V('frame-body',(mo?'':V('sidebar-divider',''))+V('frame-inner',V('main',(mo?'':body)+V('column',mo?(body||content):content))))));
|
||||
if(ua)m+=V('user-area',V('user-panel',(vh>0?V('voice-panel','','height:'+vp):'')+V('user-row',V('user-identity',B(PZ('2rem','2rem'),RC+S)+V('user-text',V('user-name',B(PZ('6.5rem','0.75rem'),RP))+V('user-status',B(PZ('4.25rem','0.5rem'),RP+M))))+V('user-controls',B(PZ('2rem','2rem'),RM+M)+B(PZ('2rem','2rem'),RM+M)+B(PZ('2rem','2rem'),RM+M)))));
|
||||
if(bn){var nav2=[['b','2.25rem']];if(h.vc===1)nav2.push(['b','2.5rem']);nav2.push(['b','4rem'],['a','1.5rem']);var nvi='';
|
||||
for(i=0;i<nav2.length;i++)nvi+=V('nav-item',B(PZ('1.5rem','1.5rem'),nav2[i][0]==='a'?RC:RS)+V('nav-label',B(PZ(nav2[i][1],'0.5rem'),RP+M)));
|
||||
m+=V('bottom-nav',nvi)}
|
||||
return '<div id="fluxer-boot-shell" aria-hidden="true" data-mobile="'+(mo?1:0)+'" data-rail="'+(rail?1:0)+'" data-user-area="'+(ua?1:0)+'" data-bottom-nav="'+(bn?1:0)+'" data-hint="'+h.src+'"><div class="'+BC+'app" style="'+sty+'">'+m+E+E}
|
||||
var tpl=document.createElement('template');tpl.innerHTML=h!=null?APP(h):AUTHM(au);root.appendChild(tpl.content);
|
||||
var shell=root.firstChild,timer=0,obs=null,parsed=false;
|
||||
var stop=function(){window.removeEventListener('error',onError,true);document.removeEventListener('DOMContentLoaded',onParsed,true);if(timer){clearTimeout(timer);timer=0}if(obs){obs.disconnect();obs=null}};
|
||||
var drop=function(){var el=document.getElementById('fluxer-boot-shell');if(el&&el.parentNode)el.parentNode.removeChild(el);stop()};
|
||||
var onParsed=function(){parsed=true};
|
||||
var onError=function(ev){var t=ev&&ev.target;if(parsed||!t)return;if(t.tagName!=='SCRIPT'&&!(t.tagName==='LINK'&&t.rel==='stylesheet'))return;drop()};
|
||||
window.addEventListener('error',onError,true);document.addEventListener('DOMContentLoaded',onParsed,true);timer=setTimeout(drop,60000);
|
||||
if(window.MutationObserver){obs=new MutationObserver(function(){if(!root.contains(shell))stop()});obs.observe(root,{childList:true})}
|
||||
if(window.electron&&window.electron.notifyFirstContentPainted)window.electron.notifyFirstContentPainted();
|
||||
}catch(e){}})();</script>
|
||||
<div id="fluxer-boot-end" hidden></div>
|
||||
<noscript>JavaScript is required to use this application.</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"build": "pnpm codegen && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"build:desktop": "pnpm codegen && pnpm build:renderer",
|
||||
"build:renderer": "FLUXER_DESKTOP_RENDERER=true rspack build --mode production",
|
||||
"codegen": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile",
|
||||
"dev": "TOKIO_WORKER_THREADS=4 RAYON_NUM_THREADS=4 ../tools/ci/run.sh app-dev-server",
|
||||
"test": "vitest run",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts",
|
||||
@@ -177,6 +180,7 @@
|
||||
"@floating-ui/react-dom": "catalog:",
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/date_utils": "workspace:*",
|
||||
"@fluxer/desktop_ipc": "workspace:*",
|
||||
"@fluxer/geo_utils": "workspace:*",
|
||||
"@fluxer/instance_bootstrap": "workspace:*",
|
||||
"@fluxer/limits": "workspace:*",
|
||||
@@ -204,7 +208,6 @@
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"codemirror": "^6.0.2",
|
||||
"colorjs.io": "catalog:",
|
||||
"combokeys": "catalog:",
|
||||
"eventemitter3": "catalog:",
|
||||
"fast-png": "^8.0.0",
|
||||
@@ -269,6 +272,7 @@
|
||||
"@webgpu/types": "catalog:",
|
||||
"autoprefixer": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"fake-indexeddb": "catalog:",
|
||||
"happy-dom": "20.14.5",
|
||||
"postcss": "catalog:",
|
||||
"postcss-discard-comments": "catalog:",
|
||||
|
||||
+321
-26
@@ -4,11 +4,13 @@ import {existsSync, mkdirSync, readdirSync, writeFileSync} from 'node:fs';
|
||||
import path, {dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {
|
||||
Compilation,
|
||||
CopyRspackPlugin,
|
||||
DefinePlugin,
|
||||
HtmlRspackPlugin,
|
||||
LightningCssMinimizerRspackPlugin,
|
||||
SwcJsMinimizerRspackPlugin,
|
||||
sources,
|
||||
} from '@rspack/core';
|
||||
import {createPoFileRule, getLinguiSwcPluginConfig} from './scripts/build/rspack/lingui.mjs';
|
||||
import {staticFilesPlugin} from './scripts/build/rspack/static-files.mjs';
|
||||
@@ -23,6 +25,35 @@ const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
|
||||
const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
|
||||
const BROWSER_ASSERT_STRICT_MODULE = path.join(SRC_DIR, 'features', 'platform', 'utils', 'BrowserAssertStrict.ts');
|
||||
const DEFAULT_DEV_SERVER_PORT = 3000;
|
||||
const BOOT_PRELOAD_CHUNK_GROUPS = ['app-bootstrap', 'boot-app'];
|
||||
const BOOT_PRELOAD_FONT_FILES = [
|
||||
'FluxerSans/FluxerSans-Regular.woff2',
|
||||
'FluxerSans/FluxerSans-SemiBold.woff2',
|
||||
'FluxerSans/FluxerSans-Bold.woff2',
|
||||
];
|
||||
const BOOT_LOW_PRIORITY_FONT_FILES = ['FluxerSans/FluxerSans-Medium.woff2'];
|
||||
const AUTH_ENTRY_DOCUMENT = 'auth-index.html';
|
||||
const ENTRY_STYLESHEET_LINK_PATTERN = /<link href="([^"]+\.css)" rel="stylesheet">/gu;
|
||||
const CSS_URL_PATTERN = /url\(\s*['"]?([^'")]+)/gu;
|
||||
const DESKTOP_MODULE_ASSET_QUERY = /[?&]m=([a-z][a-z0-9_]{0,63})(?:&|$)/u;
|
||||
const RESERVED_DESKTOP_MODULE_NAMES = new Set(['assets', 'fluxer_renderer']);
|
||||
|
||||
class UnusableDesktopModuleQueryError extends Error {
|
||||
constructor(resource) {
|
||||
super(
|
||||
`${resource} carries an ?m= desktop module query that is not a usable module name. It must match [a-z][a-z0-9_]{0,63} and cannot be assets or fluxer_renderer.`,
|
||||
);
|
||||
this.name = 'UnusableDesktopModuleQueryError';
|
||||
}
|
||||
}
|
||||
|
||||
function desktopModuleAssetName(resource) {
|
||||
const match = DESKTOP_MODULE_ASSET_QUERY.exec(resource);
|
||||
if (match == null || RESERVED_DESKTOP_MODULE_NAMES.has(match[1])) {
|
||||
throw new UnusableDesktopModuleQueryError(resource);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function resolveMode() {
|
||||
const modeIndex = process.argv.indexOf('--mode');
|
||||
@@ -52,6 +83,164 @@ function isMainRuntimeChunk(chunk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function mirrorCacheGroups(groups) {
|
||||
const mirrored = {};
|
||||
for (const [key, group] of Object.entries(groups)) {
|
||||
if (group.chunks != null) {
|
||||
mirrored[key] = group;
|
||||
continue;
|
||||
}
|
||||
mirrored[key] = {...group, chunks: (chunk) => isMainRuntimeChunk(chunk) && chunk.canBeInitial()};
|
||||
mirrored[`${key}Async`] = {
|
||||
...group,
|
||||
name: `${group.name}-async`,
|
||||
chunks: (chunk) => isMainRuntimeChunk(chunk) && !chunk.canBeInitial(),
|
||||
};
|
||||
}
|
||||
return mirrored;
|
||||
}
|
||||
|
||||
function bootPreloadPlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('BootPreloadPlugin', (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{name: 'BootPreloadPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_REPORT},
|
||||
() => {
|
||||
const asset = compilation.getAsset('index.html');
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
const html = String(asset.source.source());
|
||||
if (!html.includes('</body>')) {
|
||||
return;
|
||||
}
|
||||
const configuredPublicPath = compilation.outputOptions.publicPath;
|
||||
const base =
|
||||
typeof configuredPublicPath === 'string' && configuredPublicPath !== 'auto' ? configuredPublicPath : '/';
|
||||
const files = new Set();
|
||||
for (const name of BOOT_PRELOAD_CHUNK_GROUPS) {
|
||||
for (const group of compilation.chunkGroups) {
|
||||
if (group.name !== name) {
|
||||
continue;
|
||||
}
|
||||
for (const file of group.getFiles()) {
|
||||
if (!html.includes(file)) {
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const ordered = [...files];
|
||||
const links = [
|
||||
...ordered
|
||||
.filter((file) => file.endsWith('.css'))
|
||||
.map((file) => `<link rel="preload" as="style" fetchpriority="low" href="${base}${file}">`),
|
||||
...ordered
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.map((file) => `<link rel="preload" as="script" fetchpriority="low" href="${base}${file}">`),
|
||||
].join('');
|
||||
if (links === '') {
|
||||
return;
|
||||
}
|
||||
compilation.updateAsset('index.html', new sources.RawSource(html.replace('</body>', `${links}</body>`)));
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class UninlinableEntryStylesheetError extends Error {
|
||||
constructor(reason) {
|
||||
super(`Cannot inline the entry stylesheet into ${AUTH_ENTRY_DOCUMENT}: ${reason}`);
|
||||
this.name = 'UninlinableEntryStylesheetError';
|
||||
}
|
||||
}
|
||||
|
||||
function inlineEntryStylesheet(css, href) {
|
||||
const content = css.replace(/\/\*# sourceMappingURL=[^*]*\*\/\s*$/u, '');
|
||||
if (content.includes('</style')) {
|
||||
throw new UninlinableEntryStylesheetError(`${href} contains a closing style tag`);
|
||||
}
|
||||
for (const [, url] of content.matchAll(CSS_URL_PATTERN)) {
|
||||
if (!/^(?:\/|https?:|data:|#)/u.test(url)) {
|
||||
throw new UninlinableEntryStylesheetError(`${href} references ${url} relative to its own location`);
|
||||
}
|
||||
}
|
||||
return `<style>${content}</style><link rel="prefetch" as="style" href="${href}">`;
|
||||
}
|
||||
|
||||
function authEntryDocumentPlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('AuthEntryDocumentPlugin', (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{name: 'AuthEntryDocumentPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_REPORT},
|
||||
() => {
|
||||
const asset = compilation.getAsset('index.html');
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
const html = String(asset.source.source());
|
||||
const links = [...html.matchAll(ENTRY_STYLESHEET_LINK_PATTERN)];
|
||||
if (links.length === 0) {
|
||||
throw new UninlinableEntryStylesheetError('index.html links no stylesheet');
|
||||
}
|
||||
let document = html;
|
||||
for (const [link, href] of links) {
|
||||
const name = compilation
|
||||
.getAssets()
|
||||
.find((candidate) => href.endsWith(`/${candidate.name}`) || href === candidate.name)?.name;
|
||||
if (name == null) {
|
||||
throw new UninlinableEntryStylesheetError(`${href} is not an emitted asset`);
|
||||
}
|
||||
const css = String(compilation.getAsset(name).source.source());
|
||||
document = document.replace(link, () => inlineEntryStylesheet(css, href));
|
||||
}
|
||||
compilation.emitAsset(AUTH_ENTRY_DOCUMENT, new sources.RawSource(document));
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fontPreloadPlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('FontPreloadPlugin', (compilation) => {
|
||||
HtmlRspackPlugin.getCompilationHooks(compilation).alterAssetTagGroups.tap('FontPreloadPlugin', (data) => {
|
||||
const fonts = [];
|
||||
const lowPriority = new Set(BOOT_LOW_PRIORITY_FONT_FILES);
|
||||
for (const sourceName of [...BOOT_PRELOAD_FONT_FILES, ...BOOT_LOW_PRIORITY_FONT_FILES]) {
|
||||
const asset = compilation
|
||||
.getAssets()
|
||||
.find((candidate) => String(candidate.info.sourceFilename ?? '').endsWith(sourceName));
|
||||
if (asset == null) {
|
||||
throw new Error(`Cannot preload ${sourceName}: no emitted asset came from it`);
|
||||
}
|
||||
fonts.push({
|
||||
tagName: 'link',
|
||||
voidTag: true,
|
||||
attributes: {
|
||||
rel: 'preload',
|
||||
as: 'font',
|
||||
type: 'font/woff2',
|
||||
crossorigin: '',
|
||||
href: `${data.publicPath}${asset.name}`,
|
||||
...(lowPriority.has(sourceName) ? {fetchpriority: 'low'} : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
data.headTags.unshift(...fonts);
|
||||
return data;
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nodeAssertStrictSchemePlugin() {
|
||||
return {
|
||||
apply(compiler) {
|
||||
@@ -69,6 +258,7 @@ function nodeAssertStrictSchemePlugin() {
|
||||
const mode = resolveMode();
|
||||
const isProduction = mode === 'production';
|
||||
const isDevelopment = !isProduction;
|
||||
const isDesktopRenderer = envString('FLUXER_DESKTOP_RENDERER') === 'true';
|
||||
const devJsName = 'assets/[name].js';
|
||||
const devCssName = 'assets/[name].css';
|
||||
const productionJsName = 'assets/[contenthash:16].js';
|
||||
@@ -103,19 +293,16 @@ function devServerHeaders() {
|
||||
|
||||
function resolveReleaseChannel() {
|
||||
const value = envString('PUBLIC_RELEASE_CHANNEL', envString('RELEASE_CHANNEL', 'canary')).trim().toLowerCase();
|
||||
if (value === 'stable' || value === 'canary') {
|
||||
if (value === 'stable' || value === 'canary' || value === 'development') {
|
||||
return value;
|
||||
}
|
||||
return 'canary';
|
||||
throw new Error(`PUBLIC_RELEASE_CHANNEL must be stable, canary, or development, received ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function resolvePublicValues() {
|
||||
return {
|
||||
PUBLIC_API_VERSION: envString('PUBLIC_API_VERSION', '1'),
|
||||
PUBLIC_BUILD_VERSION: envString('PUBLIC_BUILD_VERSION', envString('BUILD_VERSION', 'dev')),
|
||||
PUBLIC_RELEASE_CHANNEL: resolveReleaseChannel(),
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: envString('PUBLIC_BOOTSTRAP_API_ENDPOINT', '/api'),
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: envString('PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,11 +352,10 @@ function jsFilename(pathData) {
|
||||
export default () => {
|
||||
const linguiSwcPlugin = getLinguiSwcPluginConfig();
|
||||
const publicValues = resolvePublicValues();
|
||||
const assetBaseUrl = envString('PUBLIC_ASSET_BASE_URL');
|
||||
const staticCdnEndpoint = envString(
|
||||
'PUBLIC_STATIC_CDN_ENDPOINT',
|
||||
envString('FLUXER_STATIC_CDN_ENDPOINT', isProduction ? '' : ''),
|
||||
);
|
||||
const assetBaseUrl = isDesktopRenderer ? undefined : envString('PUBLIC_ASSET_BASE_URL');
|
||||
const staticCdnEndpoint = isDesktopRenderer
|
||||
? ''
|
||||
: envString('PUBLIC_STATIC_CDN_ENDPOINT', envString('FLUXER_STATIC_CDN_ENDPOINT', isProduction ? '' : ''));
|
||||
function resolveArboriumWasmAliases() {
|
||||
const arbDir = path.join(ROOT_DIR, 'node_modules', '@arborium');
|
||||
const aliases = {};
|
||||
@@ -192,10 +378,12 @@ export default () => {
|
||||
const publicPath = isProduction ? productionPublicPath : developmentPublicPath;
|
||||
return {
|
||||
mode,
|
||||
entry: {
|
||||
main: path.join(SRC_DIR, 'index.tsx'),
|
||||
sw: path.join(SRC_DIR, 'features', 'platform', 'service_worker', 'Worker.ts'),
|
||||
},
|
||||
entry: isDesktopRenderer
|
||||
? {main: path.join(SRC_DIR, 'index.tsx')}
|
||||
: {
|
||||
main: path.join(SRC_DIR, 'index.tsx'),
|
||||
sw: path.join(SRC_DIR, 'features', 'platform', 'service_worker', 'Worker.ts'),
|
||||
},
|
||||
output: {
|
||||
path: DIST_DIR,
|
||||
publicPath,
|
||||
@@ -337,6 +525,15 @@ export default () => {
|
||||
type: 'css/module',
|
||||
parser: {namedExports: false, dashedIdents: false, grid: false, container: false},
|
||||
},
|
||||
{
|
||||
test: /[\\/]@phosphor-icons[\\/]react[\\/]dist[\\/]defs[\\/][^\\/]+\.es\.js$/,
|
||||
use: [{loader: path.join(ROOT_DIR, 'scripts/build/rspack/phosphor-unused-weights-loader.cjs')}],
|
||||
},
|
||||
{
|
||||
test: /[\\/]node_modules[\\/]katex[\\/]dist[\\/]katex(\.min)?\.css$/,
|
||||
use: [{loader: path.join(ROOT_DIR, 'scripts/build/rspack/katex-legacy-fonts-loader.cjs')}],
|
||||
type: 'css',
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
exclude: /\.module\.css$/,
|
||||
@@ -411,6 +608,18 @@ export default () => {
|
||||
filename: isProduction ? 'assets/[contenthash:16][ext]' : 'assets/[name].[hash][ext]',
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceQuery: DESKTOP_MODULE_ASSET_QUERY,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: (pathData) => {
|
||||
const moduleName = desktopModuleAssetName(pathData.filename ?? '');
|
||||
return isProduction
|
||||
? `assets/${moduleName}/[contenthash:16][ext]`
|
||||
: `assets/${moduleName}/[name].[hash][ext]`;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
generator: {
|
||||
'css/module': {
|
||||
@@ -427,6 +636,9 @@ export default () => {
|
||||
},
|
||||
plugins: [
|
||||
nodeAssertStrictSchemePlugin(),
|
||||
fontPreloadPlugin(),
|
||||
...(isProduction ? [bootPreloadPlugin()] : []),
|
||||
...(isProduction && !isDesktopRenderer ? [authEntryDocumentPlugin()] : []),
|
||||
new HtmlRspackPlugin({
|
||||
template: path.join(ROOT_DIR, 'index.html'),
|
||||
filename: 'index.html',
|
||||
@@ -434,6 +646,7 @@ export default () => {
|
||||
inject: 'body',
|
||||
scriptLoading: 'module',
|
||||
excludeChunks: ['sw'],
|
||||
...(isDesktopRenderer ? {publicPath: '/'} : {}),
|
||||
}),
|
||||
new CopyRspackPlugin({
|
||||
patterns: [
|
||||
@@ -442,6 +655,14 @@ export default () => {
|
||||
to: DIST_DIR,
|
||||
noErrorOnMissing: true,
|
||||
},
|
||||
...(isDesktopRenderer
|
||||
? [
|
||||
{
|
||||
from: path.join(MONOREPO_ROOT, 'fluxer_static', 'web'),
|
||||
to: path.join(DIST_DIR, 'web'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
staticFilesPlugin({
|
||||
@@ -457,13 +678,7 @@ export default () => {
|
||||
'import.meta.env.PROD': JSON.stringify(isProduction),
|
||||
'import.meta.env.MODE': JSON.stringify(mode),
|
||||
'import.meta.env.PUBLIC_BUILD_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_VERSION'),
|
||||
'import.meta.env.PUBLIC_API_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_API_VERSION'),
|
||||
'import.meta.env.PUBLIC_RELEASE_CHANNEL': getPublicEnvVar(publicValues, 'PUBLIC_RELEASE_CHANNEL'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT': getPublicEnvVar(publicValues, 'PUBLIC_BOOTSTRAP_API_ENDPOINT'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT': getPublicEnvVar(
|
||||
publicValues,
|
||||
'PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT',
|
||||
),
|
||||
}),
|
||||
{
|
||||
apply(compiler) {
|
||||
@@ -482,7 +697,8 @@ export default () => {
|
||||
? {
|
||||
chunks: (chunk) => isMainRuntimeChunk(chunk),
|
||||
maxInitialRequests: 15,
|
||||
cacheGroups: {
|
||||
maxAsyncSize: 2_000_000,
|
||||
cacheGroups: mirrorCacheGroups({
|
||||
icons: {
|
||||
test: /[\\/]node_modules[\\/]@phosphor-icons[\\/]/,
|
||||
name: 'icons',
|
||||
@@ -497,8 +713,24 @@ export default () => {
|
||||
enforce: true,
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
tts: {
|
||||
test: /[\\/]node_modules[\\/]mespeak[\\/]/,
|
||||
name: 'tts',
|
||||
priority: 54,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
editor: {
|
||||
test: /[\\/]node_modules[\\/](@codemirror|@lezer|codemirror|style-mod|w3c-keyname|crelt|@marijn)[\\/]/,
|
||||
name: 'editor',
|
||||
priority: 53,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
livekit: {
|
||||
test: /[\\/]node_modules[\\/](livekit-client|@livekit)[\\/]/,
|
||||
test: /[\\/](node_modules|pkgs)[\\/](livekit-client|@livekit)[\\/]/,
|
||||
name: 'livekit',
|
||||
priority: 50,
|
||||
reuseExistingChunk: true,
|
||||
@@ -511,7 +743,7 @@ export default () => {
|
||||
maxSize: 100_000,
|
||||
},
|
||||
animation: {
|
||||
test: /[\\/]node_modules[\\/](framer-motion|motion)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](framer-motion|motion|motion-dom|motion-utils)[\\/]/,
|
||||
name: 'animation',
|
||||
priority: 45,
|
||||
reuseExistingChunk: true,
|
||||
@@ -529,8 +761,15 @@ export default () => {
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
colorPicker: {
|
||||
test: /[\\/]node_modules[\\/](@react-aria[\\/]color|@react-stately[\\/]color)[\\/]/,
|
||||
name: 'color-picker',
|
||||
priority: 42,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
reactAria: {
|
||||
test: /[\\/]node_modules[\\/]react-aria-components[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](react-aria-components|@react-aria|@react-stately|@internationalized)[\\/]/,
|
||||
name: 'react-aria',
|
||||
priority: 41,
|
||||
reuseExistingChunk: true,
|
||||
@@ -578,7 +817,7 @@ export default () => {
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
utils: {
|
||||
test: /[\\/]node_modules[\\/](lodash|clsx|qrcode|thumbhash|bowser|match-sorter)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](lodash|clsx|thumbhash|match-sorter)[\\/]/,
|
||||
name: 'utils',
|
||||
priority: 28,
|
||||
reuseExistingChunk: true,
|
||||
@@ -595,6 +834,62 @@ export default () => {
|
||||
priority: 25,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
platform: {
|
||||
test: /[\\/]node_modules[\\/]bowser[\\/]/,
|
||||
name: 'platform',
|
||||
priority: 27,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
phone: {
|
||||
test: /[\\/]node_modules[\\/]libphonenumber-js[\\/]/,
|
||||
name: 'phone',
|
||||
priority: 26,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
qrcode: {
|
||||
test: /[\\/]node_modules[\\/](qrcode|dijkstrajs)[\\/]/,
|
||||
name: 'qrcode',
|
||||
priority: 24,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
lexical: {
|
||||
test: /[\\/]node_modules[\\/](lexical|@lexical)[\\/]/,
|
||||
name: 'lexical',
|
||||
priority: 23,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](react-day-picker|date-fns|@date-fns)[\\/]/,
|
||||
name: 'calendar',
|
||||
priority: 22,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
webrtc: {
|
||||
test: /[\\/]node_modules[\\/](webrtc-adapter|sdp|sdp-transform)[\\/]/,
|
||||
name: 'webrtc',
|
||||
priority: 21,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
noiseFilter: {
|
||||
test: /[\\/]node_modules[\\/]deepfilternet3-noise-filter[\\/]/,
|
||||
name: 'noise-filter',
|
||||
priority: 20,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
virtualizer: {
|
||||
test: /[\\/]node_modules[\\/]@tanstack[\\/]/,
|
||||
name: 'virtualizer',
|
||||
priority: 19,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
vendor: {
|
||||
test: (module) => {
|
||||
if (!module.resource) return false;
|
||||
@@ -607,7 +902,7 @@ export default () => {
|
||||
priority: 10,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
: false,
|
||||
runtimeChunk: false,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const LEGACY_FONT_SOURCE = /,\s*url\([^)]*\)\s*format\("(?:woff|truetype)"\)/g;
|
||||
|
||||
class MissingKatexWoff2SourceError extends Error {
|
||||
constructor(resource) {
|
||||
super(`${resource} declares no format("woff2") source, refusing to drop its legacy font sources`);
|
||||
this.name = 'MissingKatexWoff2SourceError';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function katexLegacyFontsLoader(source) {
|
||||
this.cacheable(true);
|
||||
if (!source.includes('format("woff2")')) {
|
||||
throw new MissingKatexWoff2SourceError(this.resourcePath);
|
||||
}
|
||||
return source.replace(LEGACY_FONT_SOURCE, '');
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const UNUSED_WEIGHTS = ['thin', 'light'];
|
||||
const UNUSED_WEIGHT_ENTRY = /\[\s*"(?:thin|light)"\s*,/g;
|
||||
const REQUIRED_WEIGHTS = ['"bold"', '"duotone"', '"fill"', '"regular"'];
|
||||
|
||||
class UnreadablePhosphorWeightMapError extends Error {
|
||||
constructor(resource, detail) {
|
||||
super(`${resource} does not match the expected @phosphor-icons weight map shape (${detail})`);
|
||||
this.name = 'UnreadablePhosphorWeightMapError';
|
||||
}
|
||||
}
|
||||
|
||||
function findEntryEnd(source, start) {
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
if (quote != null) {
|
||||
if (char === '\\') {
|
||||
i++;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '[' || char === '(' || char === '{') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (char === ']' || char === ')' || char === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
module.exports = function phosphorUnusedWeightsLoader(source) {
|
||||
this.cacheable(true);
|
||||
for (const weight of REQUIRED_WEIGHTS) {
|
||||
if (!source.includes(weight)) {
|
||||
throw new UnreadablePhosphorWeightMapError(this.resourcePath, `missing ${weight}`);
|
||||
}
|
||||
}
|
||||
const spans = [];
|
||||
UNUSED_WEIGHT_ENTRY.lastIndex = 0;
|
||||
for (let match = UNUSED_WEIGHT_ENTRY.exec(source); match != null; match = UNUSED_WEIGHT_ENTRY.exec(source)) {
|
||||
const end = findEntryEnd(source, match.index);
|
||||
if (end < 0) {
|
||||
throw new UnreadablePhosphorWeightMapError(this.resourcePath, `unterminated entry at ${match.index}`);
|
||||
}
|
||||
spans.push([match.index, end]);
|
||||
UNUSED_WEIGHT_ENTRY.lastIndex = end;
|
||||
}
|
||||
if (spans.length !== UNUSED_WEIGHTS.length) {
|
||||
throw new UnreadablePhosphorWeightMapError(this.resourcePath, `found ${spans.length} unused weight entries`);
|
||||
}
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
for (const [start, end] of spans) {
|
||||
out += source.slice(cursor, start);
|
||||
cursor = end;
|
||||
while (cursor < source.length && /[\s,]/.test(source[cursor])) {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
return out + source.slice(cursor);
|
||||
};
|
||||
@@ -8,27 +8,11 @@ import * as esbuild from 'esbuild';
|
||||
|
||||
const PRECACHE_ROOT_FILES = ['manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
|
||||
const NEVER_PRECACHED_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
||||
|
||||
async function fileRevision(filePath: string): Promise<string> {
|
||||
const stat = await fs.stat(filePath);
|
||||
return `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
||||
}
|
||||
|
||||
function isLocalAssetUrl(value: string): boolean {
|
||||
if (!value.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
if (value.startsWith('//')) {
|
||||
return false;
|
||||
}
|
||||
const pathname = value.split(/[?#]/, 1)[0].toLowerCase();
|
||||
if (NEVER_PRECACHED_EXTENSIONS.some((extension) => pathname.endsWith(extension))) {
|
||||
return false;
|
||||
}
|
||||
return value.startsWith('/assets/') || PRECACHE_ROOT_FILES.some((file) => value === `/${file}`);
|
||||
}
|
||||
|
||||
async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
const entries = new Map<string, string>();
|
||||
for (const file of PRECACHE_ROOT_FILES) {
|
||||
@@ -37,21 +21,6 @@ async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
entries.set(`/${file}`, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const html = await fs.readFile(path.join(DIST_DIR, 'index.html'), 'utf8');
|
||||
const attributePattern = /\b(?:href|src)=["']([^"']+)["']/g;
|
||||
for (const match of html.matchAll(attributePattern)) {
|
||||
const url = match[1];
|
||||
if (!isLocalAssetUrl(url)) {
|
||||
continue;
|
||||
}
|
||||
const pathname = new URL(url, 'https://local.invalid').pathname;
|
||||
const filePath = path.join(DIST_DIR, pathname.slice(1));
|
||||
try {
|
||||
entries.set(pathname, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return Array.from(entries, ([url, revision]) => ({url, revision}));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,13 @@ import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {ensureAutostartDefaultEnabled} from '@app/features/platform/utils/Autostart';
|
||||
import {startDesktopJumpListBridge} from '@app/features/platform/utils/DesktopJumpListBridge';
|
||||
import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLocaleBridge';
|
||||
@@ -71,6 +74,8 @@ const SKIP_TO_CONTENT_DESCRIPTOR = msg({
|
||||
comment: 'Accessible skip-link label for keyboard users.',
|
||||
});
|
||||
|
||||
const logger = new Logger('App');
|
||||
|
||||
interface AppWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -122,6 +127,22 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void SessionManager.initialize()
|
||||
.then(() => {
|
||||
if (active) {
|
||||
GatewaySessions.startRestoredSession();
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Failed to initialize the restored gateway session', error);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
GatewaySessions.dispose();
|
||||
};
|
||||
}, []);
|
||||
useEffect(
|
||||
() =>
|
||||
reaction(
|
||||
@@ -155,7 +176,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
|
||||
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useWindowEventListeners();
|
||||
useRemScaleTracking();
|
||||
usePlatformClasses(platform, isNative);
|
||||
useThemeCssVariables({
|
||||
|
||||
@@ -12,6 +12,12 @@ import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {installLocaleSwitchWatchdog} from '@app/features/i18n/utils/LocaleSwitchWatchdog';
|
||||
import {installTranslationDomGuard} from '@app/features/i18n/utils/TranslationDomGuard';
|
||||
import {installScrollRestoration} from '@app/features/platform/components/router/ScrollRestoration';
|
||||
import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime';
|
||||
import type {
|
||||
AppStorageBootstrapHandle,
|
||||
AppStorageSessionAccount,
|
||||
} from '@app/features/platform/state/AppStorageBootstrap';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {
|
||||
getFormattedClientInfo,
|
||||
@@ -24,11 +30,13 @@ import {scheduleNonLatinScriptFaces} from '@app/features/theme/fonts/ScriptFontL
|
||||
import {installVoiceSubscriptionDebugApi} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugApi';
|
||||
import {PASSKEY_BRIDGE_PATH} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {i18n} from '@lingui/core';
|
||||
import type {ReactNode} from 'react';
|
||||
import {type ReactNode, startTransition} from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
const logger = new Logger('index');
|
||||
|
||||
const SIGNED_OUT_INSTANCE_DISCOVERY_TIMEOUT_MS = 5000;
|
||||
|
||||
if (typeof window !== 'undefined' && window.history) {
|
||||
bootstrapSyntheticHistory();
|
||||
installScrollRestoration();
|
||||
@@ -47,18 +55,22 @@ function createRoot(): ReactDOM.Root {
|
||||
|
||||
function mountRoot(content: ReactNode, dataFlxScope: string): void {
|
||||
installTranslationDomGuard();
|
||||
createRoot().render(
|
||||
<AppErrorBoundary
|
||||
fallback={(error) => (
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
|
||||
</AppI18nProvider>
|
||||
)}
|
||||
data-flx={`${dataFlxScope}.app-error-boundary`}
|
||||
>
|
||||
{content}
|
||||
</AppErrorBoundary>,
|
||||
);
|
||||
const root = createRoot();
|
||||
startTransition(() => {
|
||||
root.render(
|
||||
<AppErrorBoundary
|
||||
fallback={(error) => (
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
|
||||
</AppI18nProvider>
|
||||
)}
|
||||
data-flx={`${dataFlxScope}.app-error-boundary`}
|
||||
>
|
||||
{content}
|
||||
</AppErrorBoundary>,
|
||||
);
|
||||
});
|
||||
globalThis.window?.electron?.notifyFirstContentPainted?.();
|
||||
}
|
||||
|
||||
async function logClientInfo(): Promise<void> {
|
||||
@@ -82,14 +94,25 @@ async function preloadMarkdownParser(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapThemeStudio(): Promise<void> {
|
||||
function storageSessionAccount(account: Account | null): AppStorageSessionAccount | null {
|
||||
if (account === null) {
|
||||
return null;
|
||||
}
|
||||
if (account.instance === undefined) {
|
||||
throw new Error(`Authenticated account ${account.storageKey} has no instance runtime`);
|
||||
}
|
||||
return {accountKey: account.storageKey, userId: account.userId, token: account.token, instance: account.instance};
|
||||
}
|
||||
|
||||
async function bootstrapThemeStudio(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
|
||||
const markdownParserReady = preloadMarkdownParser();
|
||||
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: AccountManager}] = await Promise.all([
|
||||
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: Accounts}] = await Promise.all([
|
||||
loadLazyModule(() => import('@app/features/theme_studio/ThemeStudioStandaloneApp')),
|
||||
loadLazyModule(() => import('@app/app/SetupHttp')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/Accounts')),
|
||||
]);
|
||||
await AccountManager.bootstrap();
|
||||
await Accounts.bootstrap();
|
||||
await storageBootstrap.finalizeAfterSessionResolution(storageSessionAccount(Accounts.currentAccount));
|
||||
setupHttp();
|
||||
await markdownParserReady;
|
||||
mountRoot(
|
||||
@@ -132,7 +155,39 @@ async function probeSignedOutDomainMigration(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp(): Promise<void> {
|
||||
async function activateSignedOutDocumentInstance(): Promise<void> {
|
||||
if (isDesktopLocalAppDocument()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const {default: RuntimeConfig} = await loadLazyModule(() => import('@app/features/app/state/RuntimeConfig'));
|
||||
if (RuntimeConfig.getSnapshotOrNull() !== null) {
|
||||
return;
|
||||
}
|
||||
const resolution = await RuntimeConfig.resolveEndpoint({
|
||||
input: window.location.origin,
|
||||
signal: AbortSignal.timeout(SIGNED_OUT_INSTANCE_DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
if (RuntimeConfig.getSnapshotOrNull() === null) {
|
||||
RuntimeConfig.applySnapshot(resolution.snapshot);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to activate the instance this page belongs to:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareSignedOutFirstScreen(): Promise<void> {
|
||||
try {
|
||||
const {prepareSignedOutFirstScreen: prepare} = await loadLazyModule(
|
||||
() => import('@app/features/auth/flow/AuthFirstScreen'),
|
||||
);
|
||||
await prepare();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to prepare the signed-out first screen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
|
||||
const markdownParserReady = preloadMarkdownParser();
|
||||
const [
|
||||
{App},
|
||||
@@ -140,7 +195,7 @@ async function bootstrapApp(): Promise<void> {
|
||||
{default: CaptchaInterceptor},
|
||||
{initializeEmojiParser},
|
||||
{registerServiceWorker},
|
||||
{default: AccountManager},
|
||||
{default: Accounts},
|
||||
{default: ChannelDisplayName},
|
||||
_channelFrecency,
|
||||
_geoIp,
|
||||
@@ -152,15 +207,15 @@ async function bootstrapApp(): Promise<void> {
|
||||
{default: StatusPage},
|
||||
{installMigratedDeviceRemap},
|
||||
] = await Promise.all([
|
||||
loadLazyModule(() => import('@app/app/App')),
|
||||
loadLazyModule(() => import(/* webpackChunkName: "boot-app" */ '@app/app/App')),
|
||||
loadLazyModule(() => import('@app/app/SetupHttp')),
|
||||
loadLazyModule(() => import('@app/features/auth/altcha/CaptchaInterceptor')),
|
||||
loadLazyModule(() => import('@app/features/messaging/utils/markdown/EmojiProviderSetup')),
|
||||
loadLazyModule(() => import('@app/features/platform/service_worker/Register')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/Accounts')),
|
||||
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
|
||||
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
|
||||
loadLazyModule(() => import('@app/features/app/state/GeoIP')).then(({default: GeoIP}) => GeoIP.load()),
|
||||
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
|
||||
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
|
||||
loadLazyModule(() => import('@app/features/ui/state/Notification')),
|
||||
@@ -178,10 +233,14 @@ async function bootstrapApp(): Promise<void> {
|
||||
CaptchaInterceptor.setI18n(reactiveI18n);
|
||||
void StatusPage.checkIncidents();
|
||||
StatusPage.startPolling();
|
||||
await Promise.all([AccountManager.bootstrap(), installMigratedDeviceRemap()]);
|
||||
if (AccountManager.currentUserId === null) {
|
||||
await Promise.all([Accounts.bootstrap(), installMigratedDeviceRemap()]);
|
||||
await storageBootstrap.finalizeAfterSessionResolution(storageSessionAccount(Accounts.currentAccount));
|
||||
if (Accounts.currentUserId === null) {
|
||||
void probeSignedOutDomainMigration();
|
||||
}
|
||||
if (Accounts.currentAccount === null) {
|
||||
await Promise.all([activateSignedOutDocumentInstance(), prepareSignedOutFirstScreen()]);
|
||||
}
|
||||
setupHttp();
|
||||
initializeEmojiParser();
|
||||
await markdownParserReady;
|
||||
@@ -190,15 +249,15 @@ async function bootstrapApp(): Promise<void> {
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
export async function runApp(): Promise<void> {
|
||||
export async function runApp(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
|
||||
scheduleNonLatinScriptFaces();
|
||||
await initI18n();
|
||||
installLocaleSwitchWatchdog();
|
||||
installSelfXssNotice();
|
||||
void logClientInfo();
|
||||
if (window.location.pathname === Routes.THEME_STUDIO) {
|
||||
await bootstrapThemeStudio();
|
||||
await bootstrapThemeStudio(storageBootstrap);
|
||||
} else {
|
||||
await bootstrapApp();
|
||||
await bootstrapApp(storageBootstrap);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
import {NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {WELCOME_ROTATION} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
|
||||
import {
|
||||
AuthShellHintKind,
|
||||
recordAuthShellHint,
|
||||
resolveAuthShellPlaceholder,
|
||||
} from '@app/features/app/components/skeleton/AuthShellHint';
|
||||
import {SHELL_HINT_NAGBAR_TONE_ORDER, SHELL_HINT_VERSION} from '@app/features/app/components/skeleton/ShellHint';
|
||||
import {
|
||||
LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER,
|
||||
LEGACY_SHELL_HINT_SEED_VERSION,
|
||||
legacyShellHintSeed,
|
||||
} from '@fluxer/desktop_ipc/src/LegacyShellHintSeed';
|
||||
import {createElement, type ReactNode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {afterEach, expect, test, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: ({children}: {children?: ReactNode}) => children ?? null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
|
||||
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
|
||||
|
||||
function readBootScript(): string {
|
||||
const body = html.slice(html.indexOf('<div id="root"></div>'));
|
||||
const match = /<script[^>]*>([\s\S]*?)<\/script>/u.exec(body);
|
||||
expect(match).not.toBeNull();
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
interface ShellHintOverrides {
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
const ACCOUNT_KEY = 'https://one.example/api::100';
|
||||
|
||||
function desktopDMHint(overrides: ShellHintOverrides = {}): Record<string, unknown> {
|
||||
return {
|
||||
v: SHELL_HINT_VERSION,
|
||||
t: Date.now(),
|
||||
p: '/channels/@me',
|
||||
a: ACCOUNT_KEY,
|
||||
mo: 0,
|
||||
ua: 0,
|
||||
fs: 0,
|
||||
sw: 320,
|
||||
sv: 'dm',
|
||||
ck: 'other',
|
||||
cb: 1,
|
||||
rf: 1,
|
||||
rv: 1,
|
||||
ru: [],
|
||||
rti: 0,
|
||||
ro: 0,
|
||||
ri: [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
],
|
||||
rgi: -1,
|
||||
rbm: 15,
|
||||
rbi: -1,
|
||||
rs: 0,
|
||||
gb: 0,
|
||||
ga: 1778,
|
||||
gn: 0,
|
||||
gd: 0,
|
||||
gm: 0,
|
||||
sr: 6,
|
||||
sam: 7,
|
||||
ss: 1,
|
||||
cid: '',
|
||||
ch: 2,
|
||||
ct: 0,
|
||||
cnw: 0,
|
||||
ctw: 0,
|
||||
cda: -1,
|
||||
cma: -1,
|
||||
cfv: 1,
|
||||
cst: 0,
|
||||
cuv: 0,
|
||||
ml: 0,
|
||||
co: 4,
|
||||
com: 2,
|
||||
cdv: 0,
|
||||
pc: 0,
|
||||
pg: 16,
|
||||
pf: 16,
|
||||
ps: 16,
|
||||
pa: 0,
|
||||
pt: 56,
|
||||
pv: 800,
|
||||
nr: [],
|
||||
vh: 0,
|
||||
vc: 0,
|
||||
mr: 0,
|
||||
ms: 0,
|
||||
mn: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setViewportWidth(width: number): void {
|
||||
Object.defineProperty(window, 'innerWidth', {value: width, configurable: true});
|
||||
const matches = width >= 1024;
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: (query: string) => ({matches: query.includes('1024') ? matches : false, media: query}),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function failScript(): void {
|
||||
const script = document.createElement('script');
|
||||
document.body.appendChild(script);
|
||||
script.dispatchEvent(new Event('error', {bubbles: false}));
|
||||
}
|
||||
|
||||
const bootRoots: Array<HTMLElement> = [];
|
||||
|
||||
interface BootScriptOptions {
|
||||
readonly signedIn?: boolean;
|
||||
readonly storage?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
function runBootScript(
|
||||
hint: Record<string, unknown> | null,
|
||||
pathname = '/channels/@me',
|
||||
{signedIn = true, storage = {}}: BootScriptOptions = {},
|
||||
): HTMLElement | null {
|
||||
document.body.innerHTML = '<div id="root"></div>';
|
||||
window.history.pushState({}, '', pathname);
|
||||
localStorage.clear();
|
||||
if (signedIn) {
|
||||
localStorage.setItem('fluxer:gateway:preboot:session', '1');
|
||||
}
|
||||
localStorage.setItem('fluxer:auth:active-account-key', ACCOUNT_KEY);
|
||||
if (hint != null) {
|
||||
localStorage.setItem('fluxer:ui:shell-hint', JSON.stringify(hint));
|
||||
}
|
||||
for (const [key, value] of Object.entries(storage)) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
const root = document.getElementById('root');
|
||||
if (root != null) {
|
||||
bootRoots.push(root);
|
||||
}
|
||||
new Function(readBootScript())();
|
||||
return document.querySelector<HTMLElement>('#fluxer-boot-shell');
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of bootRoots.splice(0)) {
|
||||
root.replaceChildren(document.createElement('span'));
|
||||
root.replaceChildren();
|
||||
}
|
||||
await Promise.resolve();
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('a desktop hint paints the boot shell', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint());
|
||||
|
||||
expect(shell).not.toBeNull();
|
||||
expect(shell?.dataset.mobile).toBe('0');
|
||||
expect(shell?.dataset.userArea).toBe('1');
|
||||
expect(shell?.firstElementChild?.className).toBe('fluxer-boot-app');
|
||||
});
|
||||
|
||||
test('the boot shell is parsed in an inert template so the render-blocking expectation outlives it', () => {
|
||||
setViewportWidth(1400);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
||||
const rootWrites: Array<string> = [];
|
||||
const innerHTML = vi.spyOn(Element.prototype, 'innerHTML', 'set').mockImplementation(function (
|
||||
this: Element,
|
||||
value: string,
|
||||
) {
|
||||
if (this.id === 'root') {
|
||||
rootWrites.push(value);
|
||||
}
|
||||
descriptor?.set?.call(this, value);
|
||||
});
|
||||
const createElement = vi.spyOn(document, 'createElement');
|
||||
try {
|
||||
const shell = runBootScript(desktopDMHint());
|
||||
|
||||
expect(shell?.querySelector('svg')).not.toBeNull();
|
||||
expect(shell?.parentElement?.id).toBe('root');
|
||||
expect(rootWrites).toEqual([]);
|
||||
expect(createElement.mock.calls.map(([tagName]) => tagName)).toContain('template');
|
||||
} finally {
|
||||
createElement.mockRestore();
|
||||
innerHTML.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
function railGuildSlots(shell: HTMLElement | null): number {
|
||||
return shell?.querySelectorAll('.fluxer-boot-rail-items > .fluxer-boot-slot').length ?? 0;
|
||||
}
|
||||
|
||||
test('a hint written for another account is ignored and the default shell paints instead', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint({a: 'https://two.example/api::200', ri: [[0, 0]]}));
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(railGuildSlots(shell)).toBe(6);
|
||||
});
|
||||
|
||||
test('a signed-in boot without a hint paints the generic friends shell at the DM root', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(null);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.userArea).toBe('1');
|
||||
expect(railGuildSlots(shell)).toBe(6);
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a signed-in boot without a hint paints a guild chat shell with the member list on a channel route', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(null, '/channels/100/200');
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-chat')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-member-list')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-composer')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a signed-in boot without a hint paints the mobile channel list with a bottom nav on a phone', () => {
|
||||
setViewportWidth(390);
|
||||
|
||||
const shell = runBootScript(null, '/channels/@me');
|
||||
|
||||
expect(shell?.dataset.mobile).toBe('1');
|
||||
expect(shell?.dataset.bottomNav).toBe('1');
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a signed-in boot never paints a shell on a route the app renders without guild chrome', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(null, '/notifications')).toBeNull();
|
||||
expect(runBootScript(null, '/channels/@discover')).toBeNull();
|
||||
});
|
||||
|
||||
test('a days-old hint for the same account and route still paints as remembered', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint({t: Date.now() - 3 * 86_400_000}));
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(railGuildSlots(shell)).toBe(4);
|
||||
});
|
||||
|
||||
test('a hint for another route keeps its rail and global chrome but resolves the route it lands on', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(desktopDMHint({nr: [[0, 0, 1]]}), '/channels/100/200');
|
||||
|
||||
expect(shell?.dataset.hint).toBe('derived');
|
||||
expect(railGuildSlots(shell)).toBe(4);
|
||||
expect(shell?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(1);
|
||||
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).toBeNull();
|
||||
});
|
||||
|
||||
test('a hint seeded from the legacy desktop client paints its rail, nagbars and sidebar on the first boot', () => {
|
||||
setViewportWidth(1400);
|
||||
const seed = legacyShellHintSeed(
|
||||
{
|
||||
SkeletonLayoutMemory: JSON.stringify({
|
||||
version: 9,
|
||||
chrome: {
|
||||
dmSidebar: {
|
||||
isMobile: false,
|
||||
friendsVisible: true,
|
||||
personalNotesVisible: true,
|
||||
premiumVisible: false,
|
||||
sectionVisible: true,
|
||||
channelRowCount: 2,
|
||||
channelSubtextFlags: [true, false],
|
||||
},
|
||||
guildRail: {
|
||||
inlineDmRowCount: 0,
|
||||
inlineDmUnreadFlags: [],
|
||||
selectedInlineDmRowIndex: -1,
|
||||
outageVisible: false,
|
||||
fluxerVisible: true,
|
||||
favoritesVisible: true,
|
||||
discoveryVisible: true,
|
||||
addGuildVisible: true,
|
||||
downloadVisible: false,
|
||||
helpVisible: true,
|
||||
selectedItemIndex: -1,
|
||||
organizedItems: [
|
||||
{kind: 'guild', indicator: 'none'},
|
||||
{kind: 'guild', indicator: 'unread'},
|
||||
],
|
||||
scrollTopPx: 0,
|
||||
},
|
||||
},
|
||||
nagbar: {
|
||||
rows: [
|
||||
{tone: NagbarToneKind.ALERT, hasActions: true, dismissible: false},
|
||||
{tone: NagbarToneKind.BRAND, hasActions: true, dismissible: true},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'fluxer:ui:sidebar-width': '306',
|
||||
},
|
||||
ACCOUNT_KEY,
|
||||
Date.now(),
|
||||
);
|
||||
const hint = JSON.parse(seed ?? '');
|
||||
expect(hint.v).toBe(SHELL_HINT_VERSION);
|
||||
expect(LEGACY_SHELL_HINT_SEED_VERSION).toBe(SHELL_HINT_VERSION);
|
||||
|
||||
const shell = runBootScript(hint);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(railGuildSlots(shell)).toBe(2);
|
||||
expect(shell?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(2);
|
||||
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
|
||||
expect(shell?.firstElementChild?.getAttribute('style')).toContain('--layout-sidebar-width:19.125rem');
|
||||
|
||||
const guild = runBootScript(hint, '/channels/100/200');
|
||||
|
||||
expect(guild?.dataset.hint).toBe('derived');
|
||||
expect(railGuildSlots(guild)).toBe(2);
|
||||
expect(guild?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('the legacy seed encodes nagbar tones in the order the boot shell decodes them', () => {
|
||||
expect(LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER).toEqual(SHELL_HINT_NAGBAR_TONE_ORDER);
|
||||
});
|
||||
|
||||
test('the DM root paints the remembered route because the app restores it on boot', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runBootScript(
|
||||
desktopDMHint({p: '/channels/100/200', sv: 'guild', ck: 'chat', cb: 0, cid: '200', ch: 0}),
|
||||
'/channels/@me',
|
||||
);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-chat')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a document script that never loads drops the boot shell because nothing else can mount the app', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(desktopDMHint())).not.toBeNull();
|
||||
|
||||
failScript();
|
||||
|
||||
expect(document.querySelector('#fluxer-boot-shell')).toBeNull();
|
||||
});
|
||||
|
||||
test('the backstop removes a boot shell React never cleared', () => {
|
||||
vi.useFakeTimers();
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(desktopDMHint())).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(document.querySelector('#fluxer-boot-shell')).toBeNull();
|
||||
});
|
||||
|
||||
test('the error listener and the backstop stop existing once React has cleared the boot shell', async () => {
|
||||
vi.useFakeTimers();
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runBootScript(desktopDMHint())).not.toBeNull();
|
||||
|
||||
document.getElementById('root')?.replaceChildren();
|
||||
await Promise.resolve();
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.id = 'fluxer-boot-shell';
|
||||
document.body.appendChild(marker);
|
||||
|
||||
failScript();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(document.querySelector('#fluxer-boot-shell')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('React clearing its own container is what removes the boot shell', async () => {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'root';
|
||||
const shell = document.createElement('div');
|
||||
shell.id = 'fluxer-boot-shell';
|
||||
container.appendChild(shell);
|
||||
document.body.appendChild(container);
|
||||
|
||||
createRoot(container).render(createElement('span', null, 'mounted'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain('mounted');
|
||||
});
|
||||
|
||||
expect(container.querySelector('#fluxer-boot-shell')).toBeNull();
|
||||
});
|
||||
|
||||
test('the zoom script syncs --custom-zoom with the persisted preboot mirror before paint', () => {
|
||||
const chunk = html.split(/<script[^>]*>/u).find((part) => part.includes('zoom-preboot'));
|
||||
expect(chunk).toBeDefined();
|
||||
const script = (chunk ?? '').split('</script>')[0];
|
||||
expect(script).toContain('zoom-preboot');
|
||||
const run = (mirror: string | null): string => {
|
||||
document.documentElement.style.removeProperty('--custom-zoom');
|
||||
if (mirror === null) {
|
||||
localStorage.removeItem('fluxer:accessibility:zoom-preboot');
|
||||
} else {
|
||||
localStorage.setItem('fluxer:accessibility:zoom-preboot', mirror);
|
||||
}
|
||||
new Function(script)();
|
||||
return document.documentElement.style.getPropertyValue('--custom-zoom');
|
||||
};
|
||||
expect(run('125')).toBe('125');
|
||||
expect(run('75')).toBe('75');
|
||||
expect(run(null)).toBe('');
|
||||
expect(run('9999')).toBe('');
|
||||
expect(run('not-a-number')).toBe('');
|
||||
localStorage.removeItem('fluxer:accessibility:zoom-preboot');
|
||||
});
|
||||
|
||||
function runSignedOutBootScript(pathname: string, storage: Readonly<Record<string, string>> = {}): HTMLElement | null {
|
||||
return runBootScript(null, pathname, {signedIn: false, storage});
|
||||
}
|
||||
|
||||
function setNativePlatform(native: boolean): void {
|
||||
document.documentElement.classList.toggle('platform-native', native);
|
||||
}
|
||||
|
||||
test('a signed-out boot paints the sign-in card at its final size instead of a flat background', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runSignedOutBootScript('/login');
|
||||
|
||||
expect(shell?.dataset.auth).toBe('card');
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.height).toBe('214');
|
||||
expect(shell?.querySelector<HTMLElement>('.fluxer-boot-auth-card')?.style.height).toBe('13.375rem');
|
||||
expect(shell?.querySelectorAll('.fluxer-boot-auth-list > .fluxer-boot-block')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a signed-out boot on an app route paints the sign-in card the app redirects to', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runSignedOutBootScript('/channels/@me')?.dataset.category).toBe('login');
|
||||
expect(runSignedOutBootScript('/channels/100/200')?.dataset.category).toBe('login');
|
||||
});
|
||||
|
||||
test('a signed-out boot sizes the register card and the phone layout from their own defaults', () => {
|
||||
setViewportWidth(1400);
|
||||
expect(runSignedOutBootScript('/register')?.dataset.height).toBe('674');
|
||||
|
||||
setViewportWidth(390);
|
||||
const mobile = runSignedOutBootScript('/register');
|
||||
expect(mobile?.dataset.mobile).toBe('1');
|
||||
expect(mobile?.dataset.height).toBe('592');
|
||||
expect(mobile?.querySelector('.fluxer-boot-auth-mmain')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a remembered auth card size wins over the default', () => {
|
||||
setViewportWidth(1400);
|
||||
recordAuthShellHint({pathname: '/login', mobile: false, kind: AuthShellHintKind.CARD, heightPx: 388, widthPx: 512});
|
||||
const storage = {'fluxer:ui:auth-shell-hint': localStorage.getItem('fluxer:ui:auth-shell-hint') ?? ''};
|
||||
|
||||
const shell = runSignedOutBootScript('/login', storage);
|
||||
|
||||
expect(shell?.dataset.hint).toBe('hint');
|
||||
expect(shell?.dataset.height).toBe('388');
|
||||
expect(shell?.querySelector<HTMLElement>('.fluxer-boot-auth-card')?.style.maxWidth).toBe('32rem');
|
||||
});
|
||||
|
||||
test('a remembered phone card is not used on a desktop-sized window', () => {
|
||||
setViewportWidth(1400);
|
||||
recordAuthShellHint({pathname: '/login', mobile: true, kind: AuthShellHintKind.CARD, heightPx: 500, widthPx: 342});
|
||||
const storage = {'fluxer:ui:auth-shell-hint': localStorage.getItem('fluxer:ui:auth-shell-hint') ?? ''};
|
||||
|
||||
expect(runSignedOutBootScript('/login', storage)?.dataset.height).toBe('214');
|
||||
});
|
||||
|
||||
test('a fresh desktop install paints the fullscreen welcome skeleton', () => {
|
||||
setViewportWidth(1400);
|
||||
setNativePlatform(true);
|
||||
try {
|
||||
const shell = runSignedOutBootScript('/login');
|
||||
|
||||
expect(shell?.dataset.auth).toBe('full');
|
||||
expect(shell?.querySelector('.fluxer-boot-auth-full')).not.toBeNull();
|
||||
expect(shell?.querySelector('.fluxer-boot-auth-wtext')?.textContent).toBe('Welcome');
|
||||
} finally {
|
||||
setNativePlatform(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the fullscreen welcome greets in the stored locale before the browser language', () => {
|
||||
setViewportWidth(1400);
|
||||
setNativePlatform(true);
|
||||
try {
|
||||
const shell = runSignedOutBootScript('/login', {locale: 'de'});
|
||||
|
||||
expect(shell?.querySelector('.fluxer-boot-auth-wtext')?.textContent).toBe('Willkommen');
|
||||
} finally {
|
||||
setNativePlatform(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the boot welcome words match the welcome rotation', () => {
|
||||
const table = /var t=(\{[^}]*\});/u.exec(readBootScript());
|
||||
expect(table).not.toBeNull();
|
||||
const words = new Function(`return ${table?.[1] ?? '{}'}`)() as Record<string, string>;
|
||||
|
||||
expect(words).toEqual(Object.fromEntries(WELCOME_ROTATION.map((entry) => [entry.code, entry.text])));
|
||||
});
|
||||
|
||||
test('a shell that stamps a stored session paints the app shell even before the preboot mirror exists', () => {
|
||||
setViewportWidth(1400);
|
||||
document.documentElement.setAttribute('data-boot-session', '1');
|
||||
try {
|
||||
const shell = runSignedOutBootScript('/channels/@me');
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.auth).toBeUndefined();
|
||||
} finally {
|
||||
document.documentElement.removeAttribute('data-boot-session');
|
||||
}
|
||||
});
|
||||
|
||||
test('a session an older client stored paints the app shell before the preboot mirror exists', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
const shell = runSignedOutBootScript('/channels/200/300', {token: 'legacy-token', userId: '100'});
|
||||
|
||||
expect(shell?.dataset.hint).toBe('default');
|
||||
expect(shell?.dataset.auth).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a half or emptied older session still paints the sign-in card', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runSignedOutBootScript('/channels/@me', {token: 'legacy-token'})?.dataset.auth).toBe('card');
|
||||
expect(runSignedOutBootScript('/channels/@me', {token: 'null', userId: '100'})?.dataset.auth).toBe('card');
|
||||
expect(
|
||||
runSignedOutBootScript('/channels/@me', {
|
||||
token: 'legacy-token',
|
||||
userId: '100',
|
||||
'fluxer:gateway:preboot:session': '0',
|
||||
})?.dataset.auth,
|
||||
).toBe('card');
|
||||
});
|
||||
|
||||
test('a signed-out boot leaves routes without an auth card alone', () => {
|
||||
setViewportWidth(1400);
|
||||
|
||||
expect(runSignedOutBootScript('/oauth2/authorize')).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{pathname: '/login', width: 1400, native: false},
|
||||
{pathname: '/login', width: 390, native: false},
|
||||
{pathname: '/login', width: 1400, native: true},
|
||||
{pathname: '/register', width: 1400, native: false},
|
||||
{pathname: '/register', width: 390, native: false},
|
||||
{pathname: '/forgot', width: 1400, native: false},
|
||||
{pathname: '/invite/abc', width: 390, native: false},
|
||||
])(
|
||||
'the inline auth skeleton picks what the app placeholder picks for $pathname at $width px',
|
||||
({pathname, width, native}) => {
|
||||
setViewportWidth(width);
|
||||
setNativePlatform(native);
|
||||
try {
|
||||
const shell = runSignedOutBootScript(pathname);
|
||||
const placeholder = resolveAuthShellPlaceholder(pathname, {
|
||||
mobile: width < 640,
|
||||
native,
|
||||
passkeyLikely: false,
|
||||
});
|
||||
|
||||
expect(shell?.dataset.category).toBe(placeholder?.category);
|
||||
expect(shell?.dataset.auth).toBe(placeholder?.kind);
|
||||
expect(shell?.dataset.hint).toBe(placeholder?.source);
|
||||
expect(Number(shell?.dataset.height)).toBe(placeholder?.heightPx);
|
||||
} finally {
|
||||
setNativePlatform(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
import {FriendsSkeleton} from '@app/features/app/components/skeleton/FriendsSkeleton';
|
||||
import {SHELL_HINT_VERSION} from '@app/features/app/components/skeleton/ShellHint';
|
||||
import {
|
||||
type RememberedSkeletonActiveNowCard,
|
||||
type RememberedSkeletonFriendsLayout,
|
||||
SKELETON_DEFAULT_FRIENDS_LAYOUT,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {act, createElement, type ReactNode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {afterEach, expect, test, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: ({children}: {children?: ReactNode}) => children ?? null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
|
||||
const memory = vi.hoisted(() => ({friends: null as RememberedSkeletonFriendsLayout | null}));
|
||||
|
||||
vi.mock('@app/features/app/components/skeleton/SkeletonLayoutMemory', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/features/app/components/skeleton/SkeletonLayoutMemory')>();
|
||||
return {...actual, getRememberedSkeletonFriendsLayout: () => memory.friends};
|
||||
});
|
||||
|
||||
const ACCOUNT_KEY = 'https://one.example/api::100';
|
||||
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
|
||||
|
||||
function readBootScript(): string {
|
||||
const body = html.slice(html.indexOf('<div id="root"></div>'));
|
||||
return /<script[^>]*>([\s\S]*?)<\/script>/u.exec(body)?.[1] ?? '';
|
||||
}
|
||||
|
||||
function friendsHint(extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
v: SHELL_HINT_VERSION,
|
||||
t: Date.now(),
|
||||
p: '/channels/@me',
|
||||
a: ACCOUNT_KEY,
|
||||
mo: 0,
|
||||
ua: 0,
|
||||
sv: 'dm',
|
||||
ck: 'other',
|
||||
cb: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function bootSidebar(hint: Record<string, unknown> | null): Element | null {
|
||||
document.body.innerHTML = '<div id="root"></div>';
|
||||
window.history.pushState({}, '', '/channels/@me');
|
||||
localStorage.clear();
|
||||
localStorage.setItem('fluxer:gateway:preboot:session', '1');
|
||||
localStorage.setItem('fluxer:auth:active-account-key', ACCOUNT_KEY);
|
||||
if (hint != null) {
|
||||
localStorage.setItem('fluxer:ui:shell-hint', JSON.stringify(hint));
|
||||
}
|
||||
new Function(readBootScript())();
|
||||
const shell = document.querySelector('#fluxer-boot-shell');
|
||||
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
|
||||
return shell?.querySelector('.fluxer-boot-fr-side') ?? null;
|
||||
}
|
||||
|
||||
async function reactSidebar(friends: RememberedSkeletonFriendsLayout | null): Promise<Element | null> {
|
||||
memory.friends = friends;
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(createElement(FriendsSkeleton)));
|
||||
const sidebar = container.querySelector('flx-app-friends-skeleton-sidebar');
|
||||
const copy = sidebar?.cloneNode(true) as Element | undefined;
|
||||
await act(async () => root.unmount());
|
||||
return copy ?? null;
|
||||
}
|
||||
|
||||
function bootGeometry(sidebar: Element | null): Array<string> {
|
||||
return Array.from(sidebar?.querySelectorAll('.fluxer-boot-block') ?? []).map((block) => {
|
||||
const style = block.getAttribute('style') ?? '';
|
||||
const width = /width:([^;]+);/.exec(style)?.[1];
|
||||
const height = /height:([^;]+);/.exec(style)?.[1];
|
||||
return `${width}x${height}`;
|
||||
});
|
||||
}
|
||||
|
||||
function reactGeometry(sidebar: Element | null): Array<string> {
|
||||
return Array.from(sidebar?.querySelectorAll('flx-skeleton-line, flx-skeleton-block') ?? []).map((block) => {
|
||||
const style = (block as HTMLElement).style;
|
||||
return `${style.getPropertyValue('--skeleton-width')}x${style.getPropertyValue('--skeleton-height')}`;
|
||||
});
|
||||
}
|
||||
|
||||
function layoutWith(activeNowVisible: boolean, cards: ReadonlyArray<RememberedSkeletonActiveNowCard>) {
|
||||
return {...SKELETON_DEFAULT_FRIENDS_LAYOUT, activeNowVisible, activeNowCards: cards};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
memory.friends = null;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('with nobody active the boot shell paints the same empty state React paints', async () => {
|
||||
const boot = bootSidebar(friendsHint({fa: 1, fn: []}));
|
||||
const react = await reactSidebar(layoutWith(true, []));
|
||||
|
||||
expect(boot?.querySelector('.fluxer-boot-fr-empty')).not.toBeNull();
|
||||
expect(boot?.querySelector('.fluxer-boot-fr-card')).toBeNull();
|
||||
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
|
||||
});
|
||||
|
||||
test('remembered cards paint with the same count and geometry as the React skeleton', async () => {
|
||||
const cards = [
|
||||
{participantCount: 0, streaming: false},
|
||||
{participantCount: 3, streaming: true},
|
||||
{participantCount: 7, streaming: false},
|
||||
];
|
||||
const boot = bootSidebar(
|
||||
friendsHint({
|
||||
fa: 1,
|
||||
fn: [
|
||||
[0, 0],
|
||||
[3, 1],
|
||||
[7, 0],
|
||||
],
|
||||
}),
|
||||
);
|
||||
const react = await reactSidebar(layoutWith(true, cards));
|
||||
|
||||
expect(boot?.querySelectorAll('.fluxer-boot-fr-card')).toHaveLength(3);
|
||||
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
|
||||
});
|
||||
|
||||
test('a hidden active now column paints no sidebar in either skeleton', async () => {
|
||||
expect(bootSidebar(friendsHint({fa: 0, fn: []}))).toBeNull();
|
||||
expect(await reactSidebar(layoutWith(false, []))).toBeNull();
|
||||
});
|
||||
|
||||
test('without any remembered layout neither skeleton invents activity cards', async () => {
|
||||
const boot = bootSidebar(null);
|
||||
const react = await reactSidebar(null);
|
||||
|
||||
expect(boot?.querySelector('.fluxer-boot-fr-card')).toBeNull();
|
||||
expect(react?.querySelector('flx-app-friends-skeleton-active-card')).toBeNull();
|
||||
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {initI18n} from '@app/app/I18n';
|
||||
import {ensureActiveLocale, initI18n} from '@app/app/I18n';
|
||||
import {BootstrapErrorScreen} from '@app/features/app/components/BootstrapErrorScreen';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -16,10 +16,14 @@ export async function reportBootstrapError(error: unknown): Promise<void> {
|
||||
if (!container) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
await initI18n();
|
||||
ensureActiveLocale();
|
||||
ReactDOM.createRoot(container).render(
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<BootstrapErrorScreen error={normalized} data-flx="index.bootstrap-error-screen" />
|
||||
</AppI18nProvider>,
|
||||
);
|
||||
globalThis.window?.electron?.notifyFirstContentPainted?.();
|
||||
initI18n().catch((localeError: unknown) => {
|
||||
logger.warn('Failed to load the locale for the bootstrap error screen:', localeError);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,6 +206,16 @@ function activateLocale(localeCode: LocaleCode, messages: Messages): void {
|
||||
AppStorage.setItem('locale', localeCode);
|
||||
}
|
||||
|
||||
export function ensureActiveLocale(): void {
|
||||
if (i18n.locale) {
|
||||
return;
|
||||
}
|
||||
i18n.loadAndActivate({locale: DEFAULT_LOCALE, messages: messagesEnUS});
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
const inFlightLoads = new Map<LocaleCode, Promise<Messages>>();
|
||||
let requestedLocale: LocaleCode | null = null;
|
||||
|
||||
@@ -246,6 +256,11 @@ export function applyLocaleChange(localeCode: string): LocaleCode {
|
||||
}
|
||||
|
||||
let initPromise: Promise<typeof i18n> | null = null;
|
||||
let initialized = false;
|
||||
|
||||
export function isI18nInitialized(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
export async function initI18n(forceLocale?: string) {
|
||||
if (!initPromise) {
|
||||
@@ -257,6 +272,7 @@ export async function initI18n(forceLocale?: string) {
|
||||
logger.error('Failed to initialize i18n, falling back to default locale', error);
|
||||
activateLocale(DEFAULT_LOCALE, messagesEnUS);
|
||||
}
|
||||
initialized = true;
|
||||
return i18n;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -362,6 +362,7 @@ html.platform-native.platform-macos {
|
||||
}
|
||||
|
||||
html {
|
||||
--custom-zoom: 100;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
@@ -369,35 +370,112 @@ html {
|
||||
user-select: var(--user-select);
|
||||
-webkit-user-select: var(--user-select);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-size: calc(var(--custom-zoom, 100) * 0.16px);
|
||||
line-height: 1.5;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
html.platform-native {
|
||||
--custom-zoom: 100;
|
||||
font-size: calc(var(--custom-zoom, 100) * 1%);
|
||||
}
|
||||
|
||||
html.allow-transparency,
|
||||
html.allow-transparency body {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#fluxer-startup-native-titlebar {
|
||||
.fluxer-native-titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--native-titlebar-height);
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 0 0 calc(var(--spacing-3) + env(safe-area-inset-left));
|
||||
background: var(--background-secondary);
|
||||
-webkit-app-region: drag;
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
html.platform-native:not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
.fluxer-native-titlebar[data-platform='macos'] {
|
||||
padding-left: calc(var(--macos-traffic-light-inset) + env(safe-area-inset-left));
|
||||
padding-right: calc(var(--spacing-3) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
html.platform-native:not(.window-focused) .fluxer-native-titlebar,
|
||||
html.platform-native.window-focus-activation-guard .fluxer-native-titlebar {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
color: var(--text-primary-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__wordmark {
|
||||
display: block;
|
||||
height: 0.8125rem;
|
||||
width: auto;
|
||||
color: var(--text-primary-muted);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
gap: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control {
|
||||
-webkit-app-region: no-drag;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
width: 3rem;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control svg {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--user-area-divider-color);
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control:active {
|
||||
background: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control--close:hover {
|
||||
background: #e81123;
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.fluxer-native-titlebar__control--close:active {
|
||||
background: #b50d1a;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {currentInstanceTarget} from '@app/features/platform/transport/InstanceHTTP';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {i18n} from '@lingui/core';
|
||||
|
||||
const logger = new Logger('ShortLinkAcceptModalOpener');
|
||||
|
||||
function openAcceptModal(kind: string, open: () => Promise<void>): void {
|
||||
void open().catch((error: unknown) => {
|
||||
logger.error(`Failed to open the ${kind} accept modal`, error);
|
||||
});
|
||||
}
|
||||
|
||||
export function openInvite(code: string): void {
|
||||
openAcceptModal('invite', async () => {
|
||||
const commands = await import('@app/features/invite/commands/InviteCommands');
|
||||
await commands.openAcceptModal(code, currentInstanceTarget());
|
||||
});
|
||||
}
|
||||
|
||||
export function openGift(code: string): void {
|
||||
openAcceptModal('gift', async () => {
|
||||
const commands = await import('@app/features/gift/commands/GiftCommands');
|
||||
await commands.openAcceptModal(code, currentInstanceTarget());
|
||||
});
|
||||
}
|
||||
|
||||
export function openTheme(themeId: string): void {
|
||||
openAcceptModal('theme', async () => {
|
||||
const commands = await import('@app/features/theme/commands/ThemeCommands');
|
||||
commands.openAcceptModal(themeId, i18n, RuntimeConfig.getSnapshot());
|
||||
});
|
||||
}
|
||||
@@ -15,18 +15,19 @@ import {
|
||||
import {isClientBooting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import {useLastLocationPersistence} from '@app/features/navigation/hooks/useLastLocationPersistence';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
import {navigateToWithMobileHistory} from '@app/features/navigation/utils/MobileNavigation';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import * as PushSubscriptionService from '@app/features/platform/push/PushSubscriptionService';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {resolveDocumentURLFromRoot} from '@app/features/platform/URLOriginUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {useHolidayClasses} from '@app/features/theme/hooks/useHolidayClasses';
|
||||
import Location from '@app/features/ui/state/Location';
|
||||
@@ -102,7 +103,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
if (
|
||||
isAuthenticated &&
|
||||
isLocationStateHydrated &&
|
||||
!AccountManager.isSwitching &&
|
||||
!Accounts.isSwitching &&
|
||||
pathname === Routes.ME &&
|
||||
!hasRestoredLocation
|
||||
) {
|
||||
@@ -115,7 +116,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
if (
|
||||
!isAuthenticated ||
|
||||
!isLocationStateHydrated ||
|
||||
AccountManager.isSwitching ||
|
||||
Accounts.isSwitching ||
|
||||
pathname !== Routes.ME ||
|
||||
hasRestoredLocation
|
||||
) {
|
||||
@@ -142,7 +143,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
}, [isAuthenticated]);
|
||||
const normalizeInternalUrl = useCallback((rawUrl: string): string => {
|
||||
try {
|
||||
const u = new URL(rawUrl, window.location.origin);
|
||||
const u = resolveDocumentURLFromRoot(rawUrl);
|
||||
if (u.origin === window.location.origin) {
|
||||
return u.pathname + u.search + u.hash;
|
||||
}
|
||||
@@ -157,7 +158,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
}, [shouldDismissTransientOverlays]);
|
||||
useEffect(() => {
|
||||
if (!SessionManager.isInitialized) return;
|
||||
if (AccountManager.isSwitching) return;
|
||||
if (Accounts.isSwitching) return;
|
||||
const isAuth = Authentication.isAuthenticated;
|
||||
if (isAuth && isStandaloneRoute) return;
|
||||
if (shouldBypassGateway) {
|
||||
@@ -180,17 +181,13 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
RouterUtils.replaceWith(setPathQueryParams(Routes.LOGIN, {redirect_to: pendingRedirectRef.current}));
|
||||
return;
|
||||
}
|
||||
if (isAuth && Initialization.isLoading) {
|
||||
void AuthenticationCommands.ensureSessionStarted();
|
||||
}
|
||||
}, [
|
||||
SessionManager.isInitialized,
|
||||
authToken,
|
||||
AccountManager.isSwitching,
|
||||
Accounts.isSwitching,
|
||||
Authentication.isAuthenticated,
|
||||
GatewayConnection.isConnected,
|
||||
GatewayConnection.isConnecting,
|
||||
Initialization.isLoading,
|
||||
shouldBypassGateway,
|
||||
shouldSkipAutoRedirect,
|
||||
pendingRedirectRef,
|
||||
@@ -238,12 +235,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
canNavigateToProtectedRoutes,
|
||||
location.pathname,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const shouldSaveLocation = Routes.isChannelRoute(location.pathname) || Routes.isSpecialPage(location.pathname);
|
||||
if (isAuthenticated && shouldSaveLocation) {
|
||||
Location.saveLocation(location.pathname);
|
||||
}
|
||||
}, [isAuthenticated, location.pathname]);
|
||||
useLastLocationPersistence(location.pathname);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !hasRestoredLocation) return;
|
||||
const previousMobileLayoutState = previousMobileLayoutStateRef.current;
|
||||
@@ -297,11 +289,22 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
}
|
||||
lastNotificationNavRef.current = {ts: now, key};
|
||||
void (async () => {
|
||||
if (targetUserId && targetUserId !== AccountManager.currentUserId && AccountManager.canSwitchAccounts) {
|
||||
const targetAccountKey =
|
||||
targetUserId == null ? null : (Accounts.getAccount(targetUserId)?.storageKey ?? null);
|
||||
if (targetUserId != null && targetUserId !== Accounts.currentUserId) {
|
||||
if (targetAccountKey == null || !Accounts.canSwitchAccounts) {
|
||||
logger.error('Cannot activate the notification target account', {targetUserId});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await AccountManager.switchToAccount(targetUserId);
|
||||
await Accounts.switchToAccount(targetAccountKey);
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch account for notification', error);
|
||||
return;
|
||||
}
|
||||
if (Accounts.currentUserId !== targetUserId) {
|
||||
logger.error('Notification target account was not activated', {targetUserId});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mobileLayoutState.enabled) {
|
||||
@@ -327,7 +330,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
if (urlParams.get('fromNotification') === '1') {
|
||||
const newParams = new URLSearchParams(urlParams);
|
||||
newParams.delete('fromNotification');
|
||||
const cleanUrl = new URL(location.pathname, window.location.origin);
|
||||
const cleanUrl = resolveDocumentURLFromRoot(location.pathname);
|
||||
cleanUrl.search = newParams.toString();
|
||||
const cleanPath = `${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`;
|
||||
if (mobileLayoutState.enabled) {
|
||||
@@ -370,7 +373,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
)}
|
||||
{(shouldShowLoadingSkeleton || isLoadingSkeletonRetained) && (
|
||||
<AppSkeleton
|
||||
key={`loading-skeleton:${AccountManager.currentUserId ?? 'unauthenticated'}`}
|
||||
key={`loading-skeleton:${Accounts.currentUserId ?? 'unauthenticated'}`}
|
||||
effectivePathname={effectiveSkeletonPathname ?? pendingRestorableSkeletonPathname ?? pathname}
|
||||
isExiting={!shouldShowLoadingSkeleton}
|
||||
onTransitionEnd={handleLoadingSkeletonTransitionEnd}
|
||||
|
||||
@@ -0,0 +1,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}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {GuildChannelRouter} from '@app/app/router/components/GuildChannelRouter';
|
||||
import {requireAuthentication, whenAuthenticated} from '@app/app/router/policy/AuthRouteRedirectPolicy';
|
||||
import {rootRoute} from '@app/app/router/routes/RootRoutes';
|
||||
import {AppBadge} from '@app/features/app/components/AppNotificationBadge';
|
||||
import {AppLayout} from '@app/features/app/components/layout/AppLayout';
|
||||
@@ -9,14 +10,15 @@ import {DiscoveryLayout} from '@app/features/app/components/layout/DiscoveryLayo
|
||||
import {FavoritesLayout} from '@app/features/app/components/layout/FavoritesLayout';
|
||||
import {GuildsLayout} from '@app/features/app/components/layout/GuildsLayout';
|
||||
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import {
|
||||
AuthenticatedRuntimeInvariantError,
|
||||
readAuthenticatedRuntimeContext,
|
||||
} from '@app/features/auth/state/AuthenticatedRuntime';
|
||||
import {ChannelIndexPage} from '@app/features/channel/components/ChannelIndexPage';
|
||||
import {ChannelLayout} from '@app/features/channel/components/ChannelLayout';
|
||||
import {DMLayout} from '@app/features/channel/components/direct_message/DirectMessageLayout';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import {navigateToLinkedUserProfile} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {getDirectMessagesFallbackPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
@@ -34,10 +36,18 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {type ReactNode, useEffect, useState} from 'react';
|
||||
|
||||
type AppRouteComponentProps = Record<string, unknown>;
|
||||
|
||||
function authenticatedRuntimeDisablesDirectMessages(): boolean {
|
||||
const runtimeContext = readAuthenticatedRuntimeContext();
|
||||
if (runtimeContext === null) {
|
||||
throw new AuthenticatedRuntimeInvariantError('An authenticated route requires an active account runtime');
|
||||
}
|
||||
return runtimeContext.runtime.community.direct_messages_disabled;
|
||||
}
|
||||
|
||||
const YouPage = createNamedLoadableComponent<AppRouteComponentProps>({
|
||||
displayName: 'YouPage',
|
||||
load: async () => (await import('@app/features/app/components/pages/YouPage')).YouPage,
|
||||
@@ -77,24 +87,24 @@ const StatusChangeBottomSheet = createNamedLoadableComponent<AppRouteComponentPr
|
||||
(await import('@app/features/user/components/modals/StatusChangeBottomSheet')).StatusChangeBottomSheet,
|
||||
});
|
||||
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'appLayout',
|
||||
onEnter: () => {
|
||||
if (!SessionManager.isInitialized) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Authentication.isAuthenticated) {
|
||||
const current = window.location.pathname + window.location.search;
|
||||
return new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
layout: ({children}) => (
|
||||
const ProtectedAppLayout = observer(({children}: {children: ReactNode}) => {
|
||||
if (readAuthenticatedRuntimeContext() === null && !SessionManager.isSwitching) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AppBadge data-flx="app.router.app-routes.layout.app-badge" />
|
||||
<AppLayout data-flx="app.router.app-routes.layout.app-layout">{children}</AppLayout>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'appLayout',
|
||||
onEnter: requireAuthentication,
|
||||
layout: ({children}) => (
|
||||
<ProtectedAppLayout data-flx="app.router.app-routes.layout.protected-app-layout">{children}</ProtectedAppLayout>
|
||||
),
|
||||
});
|
||||
const guildsLayoutRoute = createRoute({
|
||||
@@ -162,6 +172,7 @@ export const premiumCallbackRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'premiumCallback',
|
||||
path: Routes.PREMIUM_CALLBACK,
|
||||
onEnter: requireAuthentication,
|
||||
preload: PremiumCallbackPage.preload,
|
||||
component: () => <PremiumCallbackPage data-flx="app.router.app-routes.premium-callback-page" />,
|
||||
});
|
||||
@@ -197,12 +208,12 @@ const meRoute = createRoute({
|
||||
getParentRoute: () => guildsLayoutRoute,
|
||||
id: 'me',
|
||||
path: '/channels/@me',
|
||||
onEnter: () => {
|
||||
if (RuntimeConfig.directMessagesDisabled) {
|
||||
onEnter: whenAuthenticated(() => {
|
||||
if (authenticatedRuntimeDisablesDirectMessages()) {
|
||||
return new Redirect(getDirectMessagesFallbackPath());
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
component: observer(() => {
|
||||
const isMobileLayout = MobileLayout.enabled;
|
||||
useEffect(() => {
|
||||
@@ -297,9 +308,9 @@ const channelRoute = createRoute({
|
||||
getParentRoute: () => channelsRoute,
|
||||
id: 'channel',
|
||||
path: '/channels/:guildId/:channelId',
|
||||
onEnter: (ctx) => {
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const {guildId, channelId} = ctx.params;
|
||||
if (guildId === ME && RuntimeConfig.directMessagesDisabled) {
|
||||
if (guildId === ME && authenticatedRuntimeDisablesDirectMessages()) {
|
||||
return new Redirect(getDirectMessagesFallbackPath());
|
||||
}
|
||||
const channel = Channels.getChannel(channelId);
|
||||
@@ -307,7 +318,7 @@ const channelRoute = createRoute({
|
||||
return new Redirect(Routes.guildChannel(guildId));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
component: () => (
|
||||
<ChannelLayout data-flx="app.router.app-routes.channel-layout--2">
|
||||
<ChannelIndexPage data-flx="app.router.app-routes.channel-index-page--2" />
|
||||
@@ -318,9 +329,9 @@ const messageRoute = createRoute({
|
||||
getParentRoute: () => channelRoute,
|
||||
id: 'message',
|
||||
path: '/channels/:guildId/:channelId/:messageId',
|
||||
onEnter: (ctx) => {
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const {guildId, channelId} = ctx.params;
|
||||
if (guildId === ME && RuntimeConfig.directMessagesDisabled) {
|
||||
if (guildId === ME && authenticatedRuntimeDisablesDirectMessages()) {
|
||||
return new Redirect(getDirectMessagesFallbackPath());
|
||||
}
|
||||
const channel = Channels.getChannel(channelId);
|
||||
@@ -328,7 +339,7 @@ const messageRoute = createRoute({
|
||||
return new Redirect(Routes.guildChannel(guildId));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
component: () => (
|
||||
<ChannelLayout data-flx="app.router.app-routes.channel-layout--3">
|
||||
<ChannelIndexPage data-flx="app.router.app-routes.channel-index-page--3" />
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import * as AuthRouteRedirectPolicy from '@app/app/router/policy/AuthRouteRedirectPolicy';
|
||||
import * as ShortLinkRouteEnterPolicy from '@app/app/router/policy/ShortLinkRouteEnterPolicy';
|
||||
import {rootRoute} from '@app/app/router/routes/RootRoutes';
|
||||
import * as ShortLinkAcceptModalOpener from '@app/app/router/ShortLinkAcceptModalOpener';
|
||||
import {AuthLayout} from '@app/features/app/components/layout/AuthLayout';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {
|
||||
type AuthRoutePage,
|
||||
createAuthRoutePage,
|
||||
createNamedAuthRoutePage,
|
||||
} from '@app/features/auth/flow/AuthLoadableRoutePage';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import {safeRedirectTarget, safeRedirectTargetOrFallback} from '@app/features/auth/utils/SafeRedirect';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {createRoute} from '@app/features/platform/components/router/RouterBuilder';
|
||||
import type {RouteConfig, RouteContext} from '@app/features/platform/components/router/RouterTypes';
|
||||
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {type AuthRoutePage, createAuthRoutePage} from '@app/features/auth/flow/AuthLoadableRoutePage';
|
||||
import {createRoute, type RouteBuilder} from '@app/features/platform/components/router/RouterBuilder';
|
||||
import {NotFound, Redirect, type RouteContext} from '@app/features/platform/components/router/RouterTypes';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {i18n} from '@lingui/core';
|
||||
|
||||
const AuthorizeIPPage = createAuthRoutePage(
|
||||
'AuthorizeIPPage',
|
||||
@@ -50,8 +41,8 @@ const ResetPasswordPage = createAuthRoutePage(
|
||||
'ResetPasswordPage',
|
||||
() => import('@app/features/auth/components/pages/ResetPasswordPage'),
|
||||
);
|
||||
const SsoCallbackPage = createAuthRoutePage(
|
||||
'SsoCallbackPage',
|
||||
const SSOCallbackPage = createAuthRoutePage(
|
||||
'SSOCallbackPage',
|
||||
() => import('@app/features/auth/components/pages/SsoCallbackPage'),
|
||||
);
|
||||
const VerifyEmailPage = createAuthRoutePage(
|
||||
@@ -74,10 +65,9 @@ const InviteRegisterPage = createAuthRoutePage(
|
||||
'InviteRegisterPage',
|
||||
() => import('@app/features/invite/components/pages/InviteRegisterPage'),
|
||||
);
|
||||
const ReportPage = createNamedAuthRoutePage('ReportPage', async () => {
|
||||
const module = await import('@app/features/moderation/components/pages/ReportPage');
|
||||
return module.ReportPage;
|
||||
});
|
||||
const ReportPage = createAuthRoutePage('ReportPage', async () => ({
|
||||
default: (await import('@app/features/moderation/components/pages/ReportPage')).ReportPage,
|
||||
}));
|
||||
const ThemeLoginPage = createAuthRoutePage(
|
||||
'ThemeLoginPage',
|
||||
() => import('@app/features/theme/components/pages/ThemeLoginPage'),
|
||||
@@ -87,81 +77,23 @@ const ThemeRegisterPage = createAuthRoutePage(
|
||||
() => import('@app/features/theme/components/pages/ThemeRegisterPage'),
|
||||
);
|
||||
|
||||
const currentRedirectTarget = (fallback: string): string => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
return safeRedirectTargetOrFallback(qp.get('redirect_to'), fallback);
|
||||
};
|
||||
|
||||
const resolveToPath = (to: Redirect['to']): string => {
|
||||
if (typeof to === 'string') {
|
||||
return to;
|
||||
}
|
||||
const url = new URL(to.to, window.location.origin);
|
||||
if (to.search) {
|
||||
url.search = '';
|
||||
for (const [k, v] of Object.entries(to.search)) {
|
||||
if (v === undefined) continue;
|
||||
if (v === null) {
|
||||
url.searchParams.set(k, '');
|
||||
} else {
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (to.hash) {
|
||||
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
};
|
||||
|
||||
type AuthRedirectHandler = (ctx: RouteContext) => Redirect | undefined;
|
||||
|
||||
const redirectWhenEmailsDisabled: RouteConfig['onEnter'] = () => {
|
||||
if (!RuntimeConfig.emailsEnabled) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const redirectWhenPasswordResetUnavailable: RouteConfig['onEnter'] = () => {
|
||||
if (!RuntimeConfig.emailsEnabled && !RuntimeConfig.usesUsernameSignIn) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const whenAuthenticated = (handler: AuthRedirectHandler) => {
|
||||
return (ctx: RouteContext): Redirect | undefined => {
|
||||
const execute = (): Redirect | undefined => handler(ctx);
|
||||
if (SessionManager.isInitialized) {
|
||||
return Authentication.isAuthenticated ? execute() : undefined;
|
||||
}
|
||||
void SessionManager.initialize().then(() => {
|
||||
if (Authentication.isAuthenticated) {
|
||||
const res = execute();
|
||||
if (res instanceof Redirect) {
|
||||
RouterUtils.replaceWith(resolveToPath(res.to));
|
||||
}
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
const authLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'authLayout',
|
||||
layout: ({children}) => <AuthLayout data-flx="app.router.auth-routes.layout.auth-layout">{children}</AuthLayout>,
|
||||
});
|
||||
|
||||
type AuthPageEnterHandler = (context: RouteContext) => Redirect | NotFound | undefined;
|
||||
|
||||
interface AuthPageRouteOptions {
|
||||
id: string;
|
||||
path: string;
|
||||
page: AuthRoutePage;
|
||||
dataFlx: string;
|
||||
onEnter?: RouteConfig['onEnter'];
|
||||
onEnter?: AuthPageEnterHandler;
|
||||
}
|
||||
|
||||
function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageRouteOptions) {
|
||||
function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageRouteOptions): RouteBuilder {
|
||||
return createRoute({
|
||||
getParentRoute: () => authLayoutRoute,
|
||||
id,
|
||||
@@ -172,7 +104,7 @@ function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageR
|
||||
});
|
||||
}
|
||||
|
||||
function createAuthRedirectRoute(id: string, path: string) {
|
||||
function createAuthRedirectRoute(id: string, path: string): RouteBuilder {
|
||||
return createRoute({
|
||||
getParentRoute: () => authLayoutRoute,
|
||||
id,
|
||||
@@ -181,25 +113,84 @@ function createAuthRedirectRoute(id: string, path: string) {
|
||||
});
|
||||
}
|
||||
|
||||
interface ShortLinkRoutePaths {
|
||||
readonly register: string;
|
||||
readonly login: string;
|
||||
}
|
||||
|
||||
interface ShortLinkAuthRouteOptions {
|
||||
id: string;
|
||||
dataFlxPrefix: string;
|
||||
paths: ShortLinkRoutePaths;
|
||||
registerPage: AuthRoutePage;
|
||||
loginPage: AuthRoutePage;
|
||||
handlers: {readonly onRegisterEnter: AuthPageEnterHandler; readonly onLoginEnter: AuthPageEnterHandler};
|
||||
}
|
||||
|
||||
function createShortLinkAuthRoutes({
|
||||
id,
|
||||
dataFlxPrefix,
|
||||
paths,
|
||||
registerPage,
|
||||
loginPage,
|
||||
handlers,
|
||||
}: ShortLinkAuthRouteOptions): Array<RouteBuilder> {
|
||||
return [
|
||||
createAuthPageRoute({
|
||||
id: `${id}Register`,
|
||||
path: paths.register,
|
||||
page: registerPage,
|
||||
dataFlx: `${dataFlxPrefix}-register-page`,
|
||||
onEnter: handlers.onRegisterEnter,
|
||||
}),
|
||||
createAuthPageRoute({
|
||||
id: `${id}Login`,
|
||||
path: paths.login,
|
||||
page: loginPage,
|
||||
dataFlx: `${dataFlxPrefix}-login-page`,
|
||||
onEnter: handlers.onLoginEnter,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const inviteRouteEnterHandlers = ShortLinkRouteEnterPolicy.createHandlers({
|
||||
paramName: 'code',
|
||||
openAcceptModal: ShortLinkAcceptModalOpener.openInvite,
|
||||
});
|
||||
const giftShortLinkHandlers = ShortLinkRouteEnterPolicy.createHandlers({
|
||||
paramName: 'code',
|
||||
openAcceptModal: ShortLinkAcceptModalOpener.openGift,
|
||||
});
|
||||
|
||||
function whenGiftingAvailable(handler: AuthRouteRedirectPolicy.AuthRouteEnterHandler): AuthPageEnterHandler {
|
||||
return (context: RouteContext): Redirect | NotFound | undefined => {
|
||||
if (RuntimeConfig.getSnapshotOrNull() !== null && !shouldShowPremiumFeatures()) {
|
||||
return new NotFound();
|
||||
}
|
||||
return handler(context);
|
||||
};
|
||||
}
|
||||
|
||||
const giftRouteEnterHandlers = {
|
||||
onRegisterEnter: whenGiftingAvailable(giftShortLinkHandlers.onRegisterEnter),
|
||||
onLoginEnter: whenGiftingAvailable(giftShortLinkHandlers.onLoginEnter),
|
||||
};
|
||||
const themeRouteEnterHandlers = ShortLinkRouteEnterPolicy.createHandlers({
|
||||
paramName: 'themeId',
|
||||
openAcceptModal: ShortLinkAcceptModalOpener.openTheme,
|
||||
});
|
||||
|
||||
const loginRoute = createAuthPageRoute({
|
||||
id: 'login',
|
||||
path: '/login',
|
||||
page: LoginPage,
|
||||
dataFlx: 'app.router.auth-routes.login-page',
|
||||
onEnter: whenAuthenticated(() => {
|
||||
const search = window.location.search;
|
||||
const qp = new URLSearchParams(search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const redirectTo = safeRedirectTarget(qp.get('redirect_to'));
|
||||
return new Redirect(redirectTo || Routes.ME);
|
||||
}),
|
||||
onEnter: AuthRouteRedirectPolicy.whenAuthenticated(AuthRouteRedirectPolicy.resolveAuthenticatedLoginEntry),
|
||||
});
|
||||
const ssoCallbackRoute = createAuthPageRoute({
|
||||
id: 'ssoCallback',
|
||||
path: Routes.SSO_CALLBACK,
|
||||
page: SsoCallbackPage,
|
||||
page: SSOCallbackPage,
|
||||
dataFlx: 'app.router.auth-routes.sso-callback-page',
|
||||
});
|
||||
const inviteBaseRoute = createAuthRedirectRoute('inviteBase', '/invite');
|
||||
@@ -216,137 +207,58 @@ const oauthAuthorizeRoute = createAuthPageRoute({
|
||||
path: Routes.OAUTH_AUTHORIZE,
|
||||
page: OAuthAuthorizePage,
|
||||
dataFlx: 'app.router.auth-routes.o-auth-authorize-page',
|
||||
onEnter: () => {
|
||||
const current = window.location.pathname + window.location.search;
|
||||
if (!SessionManager.isInitialized) {
|
||||
void SessionManager.initialize().then(() => {
|
||||
if (!Authentication.isAuthenticated) {
|
||||
RouterUtils.replaceWith(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (!Authentication.isAuthenticated) {
|
||||
return new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
onEnter: AuthRouteRedirectPolicy.requireAuthentication,
|
||||
});
|
||||
const inviteRegisterRoute = createAuthPageRoute({
|
||||
id: 'inviteRegister',
|
||||
path: '/invite/:code',
|
||||
page: InviteRegisterPage,
|
||||
dataFlx: 'app.router.auth-routes.invite-register-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/invite/commands/InviteCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
const inviteRoutes = createShortLinkAuthRoutes({
|
||||
id: 'invite',
|
||||
dataFlxPrefix: 'app.router.auth-routes.invite',
|
||||
paths: {register: Routes.INVITE_REGISTER, login: Routes.INVITE_LOGIN},
|
||||
registerPage: InviteRegisterPage,
|
||||
loginPage: InviteLoginPage,
|
||||
handlers: inviteRouteEnterHandlers,
|
||||
});
|
||||
const inviteLoginRoute = createAuthPageRoute({
|
||||
id: 'inviteLogin',
|
||||
path: '/invite/:code/login',
|
||||
page: InviteLoginPage,
|
||||
dataFlx: 'app.router.auth-routes.invite-login-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/invite/commands/InviteCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
});
|
||||
const giftRegisterRoute = createAuthPageRoute({
|
||||
id: 'giftRegister',
|
||||
path: '/gift/:code',
|
||||
page: GiftRegisterPage,
|
||||
dataFlx: 'app.router.auth-routes.gift-register-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/gift/commands/GiftCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
});
|
||||
const giftLoginRoute = createAuthPageRoute({
|
||||
id: 'giftLogin',
|
||||
path: '/gift/:code/login',
|
||||
page: GiftLoginPage,
|
||||
dataFlx: 'app.router.auth-routes.gift-login-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = ctx.params['code'];
|
||||
if (code) {
|
||||
void import('@app/features/gift/commands/GiftCommands').then((commands) => {
|
||||
commands.openAcceptModal(code);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
const giftRoutes = createShortLinkAuthRoutes({
|
||||
id: 'gift',
|
||||
dataFlxPrefix: 'app.router.auth-routes.gift',
|
||||
paths: {register: Routes.GIFT_REGISTER, login: Routes.GIFT_LOGIN},
|
||||
registerPage: GiftRegisterPage,
|
||||
loginPage: GiftLoginPage,
|
||||
handlers: giftRouteEnterHandlers,
|
||||
});
|
||||
const forgotPasswordRoute = createAuthPageRoute({
|
||||
id: 'forgotPassword',
|
||||
path: Routes.FORGOT_PASSWORD,
|
||||
page: ForgotPasswordPage,
|
||||
dataFlx: 'app.router.auth-routes.forgot-password-page',
|
||||
onEnter: (ctx) => {
|
||||
if (RuntimeConfig.usesUsernameSignIn) {
|
||||
return new Redirect(Routes.RECOVER_ACCOUNT);
|
||||
}
|
||||
if (!RuntimeConfig.emailsEnabled) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return whenAuthenticated(() => new Redirect(Routes.ME))(ctx);
|
||||
},
|
||||
onEnter: AuthRouteRedirectPolicy.resolveForgotPasswordEntry,
|
||||
});
|
||||
const recoverAccountRoute = createAuthPageRoute({
|
||||
id: 'recoverAccount',
|
||||
path: Routes.RECOVER_ACCOUNT,
|
||||
page: RecoverAccountPage,
|
||||
dataFlx: 'app.router.auth-routes.recover-account-page',
|
||||
onEnter: (ctx) => {
|
||||
if (!RuntimeConfig.usesUsernameSignIn) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return whenAuthenticated(() => new Redirect(Routes.ME))(ctx);
|
||||
},
|
||||
onEnter: AuthRouteRedirectPolicy.resolveRecoverAccountEntry,
|
||||
});
|
||||
const resetPasswordRoute = createAuthPageRoute({
|
||||
id: 'resetPassword',
|
||||
path: Routes.RESET_PASSWORD,
|
||||
page: ResetPasswordPage,
|
||||
dataFlx: 'app.router.auth-routes.reset-password-page',
|
||||
onEnter: redirectWhenPasswordResetUnavailable,
|
||||
onEnter: AuthRouteRedirectPolicy.resolvePasswordResetEntry,
|
||||
});
|
||||
const emailRevertRoute = createAuthPageRoute({
|
||||
id: 'emailRevert',
|
||||
path: Routes.EMAIL_REVERT,
|
||||
page: EmailRevertPage,
|
||||
dataFlx: 'app.router.auth-routes.email-revert-page',
|
||||
onEnter: redirectWhenEmailsDisabled,
|
||||
onEnter: AuthRouteRedirectPolicy.resolveEmailFeatureEntry,
|
||||
});
|
||||
const verifyEmailRoute = createAuthPageRoute({
|
||||
id: 'verifyEmail',
|
||||
path: Routes.VERIFY_EMAIL,
|
||||
page: VerifyEmailPage,
|
||||
dataFlx: 'app.router.auth-routes.verify-email-page',
|
||||
onEnter: redirectWhenEmailsDisabled,
|
||||
onEnter: AuthRouteRedirectPolicy.resolveEmailFeatureEntry,
|
||||
});
|
||||
const authorizeIPRoute = createAuthPageRoute({
|
||||
id: 'authorizeIP',
|
||||
@@ -366,34 +278,14 @@ const themeRegisterRoute = createAuthPageRoute({
|
||||
path: Routes.THEME_REGISTER,
|
||||
page: ThemeRegisterPage,
|
||||
dataFlx: 'app.router.auth-routes.theme-register-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const themeId = ctx.params.themeId;
|
||||
if (themeId) {
|
||||
void import('@app/features/theme/commands/ThemeCommands').then((commands) => {
|
||||
commands.openAcceptModal(themeId, i18n);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
onEnter: themeRouteEnterHandlers.onRegisterEnter,
|
||||
});
|
||||
const themeLoginRoute = createAuthPageRoute({
|
||||
id: 'themeLogin',
|
||||
path: Routes.THEME_LOGIN,
|
||||
page: ThemeLoginPage,
|
||||
dataFlx: 'app.router.auth-routes.theme-login-page',
|
||||
onEnter: whenAuthenticated((ctx) => {
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
if (isHandoffRequest(qp)) {
|
||||
return undefined;
|
||||
}
|
||||
const themeId = ctx.params.themeId;
|
||||
if (themeId) {
|
||||
void import('@app/features/theme/commands/ThemeCommands').then((commands) => {
|
||||
commands.openAcceptModal(themeId, i18n);
|
||||
});
|
||||
}
|
||||
return new Redirect(currentRedirectTarget(Routes.ME));
|
||||
}),
|
||||
onEnter: themeRouteEnterHandlers.onLoginEnter,
|
||||
});
|
||||
|
||||
export const authRouteTree = authLayoutRoute.addChildren([
|
||||
@@ -404,8 +296,8 @@ export const authRouteTree = authLayoutRoute.addChildren([
|
||||
inviteBaseRoute,
|
||||
giftBaseRoute,
|
||||
themeBaseRoute,
|
||||
inviteRegisterRoute,
|
||||
inviteLoginRoute,
|
||||
...inviteRoutes,
|
||||
...giftRoutes,
|
||||
themeRegisterRoute,
|
||||
themeLoginRoute,
|
||||
forgotPasswordRoute,
|
||||
@@ -416,5 +308,4 @@ export const authRouteTree = authLayoutRoute.addChildren([
|
||||
authorizeIPRoute,
|
||||
pendingRoute,
|
||||
reportRoute,
|
||||
...(shouldShowPremiumFeatures() ? [giftRegisterRoute, giftLoginRoute] : []),
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {RootComponent} from '@app/app/router/components/RootComponent';
|
||||
import {afterSessionInitialization} from '@app/app/router/policy/AuthRouteRedirectPolicy';
|
||||
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
|
||||
import {readAuthenticatedRuntimeContext} from '@app/features/auth/state/AuthenticatedRuntime';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
import {createRootRoute, createRoute} from '@app/features/platform/components/router/RouterBuilder';
|
||||
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
|
||||
@@ -21,7 +23,12 @@ export const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'home',
|
||||
path: '/',
|
||||
onEnter: () => new Redirect(getDefaultLandingPath()),
|
||||
onEnter: afterSessionInitialization(() => {
|
||||
if (readAuthenticatedRuntimeContext() === null) {
|
||||
return new Redirect(Routes.LOGIN);
|
||||
}
|
||||
return new Redirect(getDefaultLandingPath());
|
||||
}),
|
||||
});
|
||||
export const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
|
||||
Vendored
+1
-14
@@ -2,7 +2,6 @@
|
||||
|
||||
import MediaEngineFacade from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import type {ElectronAPI} from '@app/features/platform/types/Electron';
|
||||
import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import {Buffer} from 'buffer';
|
||||
|
||||
type MediaEngineInstance = typeof MediaEngineFacade;
|
||||
@@ -21,15 +20,6 @@ interface FluxerDebugApi {
|
||||
|
||||
type FluxerDebugGlobal = Record<string, unknown> & FluxerDebugApi;
|
||||
|
||||
interface FluxerBootstrapGlobal {
|
||||
config: {
|
||||
releaseChannel: 'stable' | 'canary';
|
||||
bootstrapApiEndpoint: string;
|
||||
bootstrapApiPublicEndpoint?: string;
|
||||
};
|
||||
instance: InstanceDiscoveryResponse;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface FilePickerAcceptType {
|
||||
description?: string;
|
||||
@@ -56,9 +46,7 @@ declare global {
|
||||
readonly DEV: boolean;
|
||||
readonly PROD: boolean;
|
||||
readonly PUBLIC_BUILD_VERSION?: string;
|
||||
readonly PUBLIC_RELEASE_CHANNEL?: 'stable' | 'canary';
|
||||
readonly PUBLIC_BOOTSTRAP_API_ENDPOINT?: string;
|
||||
readonly PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT?: string;
|
||||
readonly PUBLIC_RELEASE_CHANNEL?: 'stable' | 'canary' | 'development';
|
||||
}
|
||||
interface ImportMetaHot {
|
||||
readonly data: Record<string, unknown>;
|
||||
@@ -77,7 +65,6 @@ declare global {
|
||||
};
|
||||
}
|
||||
interface Window {
|
||||
__FLUXER_BOOTSTRAP__?: FluxerBootstrapGlobal;
|
||||
__FLUXER_DEBUG__?: FluxerDebugGlobal;
|
||||
__notificationCleanup?: () => void;
|
||||
_mediaEngine?: MediaEngineInstance;
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
readStoredSessionUserId,
|
||||
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
|
||||
import AppStorage from '@app/features/platform/state/PersistentStorage';
|
||||
import {
|
||||
readRawStorageItem,
|
||||
writeRawStorageItem,
|
||||
ZOOM_PREBOOT_MIRROR_STORAGE_KEY,
|
||||
} from '@app/features/platform/state/PrebootMirror';
|
||||
import {initializeStore} from '@app/features/platform/utils/StoreInitialization';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {applyAppZoomToDocument} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {makeSyncedField} from '@app/features/user/state/SyncedField';
|
||||
@@ -151,6 +157,18 @@ function readLocalZoomLevel(storage: StartupSettingsStorage = AppStorage): numbe
|
||||
return typeof parsed === 'number' ? clampZoomLevel(parsed) : null;
|
||||
}
|
||||
|
||||
function readPrebootZoomFactor(): number | null {
|
||||
if (window.electron == null) {
|
||||
return null;
|
||||
}
|
||||
const raw = readRawStorageItem(ZOOM_PREBOOT_MIRROR_STORAGE_KEY);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const zoomPercent = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(zoomPercent) ? clampZoomLevel(zoomPercent / 100) : null;
|
||||
}
|
||||
|
||||
function readStoredBoolean(storage: StartupSettingsStorage, key: string): boolean | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
@@ -458,7 +476,7 @@ function readAccessibilityStartupSettings(
|
||||
storage: StartupSettingsStorage = AppStorage,
|
||||
options: AccessibilityStartupSettingsOptions = {},
|
||||
): AccessibilityStartupSettings | null {
|
||||
const localZoomLevel = readLocalZoomLevel(storage);
|
||||
const localZoomLevel = readPrebootZoomFactor() ?? readLocalZoomLevel(storage);
|
||||
const localMotionSettings = readLocalMotionSettings(storage);
|
||||
const legacySettings = readLegacyAccessibilityStartupSettings(storage);
|
||||
const cachedSyncedMotionSettings = localMotionSettings === null ? readCachedSyncedMotionSettings(storage) : null;
|
||||
@@ -504,6 +522,15 @@ function persistLocalZoomLevel(level: number): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function writeZoomPrebootMirror(level: number, isDesktop: boolean): void {
|
||||
if (!isDesktop) {
|
||||
writeRawStorageItem(ZOOM_PREBOOT_MIRROR_STORAGE_KEY, null);
|
||||
return;
|
||||
}
|
||||
const zoomPercent = Math.max(50, Math.min(200, Math.round(clampZoomLevel(level) * 100)));
|
||||
writeRawStorageItem(ZOOM_PREBOOT_MIRROR_STORAGE_KEY, String(zoomPercent));
|
||||
}
|
||||
|
||||
function persistLocalMotionSettings(settings: LocalMotionSettings): void {
|
||||
try {
|
||||
AppStorage.setItem(ACCESSIBILITY_MOTION_STORAGE_KEY, JSON.stringify(settings));
|
||||
@@ -779,7 +806,7 @@ class Accessibility {
|
||||
this.initializeShowNekoStorageSync();
|
||||
this.initializeVideoSeekPreviewThumbnailsStorageSync();
|
||||
this.applyStartupPresentationSettings();
|
||||
this.initPersistence();
|
||||
initializeStore(this, () => this.initPersistence());
|
||||
}
|
||||
|
||||
get isHydrated(): boolean {
|
||||
@@ -1479,6 +1506,7 @@ class Accessibility {
|
||||
async applyZoom(level: number): Promise<void> {
|
||||
const zoomLevel = clampZoomLevel(level);
|
||||
applyAppZoomToDocument(zoomLevel * 100, window.electron);
|
||||
writeZoomPrebootMirror(zoomLevel, window.electron != null);
|
||||
}
|
||||
|
||||
async applyStoredZoom(): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
|
||||
class StalledConnectionAccountSwitchUnavailableError extends Error {
|
||||
@@ -14,13 +16,21 @@ export function canSwitchAccountFromStalledConnection(): boolean {
|
||||
return SessionManager.canSwitchAccount() || SessionManager.isConnecting;
|
||||
}
|
||||
|
||||
export async function switchAccountFromStalledConnection(userId: string): Promise<void> {
|
||||
export async function switchAccountFromStalledConnection(accountKey: string): Promise<void> {
|
||||
if (!canSwitchAccountFromStalledConnection()) {
|
||||
throw new StalledConnectionAccountSwitchUnavailableError();
|
||||
}
|
||||
SessionManager.requireAccountOnCurrentInstance(userId);
|
||||
SessionManager.requireSwitchableAccount(accountKey);
|
||||
if (SessionManager.isConnecting) {
|
||||
SessionManager.handleConnectionFailed();
|
||||
}
|
||||
await AccountManager.switchToAccount(userId, null);
|
||||
await Accounts.switchToAccount(accountKey, Routes.ME);
|
||||
}
|
||||
|
||||
export function retryStalledConnection(): void {
|
||||
const accountKey = SessionManager.currentAccountKey;
|
||||
if (accountKey === null) {
|
||||
return;
|
||||
}
|
||||
GatewayConnection.recoverForegroundSession(accountKey);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import DesktopBackgroundGateway from '@app/features/gateway/transport/DesktopBackgroundGateway';
|
||||
import GuildReadState from '@app/features/guild/state/GuildReadState';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
@@ -39,6 +40,7 @@ const setElectronBadge = (badge: number): void => {
|
||||
}
|
||||
};
|
||||
const setFaviconBadge = (badge: number): void => {
|
||||
if (badge === 0 && favico === null) return;
|
||||
const fav = initFavico();
|
||||
if (!fav) return;
|
||||
try {
|
||||
@@ -75,7 +77,7 @@ const setBadge = (badge: number): void => {
|
||||
export const AppBadge: React.FC = observer(() => {
|
||||
const relationships = Relationships.getRelationships();
|
||||
const unreadMessageBadgeEnabled = Notification.unreadMessageBadgeEnabled;
|
||||
const mentionCount = GuildReadState.mentionCountAcrossGuilds();
|
||||
const mentionCount = GuildReadState.mentionCountAcrossGuilds() + DesktopBackgroundGateway.totalMentionCount;
|
||||
const hasUnread = GuildReadState.anyGuildUnread;
|
||||
const pendingCount = RuntimeConfig.directMessagesDisabled
|
||||
? 0
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BootstrapErrorKind, classifyBootstrapError} from '@app/features/app/components/BootstrapErrorKind';
|
||||
import {InstanceDiscoveryUnreachableError} from '@fluxer/instance_bootstrap/src/Discovery';
|
||||
import {expect, test} from 'vitest';
|
||||
|
||||
const DESKTOP_UNREACHABLE = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryUnreachableError: No usable instance discovery document was served for https://web.canary.fluxer.app/api: https://web.canary.fluxer.app/.well-known/fluxer (connect ECONNREFUSED 127.0.0.1:9)",
|
||||
);
|
||||
|
||||
test('a desktop discovery that could not reach the instance is a connection failure', () => {
|
||||
expect(classifyBootstrapError(DESKTOP_UNREACHABLE, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('a web discovery that no candidate served is a connection failure', () => {
|
||||
const error = new InstanceDiscoveryUnreachableError([{url: 'https://one.example', reason: 'Failed to fetch'}]);
|
||||
|
||||
expect(classifyBootstrapError(error, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('an unreachable instance wrapped as a cause or inside an aggregate is still a connection failure', () => {
|
||||
const wrapped = new Error('Failed to restore the session', {cause: DESKTOP_UNREACHABLE});
|
||||
const aggregate = new AggregateError([new Error('cancellation was rejected'), wrapped], 'Instance discovery failed');
|
||||
|
||||
expect(classifyBootstrapError(wrapped, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
expect(classifyBootstrapError(aggregate, true)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('any failure while the device is offline is a connection failure', () => {
|
||||
expect(classifyBootstrapError(new TypeError('Failed to fetch'), false)).toBe(BootstrapErrorKind.UNREACHABLE);
|
||||
});
|
||||
|
||||
test('an instance that answered without a usable document, or a local failure, is not a connection failure', () => {
|
||||
const refused = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryFailedError: No usable instance discovery document was served for https://one.example/api: https://one.example/.well-known/fluxer (responded HTTP 404)",
|
||||
);
|
||||
|
||||
expect(classifyBootstrapError(refused, true)).toBe(BootstrapErrorKind.FAILED);
|
||||
expect(classifyBootstrapError(new Error('Stored account record is malformed'), true)).toBe(BootstrapErrorKind.FAILED);
|
||||
expect(classifyBootstrapError('boom', true)).toBe(BootstrapErrorKind.FAILED);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DESKTOP_RUNTIME_DISCOVERY_UNREACHABLE_ERROR_NAME} from '@fluxer/desktop_ipc/src/LocalAppRuntimeContract';
|
||||
import {InstanceDiscoveryUnreachableError} from '@fluxer/instance_bootstrap/src/Discovery';
|
||||
|
||||
export const BootstrapErrorKind = Object.freeze({
|
||||
UNREACHABLE: 'unreachable',
|
||||
FAILED: 'failed',
|
||||
} as const);
|
||||
|
||||
export type BootstrapErrorKind = (typeof BootstrapErrorKind)[keyof typeof BootstrapErrorKind];
|
||||
|
||||
const MAX_CAUSE_DEPTH = 8;
|
||||
|
||||
function reportsUnreachableInstance(error: unknown, depth: number): boolean {
|
||||
if (!(error instanceof Error) || depth > MAX_CAUSE_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
error instanceof InstanceDiscoveryUnreachableError ||
|
||||
error.message.includes(DESKTOP_RUNTIME_DISCOVERY_UNREACHABLE_ERROR_NAME)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof AggregateError && error.errors.some((inner) => reportsUnreachableInstance(inner, depth + 1))) {
|
||||
return true;
|
||||
}
|
||||
return reportsUnreachableInstance(error.cause, depth + 1);
|
||||
}
|
||||
|
||||
export function classifyBootstrapError(error: unknown, online: boolean): BootstrapErrorKind {
|
||||
return !online || reportsUnreachableInstance(error, 0) ? BootstrapErrorKind.UNREACHABLE : BootstrapErrorKind.FAILED;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {afterEach, expect, test, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: ({children}: {children?: ReactNode}) => children ?? null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
vi.mock('@app/features/platform/state/ResetClientState', () => ({
|
||||
ResetClientStateReason: {RESET_APP_DATA: 'reset_app_data'},
|
||||
resetClientState: vi.fn(),
|
||||
}));
|
||||
|
||||
const {BootstrapErrorScreen} = await import('@app/features/app/components/BootstrapErrorScreen');
|
||||
|
||||
const RESET_BUTTON = 'app.bootstrap-error-screen.button.reset';
|
||||
const RETRY_BUTTON = 'app.bootstrap-error-screen.button.retry';
|
||||
const COPY_DETAILS_BUTTON = 'app.bootstrap-error-screen.button.copy-details';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('an unreachable instance offers a retry and never a reset of app data', () => {
|
||||
const error = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryUnreachableError: No usable instance discovery document was served",
|
||||
);
|
||||
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={error} />);
|
||||
|
||||
expect(markup).toContain(RETRY_BUTTON);
|
||||
expect(markup).not.toContain(RESET_BUTTON);
|
||||
expect(markup).not.toContain('corrupted data');
|
||||
});
|
||||
|
||||
test('an unreachable instance keeps the raw error out of the page and offers it as a copy', () => {
|
||||
const error = new Error(
|
||||
"Error invoking remote method 'desktop-runtime-config:resolve': DesktopRuntimeDiscoveryUnreachableError: connect ECONNREFUSED 127.0.0.1:9",
|
||||
);
|
||||
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={error} />);
|
||||
|
||||
expect(markup).not.toContain('DesktopRuntimeDiscoveryUnreachableError');
|
||||
expect(markup).not.toContain('ECONNREFUSED');
|
||||
expect(markup).toContain(COPY_DETAILS_BUTTON);
|
||||
});
|
||||
|
||||
test('a failure while the device is offline never offers a reset of app data', () => {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
|
||||
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={new Error('Stored account record is malformed')} />);
|
||||
|
||||
expect(markup).not.toContain(RESET_BUTTON);
|
||||
});
|
||||
|
||||
test('a local startup failure still offers the reset of app data', () => {
|
||||
const markup = renderToStaticMarkup(<BootstrapErrorScreen error={new Error('Stored account record is malformed')} />);
|
||||
|
||||
expect(markup).toContain(RETRY_BUTTON);
|
||||
expect(markup).toContain(RESET_BUTTON);
|
||||
expect(markup).toContain('Stored account record is malformed');
|
||||
expect(markup).not.toContain(COPY_DETAILS_BUTTON);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BootstrapErrorKind, classifyBootstrapError} from '@app/features/app/components/BootstrapErrorKind';
|
||||
import styles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {
|
||||
BLUESKY_PROVIDER_NAME,
|
||||
@@ -7,29 +8,59 @@ import {
|
||||
PRODUCT_NAME,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import AppStorage, {
|
||||
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
|
||||
PRESERVED_RESET_STORAGE_KEYS,
|
||||
} from '@app/features/platform/state/PersistentStorage';
|
||||
import {ResetClientStateReason, resetClientState} from '@app/features/platform/state/ResetClientState';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {APPLICATION_ICON_DESCRIPTOR, FluxerIconMark} from '@app/features/ui/components/icons/FluxerIconMark';
|
||||
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
const UNREACHABLE_RETRY_INTERVAL_MS = 30_000;
|
||||
|
||||
interface BootstrapErrorScreenProps {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
function reloadApp(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function describeError(error: Error): string {
|
||||
return error.stack?.trim() || `${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error}) => {
|
||||
const {i18n} = useLingui();
|
||||
const handleRetry = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
const unreachable = classifyBootstrapError(error, navigator.onLine) === BootstrapErrorKind.UNREACHABLE;
|
||||
useEffect(() => {
|
||||
if (!unreachable) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener('online', reloadApp);
|
||||
const retry = window.setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
reloadApp();
|
||||
}
|
||||
}, UNREACHABLE_RETRY_INTERVAL_MS);
|
||||
return () => {
|
||||
window.removeEventListener('online', reloadApp);
|
||||
window.clearInterval(retry);
|
||||
};
|
||||
}, [unreachable]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopyDetails = useCallback(() => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
void import('@app/features/ui/commands/TextCopyCommands')
|
||||
.then(({copy}) => copy(i18n, describeError(error), true))
|
||||
.then(setCopied, () => setCopied(false));
|
||||
}, [error, i18n]);
|
||||
const handleReset = useCallback(() => {
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
|
||||
window.location.reload();
|
||||
void resetClientState({reason: ResetClientStateReason.RESET_APP_DATA, keepDrafts: true}).finally(() =>
|
||||
window.location.reload(),
|
||||
);
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles.errorFallbackContainer} data-flx="app.bootstrap-error-screen.error-fallback-container">
|
||||
@@ -40,14 +71,18 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
|
||||
/>
|
||||
<div className={styles.errorFallbackContent} data-flx="app.bootstrap-error-screen.error-fallback-content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="app.bootstrap-error-screen.error-fallback-title">
|
||||
<Trans>Failed to start</Trans>
|
||||
{unreachable ? <Trans>Can't connect</Trans> : <Trans>Failed to start</Trans>}
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription} data-flx="app.bootstrap-error-screen.error-fallback-description">
|
||||
<Trans>
|
||||
{PRODUCT_NAME} failed to start properly. This could be due to corrupted data or a temporary issue.
|
||||
</Trans>
|
||||
{unreachable ? (
|
||||
<Trans>Check your connection and try again.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{PRODUCT_NAME} failed to start properly. This could be due to corrupted data or a temporary issue.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
{error && (
|
||||
{error && !unreachable && (
|
||||
<p
|
||||
className={styles.errorFallbackDescription}
|
||||
style={{fontSize: '0.875rem', opacity: 0.8}}
|
||||
@@ -75,14 +110,31 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions} data-flx="app.bootstrap-error-screen.error-fallback-actions">
|
||||
<Button onClick={handleRetry} data-flx="app.bootstrap-error-screen.button.retry">
|
||||
<Button onClick={reloadApp} data-flx="app.bootstrap-error-screen.button.retry">
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="danger" data-flx="app.bootstrap-error-screen.button.reset">
|
||||
<Trans comment="Destructive button on the startup failure screen. Clears local app data except preserved drafts.">
|
||||
Reset app data
|
||||
</Trans>
|
||||
</Button>
|
||||
{error && unreachable && (
|
||||
<Button
|
||||
onClick={handleCopyDetails}
|
||||
variant="secondary"
|
||||
data-flx="app.bootstrap-error-screen.button.copy-details"
|
||||
>
|
||||
{copied ? (
|
||||
<Trans comment="Toast shown after copying text to the clipboard succeeds.">Copied to clipboard</Trans>
|
||||
) : (
|
||||
<Trans comment="Button on the crash screen that copies developer diagnostic text.">
|
||||
Copy stack trace
|
||||
</Trans>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!unreachable && (
|
||||
<Button onClick={handleReset} variant="danger" data-flx="app.bootstrap-error-screen.button.reset">
|
||||
<Trans comment="Destructive button on the startup failure screen. Clears local app data except preserved drafts.">
|
||||
Reset app data
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,8 @@
|
||||
import errorFallbackStyles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
|
||||
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import AppStorage, {
|
||||
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
|
||||
PRESERVED_RESET_STORAGE_KEYS,
|
||||
} from '@app/features/platform/state/PersistentStorage';
|
||||
import {ensureLatestAssets} from '@app/features/platform/types/Versioning';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {copy as copyText} from '@app/features/ui/commands/TextCopyCommands';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import LayerManager from '@app/features/ui/state/LayerManager';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
@@ -43,6 +37,16 @@ function getStackTraceText(error: Error | undefined, unknownErrorLabel: string):
|
||||
return `${name}: ${message}`;
|
||||
}
|
||||
|
||||
async function ensureLatestAssets(): Promise<{updateFound: boolean}> {
|
||||
const {ensureLatestAssets: ensure} = await import('@app/features/platform/types/Versioning');
|
||||
return await ensure({force: true});
|
||||
}
|
||||
|
||||
async function resetAppDataExceptDrafts(): Promise<void> {
|
||||
const {ResetClientStateReason, resetClientState} = await import('@app/features/platform/state/ResetClientState');
|
||||
await resetClientState({reason: ResetClientStateReason.RESET_APP_DATA, keepDrafts: true});
|
||||
}
|
||||
|
||||
async function cleanupRuntimeStateOnCrash(): Promise<void> {
|
||||
LayerManager.closeAll();
|
||||
const [{default: GatewayConnection}, {default: MediaEngine}] = await Promise.all([
|
||||
@@ -71,7 +75,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
let isMounted = true;
|
||||
const run = async () => {
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
const {updateFound} = await ensureLatestAssets();
|
||||
if (isMounted) {
|
||||
setUpdateAvailable(updateFound);
|
||||
}
|
||||
@@ -91,7 +95,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
const handleUpdate = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
const {updateFound} = await ensureLatestAssets();
|
||||
if (!updateFound) {
|
||||
setIsUpdating(false);
|
||||
window.location.reload();
|
||||
@@ -107,7 +111,10 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
}
|
||||
setIsCopyingStackTrace(true);
|
||||
try {
|
||||
await copyText(i18n, stackTraceText);
|
||||
const {copy} = await import('@app/features/ui/commands/TextCopyCommands');
|
||||
await copy(i18n, stackTraceText);
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy the stack trace:', error);
|
||||
} finally {
|
||||
setIsCopyingStackTrace(false);
|
||||
}
|
||||
@@ -157,8 +164,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
|
||||
location.reload();
|
||||
void resetAppDataExceptDrafts().finally(() => location.reload());
|
||||
}}
|
||||
variant="danger"
|
||||
disabled={checkingForUpdates}
|
||||
|
||||
@@ -5,28 +5,40 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
interface GenericErrorModalOptions {
|
||||
interface GenericErrorModalContentOptions {
|
||||
title: string | (() => string);
|
||||
message: ReactNode | (() => ReactNode);
|
||||
dataFlx?: string;
|
||||
}
|
||||
|
||||
interface GenericErrorModalOptions extends GenericErrorModalContentOptions {
|
||||
defer?: boolean;
|
||||
}
|
||||
|
||||
export function showGenericErrorModal({title, message, dataFlx, defer}: GenericErrorModalOptions): void {
|
||||
const pushErrorModal = () => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={typeof title === 'function' ? title() : title}
|
||||
message={typeof message === 'function' ? message() : message}
|
||||
data-flx={dataFlx}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
interface GenericErrorModalPortalOptions extends GenericErrorModalContentOptions {
|
||||
portalHost: HTMLElement;
|
||||
}
|
||||
|
||||
function createGenericErrorModal({title, message, dataFlx}: GenericErrorModalContentOptions) {
|
||||
return modal(() => (
|
||||
<GenericErrorModal
|
||||
title={typeof title === 'function' ? title() : title}
|
||||
message={typeof message === 'function' ? message() : message}
|
||||
data-flx={dataFlx}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function showGenericErrorModal({defer, ...content}: GenericErrorModalOptions): void {
|
||||
const pushErrorModal = () => ModalCommands.push(createGenericErrorModal(content));
|
||||
if (defer) {
|
||||
window.setTimeout(pushErrorModal, 0);
|
||||
return;
|
||||
}
|
||||
pushErrorModal();
|
||||
}
|
||||
|
||||
export function showGenericErrorModalInPortal({portalHost, ...content}: GenericErrorModalPortalOptions): void {
|
||||
if (!portalHost.isConnected) return;
|
||||
ModalCommands.pushToPortalHost({modal: createGenericErrorModal(content), portalHost});
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Limits} from '@app/features/app/utils/UserLimits';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import {MAX_BOOKMARKS_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -72,10 +71,10 @@ export const MaxBookmarksModal = observer(({user}: MaxBookmarksModalProps) => {
|
||||
title={i18n._(BOOKMARK_LIMIT_REACHED_DESCRIPTOR)}
|
||||
description={i18n._(YOU_VE_REACHED_THE_MAXIMUM_NUMBER_OF_BOOKMARKS_3_DESCRIPTOR, {
|
||||
count: maxBookmarks,
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
premiumCount: premiumBookmarks,
|
||||
})}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Limits} from '@app/features/app/utils/UserLimits';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {MAX_FAVORITE_MEMES_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {msg, plural} from '@lingui/core/macro';
|
||||
@@ -73,14 +72,14 @@ export const MaxFavoriteMemesModal = observer(() => {
|
||||
}
|
||||
const freeDescription = i18n._(YOU_VE_REACHED_THE_MAXIMUM_LIMIT_OF_FOR_DESCRIPTOR, {
|
||||
count: maxFavoriteMemes,
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
premiumCount: premiumLimit,
|
||||
});
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={i18n._(SAVED_MEDIA_LIMIT_REACHED_DESCRIPTOR)}
|
||||
description={freeDescription}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
primaryText={i18n._(UPGRADE_TO_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: hsl(0deg 0% 0%);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import styles from '@app/features/app/components/dialogs/LoadableSettingsModals.module.css';
|
||||
import type {ChannelSettingsModal as ChannelSettingsModalComponent} from '@app/features/channel/components/modals/ChannelSettingsModal';
|
||||
import type {GuildSettingsModal as GuildSettingsModalComponent} from '@app/features/guild/components/modals/GuildSettingsModal';
|
||||
import {CLOSE_DESCRIPTOR, TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
createNamedLoadableComponent,
|
||||
type LoadableErrorProps,
|
||||
} from '@app/features/platform/components/loadable/LoadableComponent';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {Spinner, SpinnerSize} from '@app/features/ui/components/Spinner';
|
||||
import LayerManager from '@app/features/ui/state/LayerManager';
|
||||
import {ModalStackContext} from '@app/features/ui/utils/ModalStackContext';
|
||||
import type {UserSettingsModal as UserSettingsModalComponent} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useContext, useEffect, useId} from 'react';
|
||||
|
||||
const SettingsModalPending = observer(function SettingsModalPending() {
|
||||
const {isVisible, needsBackdrop} = useContext(ModalStackContext);
|
||||
const layerKey = useId();
|
||||
useEffect(() => {
|
||||
LayerManager.addLayer('modal', layerKey, ModalCommands.pop);
|
||||
return () => {
|
||||
LayerManager.removeLayer('modal', layerKey);
|
||||
};
|
||||
}, [layerKey]);
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.overlay} data-flx="app.loadable-settings-modals.settings-modal-pending.overlay">
|
||||
{needsBackdrop && (
|
||||
<div className={styles.backdrop} data-flx="app.loadable-settings-modals.settings-modal-pending.backdrop" />
|
||||
)}
|
||||
<Spinner size={SpinnerSize.LARGE} data-flx="app.loadable-settings-modals.settings-modal-pending.spinner" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function SettingsModalUnavailable({retry}: LoadableErrorProps): React.JSX.Element {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={<Trans>Couldn't load settings</Trans>}
|
||||
description={<Trans>Check your connection and try again.</Trans>}
|
||||
primaryText={i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
onPrimary={retry}
|
||||
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
|
||||
disableAutoDismiss
|
||||
data-flx="app.loadable-settings-modals.settings-modal-unavailable.confirm-modal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const UserSettingsModal = createNamedLoadableComponent<React.ComponentProps<typeof UserSettingsModalComponent>>({
|
||||
displayName: 'UserSettingsModal',
|
||||
load: async () => (await import('@app/features/user/components/modals/UserSettingsModal')).UserSettingsModal,
|
||||
LoadingComponent: SettingsModalPending,
|
||||
ErrorComponent: SettingsModalUnavailable,
|
||||
});
|
||||
|
||||
export const GuildSettingsModal = createNamedLoadableComponent<
|
||||
React.ComponentProps<typeof GuildSettingsModalComponent>
|
||||
>({
|
||||
displayName: 'GuildSettingsModal',
|
||||
load: async () => (await import('@app/features/guild/components/modals/GuildSettingsModal')).GuildSettingsModal,
|
||||
LoadingComponent: SettingsModalPending,
|
||||
ErrorComponent: SettingsModalUnavailable,
|
||||
});
|
||||
|
||||
export const ChannelSettingsModal = createNamedLoadableComponent<
|
||||
React.ComponentProps<typeof ChannelSettingsModalComponent>
|
||||
>({
|
||||
displayName: 'ChannelSettingsModal',
|
||||
load: async () => (await import('@app/features/channel/components/modals/ChannelSettingsModal')).ChannelSettingsModal,
|
||||
LoadingComponent: SettingsModalPending,
|
||||
ErrorComponent: SettingsModalUnavailable,
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import styles from '@app/features/app/components/dialogs/Modal.module.css';
|
||||
import {resolveModalBackdropMotionSpec, resolveModalMotionSpec} from '@app/features/app/components/dialogs/ModalMotion';
|
||||
import {useModalResizePin, useWindowLiveResize} from '@app/features/app/components/dialogs/useModalResizePin';
|
||||
import {useModalBackHandler} from '@app/features/app/hooks/useModalBackHandler';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
@@ -80,6 +81,15 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
onClose,
|
||||
onAnimationComplete,
|
||||
});
|
||||
const isWindowLiveResizing = useWindowLiveResize();
|
||||
const setResizePinRef = useModalResizePin(isWindowLiveResizing, prefersReducedMotion);
|
||||
const setFocusLockRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
refs.setFloating(node);
|
||||
setResizePinRef(node);
|
||||
},
|
||||
[refs, setResizePinRef],
|
||||
);
|
||||
useModalBackHandler(handleClose, disableHistoryManagement);
|
||||
const isFirstModal = stackIndex === 0;
|
||||
useEffect(() => {
|
||||
@@ -265,7 +275,7 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
data-flx="app.modal.floating-focus-manager"
|
||||
>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
ref={setFocusLockRef}
|
||||
aria-labelledby={labelledBy}
|
||||
aria-modal={true}
|
||||
className={styles.focusLock}
|
||||
|
||||
+4
-2
@@ -37,6 +37,7 @@ import {
|
||||
GUILD_SETTINGS_LABEL_DESCRIPTOR,
|
||||
getGuildSettingsCategoryLabel,
|
||||
} from '@app/features/user/components/settings_utils/GuildSettingsConstants';
|
||||
import {getGuildSettingsTabComponent} from '@app/features/user/components/settings_utils/GuildSettingsTabComponents';
|
||||
import {useUnsavedChangesFlash} from '@app/features/user/hooks/useUnsavedChangesFlash';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -80,6 +81,7 @@ interface DesktopGuildSettingsViewProps {
|
||||
export const DesktopGuildSettingsView: React.FC<DesktopGuildSettingsViewProps> = observer(
|
||||
({guild, groupedSettingsTabs, currentTab, selectedTab, onTabSelect}) => {
|
||||
const {i18n} = useLingui();
|
||||
const CurrentTabComponent = currentTab ? getGuildSettingsTabComponent(currentTab.type) : null;
|
||||
const {showUnsavedBanner, flashBanner, tabData, checkUnsavedChanges} = useUnsavedChangesFlash(selectedTab);
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -314,8 +316,8 @@ export const DesktopGuildSettingsView: React.FC<DesktopGuildSettingsViewProps> =
|
||||
scrollKey={scrollKey}
|
||||
data-flx="app.desktop-guild-settings-view.settings-modal-desktop-scroll"
|
||||
>
|
||||
{currentTab && (
|
||||
<currentTab.component
|
||||
{CurrentTabComponent && (
|
||||
<CurrentTabComponent
|
||||
guildId={guild.id}
|
||||
data-flx="app.desktop-guild-settings-view.current-tab-component"
|
||||
/>
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErro
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {VerificationResult} from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {currentInstanceTarget} from '@app/features/platform/transport/InstanceHTTP';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -61,7 +62,7 @@ export const EmailVerificationAlert = observer(({title, children}: EmailVerifica
|
||||
const handleResend = async () => {
|
||||
if (isResending) return;
|
||||
setIsResending(true);
|
||||
const result = await AuthenticationCommands.resendVerificationEmail();
|
||||
const result = await AuthenticationCommands.resendVerificationEmail(currentInstanceTarget());
|
||||
switch (result) {
|
||||
case VerificationResult.SUCCESS:
|
||||
ToastCommands.success(i18n._(VERIFICATION_EMAIL_SENT_PLEASE_CHECK_YOUR_INBOX_DESCRIPTOR));
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
import {ComparisonCheckRow} from '@app/features/app/components/dialogs/components/ComparisonCheckRow';
|
||||
import {ComparisonRow} from '@app/features/app/components/dialogs/components/ComparisonRow';
|
||||
import styles from '@app/features/app/components/dialogs/components/FeatureComparisonTable.module.css';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {Limits} from '@app/features/app/utils/UserLimits';
|
||||
import {COMMUNITIES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {formatFileSize} from '@app/features/messaging/utils/FileUtils';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {
|
||||
isBooleanTierPerk,
|
||||
isNumericTierPerk,
|
||||
@@ -185,7 +185,7 @@ export const FeatureComparisonTable = observer(() => {
|
||||
<Trans>Free</Trans>
|
||||
</div>
|
||||
<div className={styles.headerStock} data-flx="app.feature-comparison-table.header-stock">
|
||||
{PREMIUM_PRODUCT_NAME}
|
||||
{getPremiumProductName()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -21,6 +21,7 @@ import type {
|
||||
GuildSettingsTabType,
|
||||
} from '@app/features/user/components/settings_utils/GuildSettingsConstants';
|
||||
import {getGuildSettingsCategoryLabel} from '@app/features/user/components/settings_utils/GuildSettingsConstants';
|
||||
import {getGuildSettingsTabComponent} from '@app/features/user/components/settings_utils/GuildSettingsTabComponents';
|
||||
import type {MobileNavigationState} from '@app/features/user/hooks/useMobileNavigation';
|
||||
import {useUnsavedChangesFlash} from '@app/features/user/hooks/useUnsavedChangesFlash';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -70,6 +71,7 @@ const headerFadeVariants = {
|
||||
export const MobileGuildSettingsView: React.FC<MobileGuildSettingsViewProps> = observer(
|
||||
({guild, groupedSettingsTabs, currentTab, mobileNav, onBack, onTabSelect}) => {
|
||||
const {i18n} = useLingui();
|
||||
const CurrentTabComponent = currentTab ? getGuildSettingsTabComponent(currentTab.type) : null;
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
const currentTabId = mobileNav.currentView?.tab;
|
||||
const {showUnsavedBanner, flashBanner, tabData, checkUnsavedChanges} = useUnsavedChangesFlash(currentTabId);
|
||||
@@ -203,7 +205,7 @@ export const MobileGuildSettingsView: React.FC<MobileGuildSettingsViewProps> = o
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{showMobileContent && currentTab && (
|
||||
{showMobileContent && CurrentTabComponent && (
|
||||
<motion.div
|
||||
key={`mobile-content-${mobileNav.currentView?.tab}`}
|
||||
custom={mobileNav.direction}
|
||||
@@ -221,7 +223,7 @@ export const MobileGuildSettingsView: React.FC<MobileGuildSettingsViewProps> = o
|
||||
data-flx="app.mobile-guild-settings-view.scroller-flex"
|
||||
>
|
||||
<div className={styles.contentContainer} data-flx="app.mobile-guild-settings-view.content-container">
|
||||
<currentTab.component
|
||||
<CurrentTabComponent
|
||||
guildId={guild.id}
|
||||
data-flx="app.mobile-guild-settings-view.current-tab-component"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/dialogs/components/PerksButton.module.css';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -17,7 +17,7 @@ export const PerksButton: React.FC<{
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={styles.link} data-flx="app.perks-button.link.click.button">
|
||||
{i18n._(PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {PricingSection} from '@app/features/app/components/dialogs/components/pl
|
||||
import {PurchaseHistorySection} from '@app/features/app/components/dialogs/components/plutonium/PurchaseHistorySection';
|
||||
import {SelfServeRefundSection} from '@app/features/app/components/dialogs/components/plutonium/SelfServeRefundSection';
|
||||
import {SubscriptionCard} from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import GeoIP from '@app/features/app/state/GeoIP';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
@@ -34,6 +33,8 @@ import {
|
||||
areGiftPurchasesAvailable,
|
||||
arePremiumPurchasesAvailable,
|
||||
canServiceStripeSubscriptions,
|
||||
getPremiumProductFullName,
|
||||
getPremiumProductName,
|
||||
shouldShowPremiumFeatures,
|
||||
} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
@@ -52,6 +53,7 @@ interface PlutoniumContentProps {
|
||||
}
|
||||
|
||||
export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumContentProps) => {
|
||||
const PREMIUM_PRODUCT_NAME = getPremiumProductName();
|
||||
const {i18n} = useLingui();
|
||||
const currentUser = Users.currentUser;
|
||||
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
|
||||
@@ -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}
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/BottomCTASection.module.css';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getPremiumProductFullName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
@@ -43,7 +43,7 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||
const {i18n} = useLingui();
|
||||
const tooltipText: React.ReactNode =
|
||||
purchaseDisabledTooltip ??
|
||||
i18n._(CLAIM_YOUR_ACCOUNT_TO_PURCHASE_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
i18n._(CLAIM_YOUR_ACCOUNT_TO_PURCHASE_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
|
||||
return (
|
||||
<div className={styles.container} data-flx="app.plutonium.bottom-cta-section.container">
|
||||
<h2 className={styles.title} data-flx="app.plutonium.bottom-cta-section.title">
|
||||
|
||||
@@ -6,7 +6,6 @@ import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/component
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/GiftSection.module.css';
|
||||
import {SectionHeader} from '@app/features/app/components/dialogs/components/plutonium/PlutoniumSectionHeader';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
BUY_GIFT_DESCRIPTOR,
|
||||
CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
SHARE_PREMIUM_EXPERIENCE_DESCRIPTOR,
|
||||
VIEW_PREMIUM_PERKS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductFullName, getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowDownIcon} from '@phosphor-icons/react';
|
||||
@@ -57,14 +57,14 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
const {i18n} = useLingui();
|
||||
const tooltipText: React.ReactNode =
|
||||
purchaseDisabledTooltip ??
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
|
||||
return (
|
||||
<div ref={giftSectionRef} data-flx="app.plutonium.gift-section.div">
|
||||
<section className={styles.section} data-flx="app.plutonium.gift-section.section">
|
||||
<SectionHeader
|
||||
title={i18n._(GIFT_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
title={i18n._(GIFT_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
description={i18n._(SHARE_PREMIUM_EXPERIENCE_DESCRIPTOR, {
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
})}
|
||||
data-flx="app.plutonium.gift-section.section-header"
|
||||
/>
|
||||
@@ -110,7 +110,7 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
<PurchaseDisclaimer data-flx="app.plutonium.gift-section.purchase-disclaimer" />
|
||||
<div className={styles.scrollPromptContainer} data-flx="app.plutonium.gift-section.scroll-prompt-container">
|
||||
<p className={styles.scrollPromptText} data-flx="app.plutonium.gift-section.scroll-prompt-text">
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</p>
|
||||
<ArrowDownIcon
|
||||
className={styles.scrollPromptIcon}
|
||||
|
||||
+12
-9
@@ -1,14 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
||||
import {
|
||||
PREMIUM_UPSELL_BANNER_DESCRIPTOR,
|
||||
VIEW_PLANS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {PlutoniumUpsell} from '@app/features/ui/plutonium_upsell/PlutoniumUpsell';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
@@ -20,17 +20,20 @@ export const PlutoniumUpsellBanner = observer(() => {
|
||||
onButtonClick={() => {
|
||||
ModalCommands.pop();
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.plutonium.plutonium-upsell-banner.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
modal(
|
||||
() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.plutonium.plutonium-upsell-banner.user-settings-modal"
|
||||
/>
|
||||
),
|
||||
'user-settings',
|
||||
),
|
||||
);
|
||||
}}
|
||||
data-flx="app.plutonium.plutonium-upsell-banner.plutonium-upsell"
|
||||
>
|
||||
{i18n._(PREMIUM_UPSELL_BANNER_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(PREMIUM_UPSELL_BANNER_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</PlutoniumUpsell>
|
||||
);
|
||||
});
|
||||
|
||||
+3
-3
@@ -6,13 +6,13 @@ import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/component
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/PricingSection.module.css';
|
||||
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
|
||||
import {ToggleButton} from '@app/features/app/components/dialogs/components/ToggleButton';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
BUY_GIFT_DESCRIPTOR,
|
||||
CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR,
|
||||
ONE_TIME_PURCHASE_DESCRIPTOR,
|
||||
VIEW_PREMIUM_PERKS_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {getPremiumProductFullName, getPremiumProductName} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowDownIcon} from '@phosphor-icons/react';
|
||||
@@ -99,7 +99,7 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
const {i18n} = useLingui();
|
||||
const tooltipText: React.ReactNode =
|
||||
purchaseDisabledTooltip ??
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
|
||||
return (
|
||||
<section className={styles.section} data-flx="app.plutonium.pricing-section.section">
|
||||
{giftPurchasesAvailable && (
|
||||
@@ -208,7 +208,7 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
data-flx="app.plutonium.pricing-section.scroll-prompt-container"
|
||||
>
|
||||
<p className={styles.scrollPromptText} data-flx="app.plutonium.pricing-section.scroll-prompt-text">
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
|
||||
{i18n._(VIEW_PREMIUM_PERKS_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
</p>
|
||||
<ArrowDownIcon
|
||||
className={styles.scrollPromptIcon}
|
||||
|
||||
+114
-49
@@ -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"
|
||||
|
||||
+57
-36
@@ -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,
|
||||
],
|
||||
|
||||
+17
-1
@@ -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
Reference in New Issue
Block a user