Compare commits

..
Author SHA1 Message Date
HampusandGitHub b30a4f5d14 fix(admin): omit synthetic accounts from user lookup and search (#2705) 2026-09-11 22:06:29 +02:00
HampusandGitHub f254ed679b fix(app): never lower the read-state unread watermark (#2704) 2026-09-11 22:02:24 +02:00
HampusandGitHub 258fe6f742 feat(voice): add audio bitrate guild features and 96 kbps cap (#2703) 2026-09-11 22:00:15 +02:00
HampusandGitHub cfa20b7093 fix(admin): let voice restriction lists be cleared (#2701) 2026-09-11 21:28:26 +02:00
HampusandGitHub 569146c5bc fix(app): pick favorite GIF preview kind from content type (#2696) 2026-09-11 21:06:00 +02:00
HampusandGitHub 1b1d48b05e feat(voice): soft connection limits for voice servers (#2694) 2026-09-11 20:05:14 +02:00
HampusandGitHub cadca2c18e test(voice): build watch attempt keys from the shared builder (#2693) 2026-09-11 19:44:34 +02:00
HampusandGitHub 2e3f78b3c6 fix(app): stop restarting the read-state ack batch window (#2689) 2026-09-11 16:26:34 +02:00
HampusandGitHub b57545b1a4 refactor(api): replace stripe mock currency ternary chains (#2688) 2026-09-11 16:02:09 +02:00
HampusandGitHub e490be2f35 feat(app): prompt to delete when clearing a message edit (#2687) 2026-09-11 15:56:05 +02:00
fluxer-ci[bot]andGitHub ab07fd23cf chore(i18n): update public marketing catalogs (#2686) 2026-09-11 15:50:16 +02:00
fluxer-ci[bot]andGitHub c1fd2234b8 chore(marketing): advance pointer 7867cf8 → 23cd1c9 (#2685) 2026-09-11 15:50:05 +02:00
HampusandGitHub 3af43b3366 feat(api): add SEK, DKK and NOK as localized currencies (#2684) 2026-09-11 15:48:56 +02:00
HampusandGitHub 0e470f532e test(voice): rename the watch failure deadline test file (#2683) 2026-09-11 15:18:08 +02:00
HampusandGitHub c541b86c00 fix(voice): show buffering while screen share recovery runs (#2682) 2026-09-11 15:03:21 +02:00
HampusandGitHub 53b3fa2f4a fix(voice): key watch attempts by published track (#2681) 2026-09-11 15:01:14 +02:00
HampusandGitHub 67e01be34a fix(voice): judge H.264 hardware support by negotiated format (#2680) 2026-09-11 14:59:04 +02:00
HampusandGitHub 81fd8c9aad fix(gateway): skip empty dm partner registration casts (#2677) 2026-09-11 13:49:03 +02:00
HampusandGitHub bcef7b3123 feat(app): edit blockquote lines in the composer (#2676) 2026-09-11 13:27:50 +02:00
HampusandGitHub a98d8ef679 fix(app): wrap multiline selections in code blocks (#2675) 2026-09-11 13:22:03 +02:00
HampusandGitHub a5af857564 fix(app): insert a newline on Enter inside code blocks (#2674) 2026-09-11 13:20:26 +02:00
HampusandGitHub 2830221949 fix(desktop): download the version a linux update prompt names (#2673) 2026-09-11 13:19:25 +02:00
HampusandGitHub 84aa8880f5 fix(app): format typed @everyone and @here in the composer (#2672) 2026-09-11 13:18:48 +02:00
HampusandGitHub 395ec1d60f fix(ci): publish desktop update feeds only after the release (#2671) 2026-09-11 13:18:15 +02:00
HampusandGitHub e6ee3b8059 fix(api): only offer desktop builds whose release is published (#2670) 2026-09-11 13:17:41 +02:00
HampusandGitHub 61a13e1c1a fix(app): download the version a linux update prompt names (#2669) 2026-09-11 13:16:27 +02:00
HampusandGitHub fc0e2628a4 fix(app): honour @silent in the message composer (#2668) 2026-09-11 13:16:13 +02:00
HampusandGitHub 87fdfd9c34 fix(app): show DMs opened by an incoming message as unread (#2667) 2026-09-11 13:14:06 +02:00
HampusandGitHub 88a5ff9c45 feat(voice): record watch failures and decode counters (#2666) 2026-09-11 13:05:34 +02:00
HampusandGitHub 320949a79d fix(gateway): drop dead clauses in dm partner visibility (#2665) 2026-09-11 13:00:23 +02:00
HampusandGitHub 3a862f1484 fix(voice): record why a screen share stopped (#2664) 2026-09-11 12:59:51 +02:00
HampusandGitHub 5da256df12 fix(voice): poll the current video element for a first frame (#2663) 2026-09-11 12:57:52 +02:00
HampusandGitHub baf2cbf3fd fix(voice): rebind codec negotiation after a region hot swap (#2662) 2026-09-11 12:55:50 +02:00
HampusandGitHub 74782dc4f2 fix(voice): confirm a decode stall before withdrawing a codec (#2661) 2026-09-11 12:53:29 +02:00
HampusandGitHub 7d8778495f chore(desktop): drop Chromium switches that no longer exist (#2660) 2026-09-11 12:50:54 +02:00
HampusandGitHub 53399ffb44 fix(gateway): track dm partner presence in mutual guilds (#2658) 2026-09-11 04:23:20 +02:00
HampusandGitHub 35d73eae76 fix(voice): stop asking for camera and mic access on page load (#2657) 2026-09-11 02:00:48 +02:00
HampusandGitHub 54128e049a test(api): restore the stripe webhook secret after mocking it (#2656) 2026-09-11 01:36:26 +02:00
HampusandGitHub 7d8d0ff804 fix(ci): retry release publish after transient GitHub failures (#2655) 2026-09-11 01:35:24 +02:00
HampusandGitHub 2e8f381efc fix(gateway): keep ets tids opaque in the permission cache (#2654) 2026-09-11 01:31:58 +02:00
HampusandGitHub b29da84282 perf(gateway): trim large guild connect snapshots by default (#2653) 2026-09-11 01:03:22 +02:00
HampusandGitHub 2988c846c8 perf(gateway): read cached members from the guild member table (#2652) 2026-09-11 00:58:17 +02:00
fluxer-ci[bot]andGitHub 8e91c1412b chore(marketing): advance pointer 5908507 → 7867cf8 (#2650) 2026-09-11 00:51:33 +02:00
fluxer-ci[bot]andGitHub 5b2099c777 chore(i18n): update public marketing catalogs (#2651) 2026-09-11 00:51:25 +02:00
HampusandGitHub f97841a58f fix(installer): resolve the compose file name Compose loads (#2649) 2026-09-11 00:38:09 +02:00
HampusandGitHub 0421c86039 fix(api): price gifts in the base currency everywhere (#2648) 2026-09-11 00:28:14 +02:00
HampusandGitHub d17f320bd7 fix(app): tidy the Plutonium billing and pricing layout (#2647) 2026-09-10 23:06:18 +02:00
HampusandGitHub f708586c59 feat(api)!: always use localized pricing where it is offered (#2646) 2026-09-10 21:22:32 +02:00
HampusandGitHub 905af5dd5a fix(app): shrink stored favorite gifs and raise their budget (#2643) 2026-09-10 18:43:42 +02:00
HampusandGitHub 5fea319f4e fix(app): stop other youtube embeds when one starts playing (#2642) 2026-09-10 18:42:30 +02:00
HampusandGitHub 01fd11fea9 fix(api): unexport the search lookup result type (#2641) 2026-09-10 18:24:16 +02:00
HampusandGitHub d028679b90 fix(api): batch the message lookups behind message search (#2640) 2026-09-10 18:18:49 +02:00
335 changed files with 25619 additions and 17782 deletions
+8
View File
@@ -525,6 +525,7 @@ jobs:
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
@@ -602,7 +603,9 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
@@ -660,3 +663,8 @@ jobs:
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
+10 -4
View File
@@ -365,10 +365,16 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# In-flight request ceiling for the four services Compose forwards it to: the
# users and messages routers and their shards. The Rust built-in defaults are 192
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
# users and messages routers and their shards. Leave it unset and each service
# uses its own built-in default, which is what the numbers below describe. Set it
# and the one value replaces the built-in default on all four, so size it for the
# busiest of them rather than for the smallest. The built-in defaults are 192 for
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to. A router holds a slot for the whole round
# trip to its shard, so this is a ceiling on requests in flight at once and not a
# rate: too low a value does not slow requests down, it rejects them, and the api
# turns that rejection into a 503.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
# The api and the Rust services name their fixed Postgres statement shapes so the
# server can reuse their plans. Named prepared statements require a session that
+4 -4
View File
@@ -565,7 +565,7 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -583,7 +583,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -633,7 +633,7 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -651,7 +651,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
+44 -1
View File
@@ -9801,7 +9801,7 @@
},
"GuildFeatureSchema": {
"type": "string",
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
},
"AddGuildMembersAdminBulkJobCreateRequest": {
"type": "object",
@@ -13891,6 +13891,14 @@
"type": "number"
},
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
"soft_connection_limit": {
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
"nullable": true,
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"format": "int32"
},
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
"required_guild_features": {
"type": "array",
@@ -13928,6 +13936,7 @@
"latitude",
"longitude",
"is_active",
"soft_connection_limit",
"vip_only",
"required_guild_features",
"allowed_guild_ids",
@@ -14227,6 +14236,14 @@
"type": "number"
},
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
"soft_connection_limit": {
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
"nullable": true,
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"format": "int32"
},
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
"required_guild_features": {
"type": "array",
@@ -14264,6 +14281,7 @@
"latitude",
"longitude",
"is_active",
"soft_connection_limit",
"vip_only",
"required_guild_features",
"allowed_guild_ids",
@@ -14299,6 +14317,14 @@
"type": "number"
},
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
"soft_connection_limit": {
"description": "Connection count above which placement prefers another server, or null for no limit",
"nullable": true,
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"format": "int32"
},
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
"required_guild_features": {
"type": "array",
@@ -14356,6 +14382,14 @@
"type": "number"
},
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
"soft_connection_limit": {
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
"nullable": true,
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"format": "int32"
},
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
"required_guild_features": {
"type": "array",
@@ -14393,6 +14427,7 @@
"latitude",
"longitude",
"is_active",
"soft_connection_limit",
"vip_only",
"required_guild_features",
"allowed_guild_ids",
@@ -14428,6 +14463,14 @@
"type": "number"
},
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
"soft_connection_limit": {
"description": "Connection count above which placement prefers another server, or null for no limit",
"nullable": true,
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"format": "int32"
},
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
"required_guild_features": {
"type": "array",
+1
View File
@@ -28,6 +28,7 @@ pub struct VoiceServer {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub is_active: Option<bool>,
pub soft_connection_limit: Option<i64>,
pub vip_only: Option<bool>,
#[serde(default)]
pub required_guild_features: Vec<String>,
+24 -18
View File
@@ -55,15 +55,13 @@ impl AdminApiClient {
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
let region_id = required_field(params, "id")?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_region(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
validate_against::<generated_types::UpdateVoiceRegionRequest>(params)?;
self.patch_with_reason(
&format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
Some(params),
None,
)
.await
}
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
@@ -121,15 +119,17 @@ impl AdminApiClient {
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_server(&region_id, &server_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
validate_against::<generated_types::UpdateVoiceServerRequest>(params)?;
self.patch_with_reason(
&format!(
"/admin/voice/regions/{}/servers/{}",
urlencoding::encode(&region_id),
urlencoding::encode(&server_id)
),
Some(params),
None,
)
.await
}
pub async fn delete_voice_server(
@@ -150,6 +150,12 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
serde_json::from_value::<T>(params.clone())
.map(drop)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
if has_coordinate("latitude") == has_coordinate("longitude") {
+48 -16
View File
@@ -4,7 +4,7 @@ use crate::{
api::client::AdminApiClient,
middleware::{auth::AuthContext, csrf},
state::AppState,
templates,
templates::{self, pages::voice_servers::VoiceServersPageParams},
};
use axum::{
Router,
@@ -13,12 +13,34 @@ use axum::{
routing::get,
};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
struct VoiceServersQuery {
region_id: Option<String>,
}
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
let response = match client.get_gateway_voice_state_counts().await {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "admin API request failed: load voice state counts");
return HashMap::new();
}
};
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
return HashMap::new();
};
servers
.iter()
.filter_map(|entry| {
let server_id = entry.get("server_id")?.as_str()?.to_owned();
let count = entry.get("voice_state_count")?.as_i64()?;
Some((server_id, count))
})
.collect()
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
@@ -79,17 +101,21 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
None,
None,
None,
None,
&csrf_token,
&VoiceServersPageParams {
region_id: None,
region_name: None,
servers: None,
connection_counts: &HashMap::new(),
error: None,
csrf_token: &csrf_token,
},
);
return Html(markup.into_string()).into_response();
}
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let connection_counts = load_server_connection_counts(&client).await;
let region_name = match client.get_voice_region(region_id, false).await {
Ok(resp) => resp
@@ -107,11 +133,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
Some(region_id),
Some(&region_name),
Some(&resp.servers),
None,
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_name),
servers: Some(&resp.servers),
connection_counts: &connection_counts,
error: None,
csrf_token: &csrf_token,
},
);
Html(markup.into_string()).into_response()
}
@@ -120,11 +149,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
Some(region_id),
Some(&region_name),
None,
Some(&msg),
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_name),
servers: None,
connection_counts: &connection_counts,
error: Some(&msg),
csrf_token: &csrf_token,
},
);
Html(markup.into_string()).into_response()
}
+109 -20
View File
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
}
body.insert("is_default".into(), form.bool_value("is_default").into());
body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert(
"required_guild_features".into(),
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
insert_submitted_list(&mut body, form, "required_guild_features");
insert_submitted_list(&mut body, form, "allowed_guild_ids");
serde_json::Value::Object(body)
}
fn insert_submitted_list(
body: &mut serde_json::Map<String, serde_json::Value>,
form: &MultiValueForm,
field: &str,
) {
let repeated = format!("{field}[]");
if !form.contains_key(&repeated) && !form.contains_key(field) {
return;
}
body.insert(
field.to_owned(),
form.list_values_any(&[repeated.as_str(), field]).into(),
);
}
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
let mut body = serde_json::Map::new();
if let Some(v) = form.clean("region_id") {
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
body.insert("longitude".into(), lng.into());
}
body.insert("is_active".into(), form.bool_value("is_active").into());
if let Some(raw) = form.first("soft_connection_limit") {
let trimmed = raw.trim();
if trimmed.is_empty() {
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
} else if let Ok(limit) = trimmed.parse::<i64>()
&& limit > 0
{
body.insert("soft_connection_limit".into(), limit.into());
}
}
body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert(
"required_guild_features".into(),
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
insert_submitted_list(&mut body, form, "required_guild_features");
insert_submitted_list(&mut body, form, "allowed_guild_ids");
serde_json::Value::Object(body)
}
@@ -255,6 +264,86 @@ mod tests {
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
}
#[test]
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
);
let body = build_server_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
);
let body = build_server_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], serde_json::json!(false));
}
#[test]
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
let body = build_region_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
let body = build_region_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
}
#[test]
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
let form =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
}
#[test]
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
}
#[test]
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
assert!(
!build_server_body(&absent)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let invalid =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
assert!(
!build_server_body(&invalid)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
assert!(
!build_server_body(&zero)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
}
#[test]
fn build_server_body_preserves_single_and_repeated_values() {
let form = MultiValueForm::parse(
@@ -147,6 +147,9 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_STICKER_DISABLED",
@@ -14,6 +14,9 @@ use maud::{Markup, html};
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_STICKER_DISABLED",
@@ -18,19 +18,33 @@ use crate::{
},
};
use maud::{Markup, html};
use std::collections::HashMap;
use super::voice_servers_forms::{create_server_form, edit_server_form};
pub struct VoiceServersPageParams<'a> {
pub region_id: Option<&'a str>,
pub region_name: Option<&'a str>,
pub servers: Option<&'a [VoiceServer]>,
pub connection_counts: &'a HashMap<String, i64>,
pub error: Option<&'a str>,
pub csrf_token: &'a str,
}
pub fn voice_servers_page(
config: &AdminConfig,
auth: &AuthContext,
region_id: Option<&str>,
region_name: Option<&str>,
servers: Option<&[VoiceServer]>,
error: Option<&str>,
csrf_token: &str,
p: &VoiceServersPageParams<'_>,
) -> Markup {
let base = &config.base_path;
let VoiceServersPageParams {
region_id,
region_name,
servers,
connection_counts,
error,
csrf_token,
} = *p;
let options = LayoutOptions {
csrf_token,
inspected_voice_region_id: region_id,
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
html! {},
))
@if let Some(servers) = servers {
(servers_list(config, rid, servers, csrf_token))
(servers_list(config, rid, servers, connection_counts, csrf_token))
}
div id="create" class="mt-8" {
(create_server_form(config, rid, csrf_token))
@@ -109,6 +123,7 @@ fn servers_list(
config: &AdminConfig,
region_id: &str,
servers: &[VoiceServer],
connection_counts: &HashMap<String, i64>,
csrf_token: &str,
) -> Markup {
if servers.is_empty() {
@@ -121,7 +136,7 @@ fn servers_list(
html! {
div class="space-y-4" {
@for server in servers {
(server_card(config, region_id, server, csrf_token))
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
}
}
}
@@ -131,6 +146,7 @@ fn server_card(
config: &AdminConfig,
region_id: &str,
server: &VoiceServer,
connection_count: Option<i64>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
@@ -145,6 +161,15 @@ fn server_card(
let lng_str = server
.longitude
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
let soft_limit_str = server
.soft_connection_limit
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
let connections_str =
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
let at_soft_limit = matches!(
(server.soft_connection_limit, connection_count),
(Some(limit), Some(count)) if limit > 0 && count >= limit
);
card(html! {
div class="mb-4 flex flex-col gap-1" {
@@ -155,6 +180,9 @@ fn server_card(
} @else {
(badge("INACTIVE", BadgeVariant::Default))
}
@if at_soft_limit {
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
}
(voice_status_badges(vip_only, has_features, has_guild_ids))
}
p class="text-sm text-neutral-500" { (endpoint) }
@@ -164,6 +192,8 @@ fn server_card(
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
(data_field_text("Latitude", &lat_str))
(data_field_text("Longitude", &lng_str))
(data_field_text("Soft connection limit", &soft_limit_str))
(data_field_text("Live connections", &connections_str))
}
(voice_features_list(&server.required_guild_features))
(voice_guild_ids_list(&server.allowed_guild_ids))
@@ -26,6 +26,9 @@ pub fn edit_server_form(
let lat_val = server.latitude.map_or_else(String::new, |v| v.to_string());
let lng_val = server.longitude.map_or_else(String::new, |v| v.to_string());
let is_active = server.is_active.unwrap_or(false);
let soft_limit_val = server
.soft_connection_limit
.map_or_else(String::new, |v| v.to_string());
let vip_only = server.vip_only.unwrap_or(false);
let features_csv = server.required_guild_features.join(", ");
let guild_ids_csv = server.allowed_guild_ids.join(", ");
@@ -57,6 +60,15 @@ pub fn edit_server_form(
"Optional per-server coordinate override",
))
}
(form_field_with_helper(
"Soft Connection Limit",
&format!("{id_prefix}-soft-connection-limit"),
"soft_connection_limit",
"number",
&soft_limit_val,
"Leave empty for no limit",
"Placement prefers another server once this server holds this many connections",
))
(form_field_with_helper(
"API Key",
&format!("{id_prefix}-api-key"),
@@ -105,6 +117,15 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
(form_field_with_id("API Secret", "new-server-api-secret", "api_secret", "password", "", "LiveKit API secret", true))
(form_field_with_id("Latitude (optional)", "new-server-latitude", "latitude", "number", "", "40.7128", false))
(form_field_with_id("Longitude (optional)", "new-server-longitude", "longitude", "number", "", "-74.0060", false))
(form_field_with_helper(
"Soft Connection Limit (optional)",
"new-server-soft-connection-limit",
"soft_connection_limit",
"number",
"",
"Leave empty for no limit",
"Placement prefers another server once this server holds this many connections",
))
}
div class="space-y-3" {
(checkbox("is_active", "true", "Server is active", true, true))
@@ -0,0 +1,336 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
Json, Router,
body::{Body, to_bytes},
extract::State,
http::{Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "voice-restriction-writes-test-secret";
const REGION_ID: &str = "europe-north";
const SERVER_ID: &str = "europe-north-server-1";
type CapturedBodies = Arc<Mutex<Vec<(String, Value)>>>;
#[tokio::test]
async fn clearing_the_restriction_fields_reaches_the_api_as_empty_lists() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-servers?action=update",
&format!(
"_csrf={csrf_token}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=true\
&required_guild_features=&allowed_guild_ids=&soft_connection_limit="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
assert_eq!(body["soft_connection_limit"], Value::Null);
assert_eq!(body["vip_only"], json!(false));
}
#[tokio::test]
async fn activating_a_server_leaves_the_restriction_fields_untouched() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-servers?action=update",
&format!(
"_csrf={csrf_token}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=false&vip_only=true"
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
let object = body.as_object().expect("object body");
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], json!(false));
assert_eq!(body["vip_only"], json!(true));
}
#[tokio::test]
async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-regions?action=update",
&format!(
"_csrf={csrf_token}&id={REGION_ID}&name=Northern%20Europe&emoji=%F0%9F%87%B8%F0%9F%87%AA\
&latitude=59.33&longitude=18.06&required_guild_features=&allowed_guild_ids="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(&app, "PATCH /admin/voice/regions/europe-north");
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
}
struct TestApp {
router: Router,
session_cookie: String,
captured: CapturedBodies,
}
async fn setup() -> TestApp {
let captured: CapturedBodies = Arc::new(Mutex::new(Vec::new()));
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
let router = build_router(test_config(api_endpoint));
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
captured,
}
}
fn captured_body(app: &TestApp, route: &str) -> Value {
let captured = app.captured.lock().expect("captured bodies");
captured
.iter()
.find(|(seen, _)| seen == route)
.map(|(_, body)| body.clone())
.unwrap_or_else(|| {
panic!(
"no request captured for {route}, saw {:?}",
captured.iter().map(|(seen, _)| seen).collect::<Vec<_>>()
)
})
}
async fn csrf_token(app: &TestApp) -> String {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/voice-regions")
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
let pair = value.split(';').next()?;
let token = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="))?;
(!token.is_empty()).then(|| token.to_owned())
})
.expect("csrf_token cookie")
}
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(
header::COOKIE,
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
)
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
response.status()
}
async fn spawn_mock_api(captured: CapturedBodies) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(
listener,
Router::new().fallback(mock_api).with_state(captured),
)
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(
State(captured): State<CapturedBodies>,
method: Method,
uri: Uri,
request: Request<Body>,
) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH {
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
captured
.lock()
.expect("captured bodies")
.push((format!("PATCH {path}"), body));
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::PATCH, "/admin/voice/regions/europe-north") => {
Json(json!({ "region": region() })).into_response()
}
(Method::PATCH, "/admin/voice/regions/europe-north/servers/europe-north-server-1") => {
Json(json!({ "server": server() })).into_response()
}
(Method::GET, "/admin/voice/regions") => {
Json(json!({ "regions": [region()] })).into_response()
}
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn region() -> Value {
json!({
"id": REGION_ID,
"name": "Northern Europe",
"emoji": "flag",
"latitude": 59.33,
"longitude": 18.06,
"is_default": true,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn server() -> Value {
json!({
"region_id": REGION_ID,
"server_id": SERVER_ID,
"endpoint": "wss://voice.example.com",
"latitude": null,
"longitude": null,
"is_active": true,
"soft_connection_limit": null,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn admin_user() -> Value {
json!({
"id": "1500000000000000000",
"username": "AdminUser",
"discriminator": 1,
"avatar": null,
"banner": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"global_name": "AdminUser",
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-US",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": false,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
+12
View File
@@ -372,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
monthlyEur: master.integrations.stripe.prices.monthly_eur,
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
monthlyInr: master.integrations.stripe.prices.monthly_inr,
monthlyNok: master.integrations.stripe.prices.monthly_nok,
monthlyPln: master.integrations.stripe.prices.monthly_pln,
monthlySek: master.integrations.stripe.prices.monthly_sek,
monthlyTry: master.integrations.stripe.prices.monthly_try,
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
yearlyEur: master.integrations.stripe.prices.yearly_eur,
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
yearlyInr: master.integrations.stripe.prices.yearly_inr,
yearlyNok: master.integrations.stripe.prices.yearly_nok,
yearlyPln: master.integrations.stripe.prices.yearly_pln,
yearlySek: master.integrations.stripe.prices.yearly_sek,
yearlyTry: master.integrations.stripe.prices.yearly_try,
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
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,
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,
@@ -229,14 +229,11 @@ export class AdminMessageService {
hitsPerPage: limit,
page: 1,
});
const messageEntries = result.hits.map((hit) => ({
channelId: createChannelID(BigInt(hit.channelId)),
messageId: createMessageID(BigInt(hit.id)),
}));
const resolvedMessages = await Promise.all(
messageEntries.map(({channelId, messageId}) => this.getMessageResponseForAdmin(channelId, messageId)),
);
const messageResponses = resolvedMessages.filter((message): message is MessageResponse => message !== null);
const messageResponses = await createMessageResponseDataService().buildMessages({
userId: createUserID(0n),
messages: result.messages,
access: await this.getMessageResponseAccessForAdmin(channelId),
});
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
const priorReports = await this.getPriorReportsForMessages(messageResponses);
const adminMessages = messageResponses.map((message) =>
@@ -282,19 +279,6 @@ export class AdminMessageService {
});
}
private async getMessageResponseForAdmin(
channelId: ChannelID,
messageId: MessageID,
): Promise<MessageResponse | null> {
const access = await this.getMessageResponseAccessForAdmin(channelId);
return createMessageResponseDataService().getMessage({
userId: createUserID(0n),
channelId,
messageId,
access,
});
}
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
@@ -5,6 +5,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import type {ApiContext} from '../../ApiContext';
import {createGuildID, createUserID, type UserID} from '../../BrandedTypes';
import {isSyntheticUserId} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {Logger} from '../../Logger';
import {getGuildSearchService, getUserSearchService} from '../../SearchFactory';
@@ -129,12 +130,11 @@ export class AdminSearchService {
throw new FeatureTemporarilyDisabledError();
}
const query = data.query?.trim() || '';
const isIdQuery = /^\d+$/.test(query);
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
const [searchResult, directUser] = await Promise.all([
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
isIdQuery && data.offset === 0
? userRepository.findUnique(createUserID(BigInt(query))).catch(() => null)
: Promise.resolve(null),
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
]);
const {hits, total} = searchResult;
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
@@ -3,6 +3,7 @@
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {ApiContext} from '../../ApiContext';
import {createUserID} from '../../BrandedTypes';
import {isSyntheticUserId} from '../../constants/Core';
import {Logger} from '../../Logger';
import {mapUserToAdminResponse} from '../models/UserTypes';
@@ -32,7 +33,7 @@ export class AdminUserLookupService {
} else if (/^\d+$/.test(query)) {
try {
const userId = createUserID(BigInt(query));
user = await userRepository.findUnique(userId);
user = isSyntheticUserId(userId) ? null : await userRepository.findUnique(userId);
} catch (error) {
Logger.debug({query, error}, 'Failed to lookup user by numeric ID, invalid ID format');
user = null;
@@ -235,6 +235,7 @@ export class AdminVoiceService {
serverId: data.server_id,
endpoint: data.endpoint,
isActive: data.is_active ?? true,
softConnectionLimit: data.soft_connection_limit ?? null,
apiKey: data.api_key ?? null,
apiSecret: data.api_secret ?? null,
latitude: data.latitude ?? null,
@@ -271,6 +272,7 @@ export class AdminVoiceService {
if (data.latitude !== undefined) updates.latitude = data.latitude;
if (data.longitude !== undefined) updates.longitude = data.longitude;
if (data.is_active !== undefined) updates.isActive = data.is_active;
if (data.soft_connection_limit !== undefined) updates.softConnectionLimit = data.soft_connection_limit;
updates.restrictions = patchVoiceRestrictions(existing.restrictions, data);
updates.updatedAt = new Date();
await voiceRepository.upsertServer(updates);
@@ -339,6 +341,7 @@ export class AdminVoiceService {
latitude: server.latitude ?? null,
longitude: server.longitude ?? null,
is_active: server.isActive,
soft_connection_limit: server.softConnectionLimit ?? null,
vip_only: server.restrictions.vipOnly,
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
allowed_guild_ids: allowedGuildIds,
@@ -21,6 +21,8 @@ interface UserListResponse {
total: number;
}
const SYNTHETIC_USER_IDS = ['0', '1'];
async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: string): Promise<void> {
await createBuilder(harness, `${token}`)
.get('/users/@me')
@@ -170,5 +172,35 @@ describe('Admin user directory', () => {
expect(result.users.map((user) => user.id)).toEqual([target.userId]);
expect(result.users[0]?.email).toBeNull();
});
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the resolve selector', async (userId) => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
.get(`/admin/users?resolve=${userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(result.users).toEqual([]);
expect(result.total).toBe(0);
});
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the q selector', async (userId) => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
.get(`/admin/users?q=${userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(result.users.map((user) => user.id)).not.toContain(userId);
});
});
describe('GET /admin/users/:user_id', () => {
test.each(SYNTHETIC_USER_IDS)('reports no user for the synthetic account %s', async (userId) => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
.get(`/admin/users/${userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(result.users).toEqual([]);
});
});
});
@@ -266,4 +266,161 @@ describe('VoiceAdminController', () => {
expect(persisted?.apiKey).toBe(fixture.initialApiKey);
expect(persisted?.apiSecret).toBe(fixture.initialApiSecret);
});
test('stores, keeps, and clears a voice server soft connection limit', async () => {
const admin = await createAdminWithAcls(harness, [
AdminACLs.VOICE_REGION_CREATE,
AdminACLs.VOICE_SERVER_CREATE,
AdminACLs.VOICE_SERVER_LIST,
AdminACLs.VOICE_SERVER_UPDATE,
]);
const regionId = 'voice-region-soft-limit';
const serverId = 'voice-server-soft-limit';
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
.post('/admin/voice/regions')
.body({
id: regionId,
name: `Region ${regionId}`,
emoji: ':earth_americas:',
latitude: 1,
longitude: 2,
})
.expect(HTTP_STATUS.OK)
.execute();
const created = await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
.post(`/admin/voice/regions/${regionId}/servers`)
.body({
server_id: serverId,
endpoint: 'https://voice-soft-limit.example.com/socket',
api_key: 'soft-limit-api-key',
api_secret: 'soft-limit-api-secret',
soft_connection_limit: 250,
})
.expect(HTTP_STATUS.OK)
.execute();
expect(created.server.soft_connection_limit).toBe(250);
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
.body({endpoint: 'https://voice-soft-limit-2.example.com/socket'})
.expect(HTTP_STATUS.OK)
.execute();
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
.body({soft_connection_limit: null})
.expect(HTTP_STATUS.OK)
.execute();
expect(cleared.server.soft_connection_limit).toBeNull();
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBeNull();
});
test('clears voice server restriction lists when empty arrays are supplied', async () => {
const admin = await createAdminWithAcls(harness, [
AdminACLs.VOICE_REGION_CREATE,
AdminACLs.VOICE_SERVER_CREATE,
AdminACLs.VOICE_SERVER_UPDATE,
]);
const regionId = 'voice-region-clear-restrictions';
const serverId = 'voice-server-clear-restrictions';
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
.post('/admin/voice/regions')
.body({
id: regionId,
name: `Region ${regionId}`,
emoji: ':earth_americas:',
latitude: 1,
longitude: 2,
})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
.post(`/admin/voice/regions/${regionId}/servers`)
.body({
server_id: serverId,
endpoint: 'https://voice-clear.example.com/socket',
api_key: 'clear-api-key',
api_secret: 'clear-api-secret',
required_guild_features: ['VIP_VOICE'],
allowed_guild_ids: [1234567890123456789n.toString()],
})
.expect(HTTP_STATUS.OK)
.execute();
const stored = await voiceRepository.getServer(regionId, serverId);
expect(Array.from(stored?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
expect(stored?.restrictions.allowedGuildIds.size).toBe(1);
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
.body({required_guild_features: [], allowed_guild_ids: []})
.expect(HTTP_STATUS.OK)
.execute();
expect(cleared.server.required_guild_features).toEqual([]);
expect(cleared.server.allowed_guild_ids).toEqual([]);
const persisted = await voiceRepository.getServer(regionId, serverId);
expect(persisted?.restrictions.requiredGuildFeatures.size).toBe(0);
expect(persisted?.restrictions.allowedGuildIds.size).toBe(0);
});
test('leaves voice server restriction lists unchanged when they are omitted', async () => {
const admin = await createAdminWithAcls(harness, [
AdminACLs.VOICE_REGION_CREATE,
AdminACLs.VOICE_SERVER_CREATE,
AdminACLs.VOICE_SERVER_UPDATE,
]);
const regionId = 'voice-region-keep-restrictions';
const serverId = 'voice-server-keep-restrictions';
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
.post('/admin/voice/regions')
.body({
id: regionId,
name: `Region ${regionId}`,
emoji: ':earth_americas:',
latitude: 1,
longitude: 2,
})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
.post(`/admin/voice/regions/${regionId}/servers`)
.body({
server_id: serverId,
endpoint: 'https://voice-keep.example.com/socket',
api_key: 'keep-api-key',
api_secret: 'keep-api-secret',
required_guild_features: ['VIP_VOICE'],
})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
.body({is_active: false})
.expect(HTTP_STATUS.OK)
.execute();
const persisted = await voiceRepository.getServer(regionId, serverId);
expect(Array.from(persisted?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
expect(persisted?.isActive).toBe(false);
});
test('rejects a voice server soft connection limit below one', async () => {
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_REGION_CREATE, AdminACLs.VOICE_SERVER_CREATE]);
const regionId = 'voice-region-soft-limit-invalid';
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
.post('/admin/voice/regions')
.body({
id: regionId,
name: `Region ${regionId}`,
emoji: ':earth_americas:',
latitude: 1,
longitude: 2,
})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, `${admin.token}`)
.post(`/admin/voice/regions/${regionId}/servers`)
.body({
server_id: 'voice-server-soft-limit-invalid',
endpoint: 'https://voice-soft-limit-invalid.example.com/socket',
api_key: 'soft-limit-invalid-api-key',
api_secret: 'soft-limit-invalid-api-secret',
soft_connection_limit: 0,
})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
});
});
@@ -7,7 +7,7 @@ import {
GUILD_TEXT_BASED_CHANNEL_TYPES,
Permissions,
} from '@fluxer/constants/src/ChannelConstants';
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
@@ -260,13 +260,17 @@ export class ChannelOperationsService {
validateCapacity: requestedParentId !== null && requestedParentId !== (channel.parentId ?? null),
});
}
let nextBitrate = channel.bitrate;
if (data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
nextBitrate = data.bitrate === null ? null : clampVoiceChannelBitrate(data.bitrate, guild.features ?? []);
}
const updatedChannelData = {
...channel.toRow(),
name: channelName,
topic: data.topic !== undefined ? data.topic : channel.topic,
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
parent_id: requestedParentId,
bitrate: data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.bitrate : channel.bitrate,
bitrate: nextBitrate,
user_limit:
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
? data.user_limit
@@ -4,11 +4,10 @@ import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import type {MessageResponse, MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {AttachmentDecayService} from '../../../attachment/AttachmentDecayService';
import type {AttachmentID, ChannelID, MessageID, UserID} from '../../../BrandedTypes';
import {createChannelID, createMessageID} from '../../../BrandedTypes';
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
import type {Channel} from '../../../models/Channel';
@@ -192,29 +191,19 @@ export class MessageRetrievalService {
hitsPerPage,
page,
});
const messageEntries = result.hits.map((hit) => ({
channelId: createChannelID(BigInt(hit.channelId)),
messageId: createMessageID(BigInt(hit.id)),
}));
const access = {
sourceGuildId: channel.guildId,
messageHistoryCutoff: !hasReadHistory ? (authChannel.guild?.message_history_cutoff ?? null) : null,
canReadMessageHistory: hasReadHistory,
};
const responseDataService = createMessageResponseDataService();
const foundMessages = await Promise.all(
messageEntries.map(({channelId, messageId}) =>
responseDataService.getMessage({
userId,
channelId,
messageId,
access,
}),
),
const builtMessages = await createMessageResponseDataService().buildMessages({
userId,
messages: result.messages,
access,
});
const messageResponses = builtMessages.map(
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
);
const messageResponses = foundMessages
.filter((message): message is MessageResponse => message !== null)
.map(({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage);
return {
channels: messageResponses.length > 0 ? [await this.mapSearchChannelResponse(channel, userId, requestCache)] : [],
messages: messageResponses,
+12
View File
@@ -226,17 +226,29 @@ export interface APIConfig {
monthlyUsd?: string;
monthlyEur?: string;
monthlyBrl?: string;
monthlyDkk?: string;
monthlyInr?: string;
monthlyNok?: string;
monthlyPln?: string;
monthlySek?: string;
monthlyTry?: string;
yearlyUsd?: string;
yearlyEur?: string;
yearlyBrl?: string;
yearlyDkk?: string;
yearlyInr?: string;
yearlyNok?: string;
yearlyPln?: string;
yearlySek?: string;
yearlyTry?: string;
gift1MonthUsd?: string;
gift1MonthEur?: string;
gift1MonthSek?: string;
gift1YearSek?: string;
gift1MonthDkk?: string;
gift1YearDkk?: string;
gift1MonthNok?: string;
gift1YearNok?: string;
gift1MonthBrl?: string;
gift1MonthInr?: string;
gift1MonthPln?: string;
+6 -1
View File
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createUserID} from '../BrandedTypes';
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
import {createUserID, type UserID} from '../BrandedTypes';
export const SYSTEM_USER_ID = createUserID(0n);
export function isSyntheticUserId(userId: UserID): boolean {
return userId === SYSTEM_USER_ID || userId === DELETED_USER_ID;
}
@@ -39,6 +39,7 @@ export interface VoiceServerRow {
latitude: number | null;
longitude: number | null;
is_active: boolean | null;
soft_connection_limit: number | null;
vip_only: boolean | null;
required_guild_features: Set<string> | null;
allowed_guild_ids: Set<bigint> | null;
@@ -56,6 +57,7 @@ export const VOICE_SERVER_COLUMNS = [
'latitude',
'longitude',
'is_active',
'soft_connection_limit',
'vip_only',
'required_guild_features',
'allowed_guild_ids',
+302 -239
View File
@@ -17,7 +17,7 @@ import {
StorageObjectRangeNotSatisfiableError,
} from '../infrastructure/IStorageService';
import {Logger} from '../Logger';
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
parseDesktopReleaseDescriptor,
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
);
}
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
| {kind: 'awaiting_release'}
| {kind: 'ready'; location: string};
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
type ListedDesktopVersion = {
version: string;
pub_date: Date;
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
};
export class DownloadService {
constructor(private readonly storageService: IStorageService) {}
@@ -216,41 +225,28 @@ export class DownloadService {
) {
return {kind: 'not_current'};
}
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
if (descriptorText == null) {
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
if (release.kind === 'untracked') {
return {kind: 'not_current'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
if (release.kind === 'unpublished') {
return {kind: 'awaiting_release'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
if (
!descriptor ||
descriptor.channel !== scope.channel ||
descriptor.version !== manifest.version ||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
) {
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
throw new Error(
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
);
}
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
if (!releaseAsset) {
return {kind: 'not_current'};
}
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
if (marker == null) {
return {kind: 'awaiting_release'};
}
const readiness = parseDesktopReleaseReadiness(marker);
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
if (
!readiness ||
readiness.channel !== descriptor.channel ||
readiness.version !== descriptor.version ||
readiness.release_tag !== descriptor.release_tag ||
readiness.source_sha !== descriptor.source_sha ||
readiness.descriptor_sha256 !== descriptorSha256
) {
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
}
return {
kind: 'ready',
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
@@ -269,18 +265,22 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.resolveLatestDesktopKeyFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const entry = manifest.files[params.format];
if (!entry) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const filename = this.extractFilename(entry);
if (filename.trim().length === 0) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const resolvedFilename = await this.resolveManifestFilename({
channel: params.channel,
@@ -291,7 +291,11 @@ export class DownloadService {
test: params.test,
});
if (!resolvedFilename) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
if (parsed && parsed.version !== manifest.version) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
return this.buildDesktopArtifactKey({
channel: params.channel,
@@ -302,7 +306,7 @@ export class DownloadService {
});
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
throw error;
}
@@ -320,10 +324,14 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.getLatestDesktopVersionFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
if (result) {
@@ -331,11 +339,11 @@ export class DownloadService {
}
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
throw error;
}
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
async listDesktopVersions(params: {
@@ -351,158 +359,17 @@ export class DownloadService {
versions: Array<VersionInfo>;
hasMore: boolean;
}> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return {versions: [], hasMore: false};
let listedVersions = await this.listDesktopVersionFiles(params);
if (params.before) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
}
const prefix = `${basePrefix}/`;
try {
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return {versions: [], hasMore: false};
}
const versionMap = new Map<
string,
{
pub_date: Date;
files: Map<
DesktopFormat,
{
filename: string;
sha256Key: string | null;
}
>;
}
>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
let filteredVersions = sortedVersions;
if (params.before) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
}
if (params.after) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
}
const hasMore = filteredVersions.length > params.limit;
const paginatedVersions = filteredVersions.slice(0, params.limit);
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return {versions, hasMore};
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return {versions: [], hasMore: false};
}
throw error;
if (params.after) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
}
return {
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
hasMore: listedVersions.length > params.limit,
};
}
async resolveVersionedDesktopKey(params: {
@@ -558,10 +425,10 @@ export class DownloadService {
}): Promise<DesktopChecksumFile | null> {
const version = await this.getLatestDesktopVersion(params);
const file = version?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
const key = await this.resolveLatestDesktopKey(params);
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
if (!key) {
return null;
}
@@ -586,8 +453,16 @@ export class DownloadService {
if (objectSha256) {
return this.buildDesktopChecksumFile(key, filename, objectSha256);
}
const latest = await this.getLatestDesktopVersion(params);
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
const prefix = desktopArtifactPrefix(params);
if (!prefix) {
return null;
}
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
const versionInfo =
isDesktopManifest(manifest) && manifest.version === params.version
? await this.getLatestDesktopVersionFromManifest(params, manifest)
: null;
const file = versionInfo?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
@@ -768,6 +643,77 @@ export class DownloadService {
}
}
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
const [descriptorText, markerText] = await Promise.all([
this.readOptionalTextFromStorage(descriptorKey),
this.readOptionalTextFromStorage(markerKey),
]);
if (descriptorText == null) {
return {kind: 'untracked'};
}
if (markerText == null) {
return {kind: 'unpublished'};
}
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
if (
!readiness ||
readiness.channel !== channel ||
readiness.version !== version ||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
) {
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
return {kind: 'unpublished'};
}
return {kind: 'published', descriptorText};
}
private async isReleasableDesktopVersion(
params: {channel: DesktopChannel; test?: boolean},
version: string,
releasability: Map<string, boolean>,
): Promise<boolean> {
if (params.test || Config.instance.selfHosted) {
return true;
}
const checked = releasability.get(version);
if (checked !== undefined) {
return checked;
}
let releasable = true;
try {
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
} catch (error) {
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
}
releasability.set(version, releasable);
return releasable;
}
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
params: {channel: DesktopChannel; test?: boolean},
candidates: ReadonlyArray<T>,
releasability: Map<string, boolean>,
): Promise<T | null> {
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
for (const candidate of newestCandidates) {
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
return candidate;
}
}
const [newest] = newestCandidates;
if (!newest) {
return null;
}
Logger.error(
{channel: params.channel, version: newest.version},
'No recent desktop version has a published release',
);
return newest;
}
private isValidSha256(value: string): boolean {
return /^[a-f0-9]{64}$/u.test(value);
}
@@ -789,7 +735,13 @@ export class DownloadService {
) {
return manifestFilename;
}
return this.findLatestFilenameForRequestedArch(params);
for (const entry of await this.listDesktopVersionFiles(params)) {
const file = entry.files.get(params.format);
if (file) {
return file.filename;
}
}
return null;
}
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
@@ -826,44 +778,145 @@ export class DownloadService {
return params.filename.toLowerCase().endsWith('.exe');
}
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
private async listDesktopVersionFiles(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
test?: boolean;
}): Promise<Array<ListedDesktopVersion>> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return null;
return [];
}
const prefix = `${basePrefix}/`;
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return null;
try {
const objects = await this.listDesktopArtifacts(prefix);
const versionMap = new Map<string, ListedDesktopVersion>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
version,
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return [];
}
throw error;
}
let latestFilename: string | null = null;
let latestVersion: string | null = null;
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.length === 0) {
continue;
}
if (
filename.includes('/') ||
filename.endsWith('.sha256') ||
filename.endsWith('.blockmap') ||
filename.endsWith('.yml') ||
filename === 'manifest.json' ||
filename === 'RELEASES.json' ||
filename === 'releases.json'
) {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed || parsed.format !== params.format) {
continue;
}
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
latestVersion = parsed.version;
latestFilename = filename;
}
private async buildListedDesktopVersions(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
listedVersions: ReadonlyArray<ListedDesktopVersion>,
): Promise<Array<VersionInfo>> {
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const entry of listedVersions) {
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
return latestFilename;
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const entry of listedVersions) {
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version: entry.version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return versions;
}
private escapeRegex(str: string): string {
@@ -1030,16 +1083,25 @@ export class DownloadService {
return key;
}
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
const filename = await this.findLatestFilenameForRequestedArch(params);
if (!filename) {
private async resolveLatestDesktopKeyFromObjects(
params: LatestFilenameLookupParams,
releasability: Map<string, boolean>,
): Promise<string | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(
params,
listedVersions.filter((entry) => entry.files.has(params.format)),
releasability,
);
const file = latest?.files.get(params.format);
if (!file) {
return null;
}
return this.buildDesktopArtifactKey({
channel: params.channel,
plat: params.plat,
arch: params.arch,
filename,
filename: file.filename,
test: params.test,
});
}
@@ -1189,22 +1251,23 @@ export class DownloadService {
};
}
private async getLatestDesktopVersionFromObjects(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
}): Promise<VersionInfo | null> {
const {versions} = await this.listDesktopVersions({
channel: params.channel,
plat: params.plat,
arch: params.arch,
limit: 1,
baseUrl: params.baseUrl,
test: params.test,
});
return versions[0] ?? null;
private async getLatestDesktopVersionFromObjects(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
releasability: Map<string, boolean>,
): Promise<VersionInfo | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
if (!latest) {
return null;
}
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
return versionInfo ?? null;
}
private async resolveDesktopFileSha256(params: {
@@ -0,0 +1,337 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
import {describe, expect, it} from 'vitest';
import {getConfig} from '../../Config';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
const RELEASES_PREFIX = 'desktop/canary/github-releases';
const SOURCE_SHA = 'b'.repeat(40);
const V904 = '2026.904.135113';
const V908 = '2026.908.173325';
const V909 = '2026.909.202036';
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
['darwin', 'arm64', 4],
['darwin', 'x64', 4],
['linux', 'arm64', 4],
['linux', 'x64', 4],
['win32', 'arm64', 6],
['win32', 'x64', 6],
];
type StoredObjects = Map<string, string>;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
const prefix = options.prefix ?? PREFIX;
const filename = appImageFilename(version);
objects.set(`${prefix}/${filename}`, filename);
if (options.checksum !== false) {
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
}
objects.set(
`${prefix}/manifest.json`,
JSON.stringify({
channel: 'canary',
platform: 'linux',
arch: 'x64',
version,
pub_date: '2026-09-08T18:06:00Z',
files: {appimage: {filename, sha256: sha256Hex(filename)}},
}),
);
}
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
const assets = routes.flatMap(([plat, arch, count]) =>
Array.from({length: count}, (_, index) => {
const filename =
plat === 'linux' && arch === 'x64' && index === 0
? appImageFilename(version)
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
return {
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
release_asset: filename,
sha256: sha256Hex(filename),
size: 1,
};
}),
);
const descriptor = JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
assets,
});
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
return descriptor;
}
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
objects.set(
`${RELEASES_PREFIX}/${version}.ready.json`,
JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
descriptor_sha256: sha256Hex(descriptor),
}),
);
}
function releaseBuild(objects: StoredObjects, version: string) {
const descriptor = publishDescriptor(objects, version);
uploadBuild(objects, version);
publishMarker(objects, version, descriptor);
}
function incidentObjects(): StoredObjects {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
return objects;
}
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
const reads: Array<string> = [];
const listings: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
onRead?.(params.key);
const body = objects.get(params.key);
if (body == null) {
return null;
}
const buffer = Buffer.from(body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) => {
listings.push(params.prefix);
return Array.from(objects.keys())
.filter((key) => key.startsWith(params.prefix))
.sort()
.map((key) => ({key}));
},
getObjectMetadata: async (_bucket: string, key: string) =>
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads, listings};
}
async function resolveLatest(service: DownloadService, test?: boolean) {
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
return {version: metadata?.version, key, checksum: checksum?.body};
}
function latestOf(version: string, prefix = PREFIX) {
const filename = appImageFilename(version);
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
}
describe('desktop release readiness', () => {
it('offers a published manifest version after reading only its release state', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
releaseBuild(objects, V909);
const {service, reads, listings} = createService(objects);
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V909}.json`,
`${RELEASES_PREFIX}/${V909}.ready.json`,
]);
expect(listings).toEqual([]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
});
it('falls back to the newest published version while the manifest version awaits its release', async () => {
const {service} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
const {service, reads, listings} = createService(incidentObjects());
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V908}.json`,
`${RELEASES_PREFIX}/${V908}.ready.json`,
`${RELEASES_PREFIX}/${V904}.json`,
`${RELEASES_PREFIX}/${V904}.ready.json`,
`${PREFIX}/${appImageFilename(V904)}.sha256`,
]);
expect(listings).toEqual([`${PREFIX}/`]);
});
it('offers a manifest version that has no release descriptor', async () => {
const objects: StoredObjects = new Map();
uploadBuild(objects, V904);
uploadBuild(objects, V908);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
publishMarker(objects, V908, 'another descriptor');
const {service} = createService(objects);
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const descriptor = publishDescriptor(
objects,
V908,
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
);
uploadBuild(objects, V908);
publishMarker(objects, V908, descriptor);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
'Invalid GitHub desktop release descriptor',
);
});
it.each([
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
const {service} = createService(incidentObjects(), (key) => {
if (key === failingKey) {
throw new S3ServiceException({
name: 'SlowDown',
$fault: 'server',
$metadata: {httpStatusCode: 503},
message: 'Please reduce your request rate.',
});
}
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('still resolves the unpublished version through versioned routes', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {checksum: false});
const {service} = createService(objects);
const params = {...APPIMAGE_PARAMS, version: V908};
const filename = appImageFilename(V908);
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
sha256: sha256Hex(filename),
});
});
it('keeps offering the manifest version on self-hosted instances', async () => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
const {service, reads} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
} finally {
config.instance.selfHosted = originalSelfHosted;
}
});
it('keeps offering the newest test build', async () => {
const objects: StoredObjects = new Map();
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
const {service, reads} = createService(objects);
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
});
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
const objects = incidentObjects();
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
publishMarker(objects, V909, descriptor);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
kind: 'ready',
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
});
});
it('offers the newest version when ten unpublished versions hide a published one', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const newest = '2026.908.170009';
for (let build = 0; build < 10; build++) {
const version = `2026.908.${170000 + build}`;
publishDescriptor(objects, version);
uploadBuild(objects, version);
}
const {service, reads} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
});
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
const objects = incidentObjects();
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
let manifestReads = 0;
const {service} = createService(objects, (key) => {
if (key !== `${PREFIX}/manifest.json`) {
return;
}
manifestReads += 1;
if (manifestReads === 2) {
publishMarker(objects, V909, descriptor);
}
});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
expect(checksum?.body).toBe(latestOf(V904).checksum);
});
it('lists an unpublished version while latest skips it', async () => {
const {service} = createService(incidentObjects());
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
});
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {describe, expect, it} from 'vitest';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const BASE_URL = 'https://api.example.test';
const V1 = '2026.901.100000';
const V2 = '2026.902.100000';
const V3 = '2026.903.100000';
const V4 = '2026.904.100000';
const V5 = '2026.905.100000';
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
type StoredObject = {body?: string; lastModified?: Date};
type StoredObjects = Map<string, StoredObject>;
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function debFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-amd64.deb`;
}
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
if (options.sha256 !== undefined) {
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
}
}
function createService(objects: StoredObjects) {
const reads: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
const object = objects.get(params.key);
if (object?.body == null) {
return null;
}
const buffer = Buffer.from(object.body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) =>
Array.from(objects.entries())
.filter(([key]) => key.startsWith(params.prefix))
.sort(([left], [right]) => (left < right ? -1 : 1))
.map(([key, object]) => ({key, lastModified: object.lastModified})),
getObjectMetadata: async () => null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads};
}
function versionNumbers(versions: Array<{version: string}>): Array<string> {
return versions.map((entry) => entry.version);
}
describe('desktop version listing', () => {
it('lists versions newest first with the files of each version', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
expect(listed.hasMore).toBe(false);
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
});
it('excludes names that are not artefacts for the requested coordinate', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V3]);
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
});
it('pages with limit, before and after and reports whether more remain', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version));
}
const {service} = createService(objects);
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
expect(firstPage.hasMore).toBe(true);
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
expect(olderPage.hasMore).toBe(false);
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
expect(newerPage.hasMore).toBe(false);
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
expect(versionNumbers(between.versions)).toEqual([V4]);
expect(between.hasMore).toBe(true);
});
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
const hash = 'b'.repeat(64);
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: hash});
addArtifact(objects, appImageFilename(V2));
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(listed.versions[0].files.appimage).toEqual({
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
sha256: hash,
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
});
expect(listed.versions[1].files.appimage.sha256).toBeNull();
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
expect(listed.versions[2].files.appimage.sha256).toBeNull();
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
});
it('reads a checksum only for the versions it returns', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
}
const {service, reads} = createService(objects);
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
});
});
@@ -156,7 +156,7 @@ export class GuildSearchService {
page,
cursor,
});
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
return {
messages: mappedResponses.messages,
channels: mappedResponses.channels,
@@ -238,7 +238,7 @@ export class GuildSearchService {
page,
cursor,
});
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
return {
messages: mappedResponses.messages,
channels: mappedResponses.channels,
@@ -2,7 +2,7 @@
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {ContentWarningLevel, GuildFeatures, resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
import {
MAX_CHANNELS_PER_CATEGORY,
MAX_GUILD_CHANNELS,
@@ -119,12 +119,16 @@ export class ChannelOperationsService {
);
}
let channelName = params.data.name;
if (params.data.type === ChannelTypes.GUILD_TEXT) {
let guildFeatures: Array<string> | null = null;
if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_VOICE) {
const guildData = await this.gatewayService.getGuildData({
guildId: params.guildId,
userId: params.userId,
});
const hasFlexibleNamesEnabled = guildData.features.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
guildFeatures = guildData.features;
}
if (params.data.type === ChannelTypes.GUILD_TEXT) {
const hasFlexibleNamesEnabled = (guildFeatures ?? []).includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
if (!hasFlexibleNamesEnabled) {
channelName = ChannelNameType.parse(channelName);
}
@@ -156,7 +160,10 @@ export class ChannelOperationsService {
content_warning_level: requestedContentWarningLevel,
content_warning_text: requestedContentWarningText,
rate_limit_per_user: params.data.rate_limit_per_user ?? 0,
bitrate: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.bitrate ?? 64000) : null,
bitrate:
params.data.type === ChannelTypes.GUILD_VOICE
? resolveVoiceChannelBitrate(params.data.bitrate, guildFeatures)
: null,
user_limit: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.user_limit ?? 0) : null,
voice_connection_limit:
params.data.type === ChannelTypes.GUILD_VOICE
@@ -10,11 +10,13 @@ import {
GuildSplashCardAlignment,
GuildVerificationLevel,
JoinSourceTypes,
resolveVoiceChannelBitrate,
SystemChannelFlags,
} from '@fluxer/constants/src/GuildConstants';
import {
MAX_GUILD_CHANNELS,
MAX_GUILD_ROLES,
VOICE_CHANNEL_BITRATE_DEFAULT,
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
} from '@fluxer/constants/src/LimitConstants';
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
@@ -934,7 +936,14 @@ export class GuildOperationsService {
addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_TEXT_CATEGORY_NAME, null, 0);
addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_VOICE_CATEGORY_NAME, null, 1);
addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, DEFAULT_TEXT_CHANNEL_NAME, textCategoryId, 0);
addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, DEFAULT_VOICE_CHANNEL_NAME, voiceCategoryId, 0, 64000);
addChannel(
generalVoiceId,
ChannelTypes.GUILD_VOICE,
DEFAULT_VOICE_CHANNEL_NAME,
voiceCategoryId,
0,
VOICE_CHANNEL_BITRATE_DEFAULT,
);
batch.addPrepared(
GuildRoles.insert({
guild_id: guildId,
@@ -1105,7 +1114,7 @@ export class GuildOperationsService {
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
bitrate: isVoice ? (channel.bitrate ?? 64000) : null,
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
voice_connection_limit: isVoice
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
@@ -25,6 +26,14 @@ describe('Guild Channel Management', () => {
afterEach(async () => {
await harness?.shutdown();
});
async function addGuildFeaturesForTesting(guildId: string, features: Array<string>): Promise<void> {
await createBuilder<{
success: boolean;
}>(harness, '')
.post(`/test/guilds/${guildId}/features`)
.body({add_features: features})
.execute();
}
describe('Channel Name Updates', () => {
test('should normalize channel name with spaces to hyphens', async () => {
const account = await createTestAccount(harness);
@@ -582,7 +591,7 @@ describe('Guild Channel Management', () => {
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject bitrate above maximum (320000)', async () => {
test('should reject bitrate above maximum (384000)', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
const voiceChannel = await createChannel(
@@ -594,7 +603,7 @@ describe('Guild Channel Management', () => {
);
await createBuilder(harness, account.token)
.patch(`/channels/${voiceChannel.id}`)
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320001})
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384001})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -678,7 +687,7 @@ describe('Guild Channel Management', () => {
.execute();
expect(data.bitrate).toBe(8000);
});
test('should accept maximum bitrate (320000)', async () => {
test('should clamp bitrate to 96000 without an audio bitrate feature', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
const voiceChannel = await createChannel(
@@ -690,9 +699,74 @@ describe('Guild Channel Management', () => {
);
const data = await createBuilder<ChannelResponse>(harness, account.token)
.patch(`/channels/${voiceChannel.id}`)
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320000})
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
.execute();
expect(data.bitrate).toBe(320000);
expect(data.bitrate).toBe(96000);
});
test('should clamp bitrate to the feature the guild holds', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_256_KBPS]);
const voiceChannel = await createChannel(
harness,
account.token,
guild.id,
'voice-channel',
ChannelTypes.GUILD_VOICE,
);
const data = await createBuilder<ChannelResponse>(harness, account.token)
.patch(`/channels/${voiceChannel.id}`)
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
.execute();
expect(data.bitrate).toBe(256000);
});
test('should accept maximum bitrate (384000) with the 384 kbps feature', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_384_KBPS]);
const voiceChannel = await createChannel(
harness,
account.token,
guild.id,
'voice-channel',
ChannelTypes.GUILD_VOICE,
);
const data = await createBuilder<ChannelResponse>(harness, account.token)
.patch(`/channels/${voiceChannel.id}`)
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
.execute();
expect(data.bitrate).toBe(384000);
});
test('should store the default bitrate on a new voice channel', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
const voiceChannel = await createChannel(
harness,
account.token,
guild.id,
'voice-channel',
ChannelTypes.GUILD_VOICE,
);
expect(voiceChannel.bitrate).toBe(64000);
});
test('should clamp bitrate on create without an audio bitrate feature', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
const data = await createBuilder<ChannelResponse>(harness, account.token)
.post(`/guilds/${guild.id}/channels`)
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 384000})
.execute();
expect(data.bitrate).toBe(96000);
});
test('should keep bitrate on create with the 128 kbps feature', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
const data = await createBuilder<ChannelResponse>(harness, account.token)
.post(`/guilds/${guild.id}/channels`)
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 128000})
.execute();
expect(data.bitrate).toBe(128000);
});
test('should accept maximum user limit (99)', async () => {
const account = await createTestAccount(harness);
@@ -734,6 +808,7 @@ describe('Guild Channel Management', () => {
test('should update both bitrate and user limit together', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
const voiceChannel = await createChannel(
harness,
account.token,
@@ -28,6 +28,7 @@ import {setInjectedSearchProvider} from '../SearchFactory';
import type {ISearchProvider} from '../search/ISearchProvider';
import {VoiceAvailabilityService} from '../voice/VoiceAvailabilityService';
import {VoiceRepository} from '../voice/VoiceRepository';
import {VoiceServerLoadTracker} from '../voice/VoiceServerLoad';
import {VoiceTopology} from '../voice/VoiceTopology';
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
@@ -289,7 +290,10 @@ export async function ensureVoiceResourcesInitialized(): Promise<void> {
const topology = new VoiceTopology(voiceRepository, voiceConfigSubscriber);
await topology.initialize();
voiceTopology = topology;
voiceAvailabilityService = new VoiceAvailabilityService(topology);
voiceAvailabilityService = new VoiceAvailabilityService(
topology,
new VoiceServerLoadTracker({gatewayService: getGatewayService()}),
);
liveKitServiceInstance = new LiveKitService(topology);
voiceRoomStoreInstance = new VoiceRoomStore(getKVClient());
})().finally(() => {
+51 -48
View File
@@ -13342,13 +13342,10 @@
"name": "country_code",
"in": "query",
"required": false,
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
},
{
"name": "pricing_mode",
"in": "query",
"required": false,
"schema": {"$ref": "#/components/schemas/PricingModeEnum"}
"schema": {
"type": "string",
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
}
}
]
}
@@ -13623,7 +13620,10 @@
"name": "country_code",
"in": "query",
"required": false,
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
"schema": {
"type": "string",
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
}
}
]
}
@@ -27348,8 +27348,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -27442,8 +27442,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -27526,8 +27526,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -27610,8 +27610,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -28041,6 +28041,9 @@
"x-enumNames": [
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_STICKER_DISABLED",
@@ -28071,6 +28074,9 @@
"x-enumDescriptions": [
"Guild can have an animated icon",
"Guild can have an animated banner",
"Guild can set a voice channel bitrate of up to 128 kbps",
"Guild can set a voice channel bitrate of up to 256 kbps",
"Guild can set a voice channel bitrate of up to 384 kbps",
"Guild can have a banner",
"Guild has the in-app one-click emoji clone shortcut disabled for non-members",
"Guild has the in-app one-click sticker clone shortcut disabled for non-members",
@@ -28098,7 +28104,7 @@
"Guild has large guild overrides enabled",
"Guild has increased member capacity enabled"
],
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
},
"ChannelPartialResponse": {
"type": "object",
@@ -30329,8 +30335,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -30397,8 +30403,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -30465,8 +30471,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -30533,8 +30539,8 @@
"description": "ID of the parent category for this channel"
},
"bitrate": {
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-320000)"
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
},
"user_limit": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
@@ -32064,9 +32070,9 @@
"description": "The amount the user is actually charged, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency of the charged amount"
},
"billing_cycle": {
@@ -32345,9 +32351,9 @@
"currency": {
"anyOf": [
{
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
},
{"type": "null"}
],
@@ -32418,9 +32424,9 @@
"currency": {
"anyOf": [
{
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
},
{"type": "null"}
],
@@ -32622,13 +32628,9 @@
"localized": {
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
"description": "Localized checkout prices resolved from mirrored billing data"
},
"base": {
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
"description": "Standard USD/EUR checkout prices resolved from mirrored billing data"
}
},
"required": ["country_code", "localized", "base"]
"required": ["country_code", "localized"]
},
"PriceIdsResponse": {
"type": "object",
@@ -32666,15 +32668,15 @@
"description": "Gift 1 year price amount in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency for the prices"
},
"gift_currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency for gift prices"
}
},
@@ -32687,7 +32689,6 @@
},
"required": ["disabled"]
},
"PricingModeEnum": {"enum": ["localized", "base"], "type": "string", "x-enumNames": ["localized", "base"]},
"SelfServeRefundResponse": {
"type": "object",
"properties": {
@@ -32754,9 +32755,9 @@
"description": "Amount billed before the switch, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency of both amounts"
}
},
@@ -32793,9 +32794,9 @@
"description": "Amount billed before the switch, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency of both amounts"
}
},
@@ -34016,7 +34017,10 @@
"type": "object",
"properties": {
"price_id": {"type": "string", "description": "The Stripe price ID for the subscription plan"},
"country_code": {"type": "string", "description": "Two-letter country code used for regional pricing"},
"country_code": {
"type": "string",
"description": "Two-letter country code used for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
},
"client_geoip_country_code": {
"type": "string",
"description": "Two-letter country code observed by the client GeoIP store before checkout"
@@ -34025,7 +34029,6 @@
"type": "boolean",
"description": "Whether the EU/EEA digital content withdrawal waiver was expressly accepted before checkout"
},
"pricing_mode": {"$ref": "#/components/schemas/PricingModeEnum"},
"payment_method": {"$ref": "#/components/schemas/CheckoutPaymentMethodEnum"},
"is_business": {
"type": "boolean",
@@ -36836,7 +36839,7 @@
"description": "Controls who sees the full profile: all guild members, only small-guild members, or only friends"
},
"synced_preferences": {
"anyOf": [{"type": "string", "maxLength": 349528}, {"type": "null"}],
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}],
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it."
}
}
@@ -10,6 +10,7 @@ import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../RateLimitConfig';
import type {HonoApp} from '../types/HonoEnv';
import {lookupGeoip} from '../utils/IpUtils';
import {Validator} from '../Validator';
export function PremiumController(app: HonoApp) {
@@ -32,7 +33,8 @@ export function PremiumController(app: HonoApp) {
async (ctx) => {
const userId = ctx.get('user').id;
const {country_code} = ctx.req.valid('query');
const state = await ctx.get('stripeService').getPremiumState(userId, country_code);
const geoip = await lookupGeoip(ctx.req.raw);
const state = await ctx.get('stripeService').getPremiumState(userId, geoip.countryCode ?? country_code);
return ctx.json(state);
},
);
@@ -253,7 +253,7 @@ export class GlobalSearchService {
page,
cursor,
});
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
return {
messages: mappedResponses.messages,
channels: mappedResponses.channels,
@@ -1,20 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
MessageResponse,
MessageSearchResultsResponse,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserID} from '../BrandedTypes';
import {createChannelID, createMessageID} from '../BrandedTypes';
import {createChannelID} from '../BrandedTypes';
import {mapChannelToResponse} from '../channel/ChannelMappers';
import type {IChannelRepository} from '../channel/IChannelRepository';
import {
createMessageResponseDataService,
messageResponseAccessForChannel,
} from '../channel/services/message/MessageResponseDataService';
import {createMessageResponseDataService} from '../channel/services/message/MessageResponseDataService';
import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import type {Channel} from '../models/Channel';
import type {Message} from '../models/Message';
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
const CHANNEL_LOOKUP_CONCURRENCY = 16;
export class MessageSearchResponseMapper {
constructor(
@@ -23,71 +21,46 @@ export class MessageSearchResponseMapper {
) {}
async mapSearchResultToResponses(
result: {
hits: Array<{
channelId: string;
id: string;
}>;
total: number;
},
messages: Array<Message>,
userId: UserID,
requestCache: RequestCache,
): Promise<{
messages: Array<MessageSearchResultsResponse['messages'][number]>;
channels: Array<MessageSearchResultsResponse['channels'][number]>;
}> {
const messageEntries = result.hits.map((hit) => ({
channelId: createChannelID(BigInt(hit.channelId)),
messageId: createMessageID(BigInt(hit.id)),
}));
const orderedChannelIds = new Set<string>();
for (const entry of messageEntries) {
orderedChannelIds.add(entry.channelId.toString());
}
const channels = await Promise.all(
Array.from(orderedChannelIds).map((channelId) =>
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
),
const orderedChannelIds = Array.from(new Set(messages.map((message) => message.channelId.toString())));
const channels = await mapWithConcurrency(orderedChannelIds, CHANNEL_LOOKUP_CONCURRENCY, (channelId) =>
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
);
const validChannels = channels.filter((channel): channel is Channel => channel !== null);
const channelById = new Map(validChannels.map((channel) => [channel.id.toString(), channel] as const));
const responseDataService = createMessageResponseDataService();
const messageResponsesWithEntries = await Promise.all(
messageEntries.map(async (entry) => {
const channel = channelById.get(entry.channelId.toString());
if (!channel) return null;
const message = await responseDataService.getMessage({
userId,
channelId: entry.channelId,
messageId: entry.messageId,
access: messageResponseAccessForChannel(channel),
});
return message ? {message, channelId: entry.channelId.toString()} : null;
}),
const channelById = new Map(
channels
.filter((channel): channel is Channel => channel !== null)
.map((channel) => [channel.id.toString(), channel] as const),
);
const validMessageResponseEntries = messageResponsesWithEntries.filter(
(entry): entry is {message: MessageResponse; channelId: string} => entry !== null,
);
const messageResponses = validMessageResponseEntries.map((entry) => {
const {referenced_message: _referencedMessage, ...searchMessage} = entry.message;
return searchMessage;
const renderableMessages = messages.filter((message) => channelById.has(message.channelId.toString()));
const messageResponses = await createMessageResponseDataService().buildMessagesForChannels({
userId,
messages: renderableMessages,
channelById,
});
const orderedResponseChannelIds = new Set(validMessageResponseEntries.map((entry) => entry.channelId));
const orderedChannels = Array.from(orderedResponseChannelIds)
const searchMessages = messageResponses.map(
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
);
const respondedChannelIds = new Set(searchMessages.map((message) => message.channel_id));
const orderedChannels = orderedChannelIds
.filter((channelId) => respondedChannelIds.has(channelId))
.map((channelId) => channelById.get(channelId))
.filter((channel): channel is Channel => channel !== undefined);
const channelResponses = await Promise.all(
orderedChannels.map((channel) =>
mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
}),
),
const channelResponses = await mapWithConcurrency(orderedChannels, CHANNEL_LOOKUP_CONCURRENCY, (channel) =>
mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
}),
);
return {
messages: messageResponses,
messages: searchMessages,
channels: channelResponses,
};
}
@@ -6,6 +6,7 @@ import {type ChannelID, createChannelID, createMessageID, type MessageID} from '
import type {IMessageRepository} from '../channel/repositories/IMessageRepository';
import {Logger} from '../Logger';
import type {Message} from '../models/Message';
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
import type {IMessageSearchService} from './IMessageSearchService';
import {deleteMessageSearchDocuments} from './MessageSearchIndexCleanup';
@@ -13,11 +14,16 @@ const RECONCILE_BATCH_SIZE = 250;
const MAX_RECONCILE_PAGES = 40;
const MAX_STALE_DELETE_ABSOLUTE = 250;
const MAX_STALE_DELETE_RATIO = 0.5;
const HIT_LOOKUP_CONCURRENCY = 32;
interface MessageLookupRepository {
readonly messages: Pick<IMessageRepository, 'getMessage'>;
}
interface MessageSearchLookupResult extends SearchResult<SearchableMessage> {
messages: Array<Message>;
}
interface SearchExistingMessagesParams {
searchService: IMessageSearchService;
messageRepository: MessageLookupRepository;
@@ -28,12 +34,21 @@ interface SearchExistingMessagesParams {
cursor?: Array<string>;
}
interface ValidatedHit {
hit: SearchableMessage;
message: Message | null;
}
interface ValidatedHits {
validHits: Array<SearchableMessage>;
validHits: Array<ValidatedHit>;
staleMessageIds: Array<MessageID>;
lookupErrorCount: number;
}
function resolvedMessages(validHits: Array<ValidatedHit>): Array<Message> {
return validHits.map((entry) => entry.message).filter((message): message is Message => message !== null);
}
export async function searchExistingMessages({
searchService,
messageRepository,
@@ -42,7 +57,7 @@ export async function searchExistingMessages({
hitsPerPage,
page,
cursor,
}: SearchExistingMessagesParams): Promise<SearchResult<SearchableMessage>> {
}: SearchExistingMessagesParams): Promise<MessageSearchLookupResult> {
const result = await searchService.searchMessages(query, filters, {
hitsPerPage,
page: cursor?.length ? undefined : page,
@@ -50,7 +65,7 @@ export async function searchExistingMessages({
});
const validated = await validateSearchHits(messageRepository, result.hits);
if (validated.staleMessageIds.length === 0) {
return result;
return {...result, messages: resolvedMessages(validated.validHits)};
}
if (cursor?.length) {
if (validated.lookupErrorCount === 0) {
@@ -58,8 +73,9 @@ export async function searchExistingMessages({
}
return {
...result,
hits: validated.validHits,
hits: validated.validHits.map((entry) => entry.hit),
total: Math.max(validated.validHits.length, result.total - validated.staleMessageIds.length),
messages: resolvedMessages(validated.validHits),
};
}
return reconcileOffsetSearchResult({
@@ -79,9 +95,9 @@ async function reconcileOffsetSearchResult({
filters,
hitsPerPage,
page,
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<SearchResult<SearchableMessage>> {
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<MessageSearchLookupResult> {
const requestedOffset = (page - 1) * hitsPerPage;
const pageHits: Array<SearchableMessage> = [];
const pageHits: Array<ValidatedHit> = [];
const staleMessageIds: Array<MessageID> = [];
let lookupErrorCount = 0;
let examinedCount = 0;
@@ -102,9 +118,9 @@ async function reconcileOffsetSearchResult({
lookupErrorCount += validated.lookupErrorCount;
examinedCount += result.hits.length;
staleMessageIds.push(...validated.staleMessageIds);
for (const hit of validated.validHits) {
for (const entry of validated.validHits) {
if (validTotal >= requestedOffset && pageHits.length < hitsPerPage) {
pageHits.push(hit);
pageHits.push(entry);
}
validTotal += 1;
}
@@ -121,8 +137,9 @@ async function reconcileOffsetSearchResult({
await deleteStaleSearchDocuments(searchService, staleMessageIds, examinedCount);
}
return {
hits: pageHits,
hits: pageHits.map((entry) => entry.hit),
total: Math.max(pageHits.length, corpusTotal - staleMessageIds.length),
messages: resolvedMessages(pageHits),
};
}
@@ -130,38 +147,36 @@ async function validateSearchHits(
messageRepository: MessageLookupRepository,
hits: Array<SearchableMessage>,
): Promise<ValidatedHits> {
const checked = await Promise.all(
hits.map(async (hit) => {
let channelId: ChannelID;
let messageId: MessageID;
try {
channelId = createChannelID(BigInt(hit.channelId));
messageId = createMessageID(BigInt(hit.id));
} catch (_invalidId) {
return {hit: null, staleMessageId: null, lookupError: false};
}
let message: Message | null;
try {
message = await messageRepository.messages.getMessage(channelId, messageId);
} catch (error) {
Logger.warn(
{error, messageId: hit.id, channelId: hit.channelId},
'Search read repair lookup failed; keeping document',
);
return {hit, staleMessageId: null, lookupError: true};
}
if (message && message.channelId.toString() === hit.channelId) {
return {hit, staleMessageId: null, lookupError: false};
}
return {hit: null, staleMessageId: messageId, lookupError: false};
}),
);
const validHits: Array<SearchableMessage> = [];
const checked = await mapWithConcurrency(hits, HIT_LOOKUP_CONCURRENCY, async (hit) => {
let channelId: ChannelID;
let messageId: MessageID;
try {
channelId = createChannelID(BigInt(hit.channelId));
messageId = createMessageID(BigInt(hit.id));
} catch (_invalidId) {
return {entry: null, staleMessageId: null, lookupError: false};
}
let message: Message | null;
try {
message = await messageRepository.messages.getMessage(channelId, messageId);
} catch (error) {
Logger.warn(
{error, messageId: hit.id, channelId: hit.channelId},
'Search read repair lookup failed; keeping document',
);
return {entry: {hit, message: null}, staleMessageId: null, lookupError: true};
}
if (message && message.channelId.toString() === hit.channelId) {
return {entry: {hit, message}, staleMessageId: null, lookupError: false};
}
return {entry: null, staleMessageId: messageId, lookupError: false};
});
const validHits: Array<ValidatedHit> = [];
const staleMessageIds: Array<MessageID> = [];
let lookupErrorCount = 0;
for (const item of checked) {
if (item.hit) {
validHits.push(item.hit);
if (item.entry) {
validHits.push(item.entry);
}
if (item.staleMessageId) {
staleMessageIds.push(item.staleMessageId);
@@ -56,8 +56,11 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
usd: 'USD',
eur: 'EUR',
brl: 'BRL',
dkk: 'DKK',
inr: 'INR',
nok: 'NOK',
pln: 'PLN',
sek: 'SEK',
try: 'TRY',
};
@@ -109,6 +112,14 @@ export class ProductRegistry {
currency: 'BRL',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyDkk, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'DKK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyInr, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -117,6 +128,14 @@ export class ProductRegistry {
currency: 'INR',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyNok, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'NOK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyPln, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -125,6 +144,14 @@ export class ProductRegistry {
currency: 'PLN',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlySek, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'SEK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyTry, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -157,6 +184,14 @@ export class ProductRegistry {
currency: 'BRL',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyDkk, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'DKK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyInr, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -165,6 +200,14 @@ export class ProductRegistry {
currency: 'INR',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyNok, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'NOK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyPln, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -173,6 +216,14 @@ export class ProductRegistry {
currency: 'PLN',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlySek, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'SEK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyTry, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -209,6 +260,48 @@ export class ProductRegistry {
isGift: true,
currency: 'INR',
});
this.registerProduct(prices.gift1MonthDkk, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'DKK',
});
this.registerProduct(prices.gift1YearDkk, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'DKK',
});
this.registerProduct(prices.gift1MonthNok, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'NOK',
});
this.registerProduct(prices.gift1YearNok, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'NOK',
});
this.registerProduct(prices.gift1MonthSek, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'SEK',
});
this.registerProduct(prices.gift1YearSek, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'SEK',
});
this.registerProduct(prices.gift1MonthPln, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -319,12 +412,21 @@ export class ProductRegistry {
if (normalizedCurrency === 'brl') {
return billingCycle === 'monthly' ? (prices.monthlyBrl ?? null) : (prices.yearlyBrl ?? null);
}
if (normalizedCurrency === 'dkk') {
return billingCycle === 'monthly' ? (prices.monthlyDkk ?? null) : (prices.yearlyDkk ?? null);
}
if (normalizedCurrency === 'inr') {
return billingCycle === 'monthly' ? (prices.monthlyInr ?? null) : (prices.yearlyInr ?? null);
}
if (normalizedCurrency === 'nok') {
return billingCycle === 'monthly' ? (prices.monthlyNok ?? null) : (prices.yearlyNok ?? null);
}
if (normalizedCurrency === 'pln') {
return billingCycle === 'monthly' ? (prices.monthlyPln ?? null) : (prices.yearlyPln ?? null);
}
if (normalizedCurrency === 'sek') {
return billingCycle === 'monthly' ? (prices.monthlySek ?? null) : (prices.yearlySek ?? null);
}
if (normalizedCurrency === 'try') {
return billingCycle === 'monthly' ? (prices.monthlyTry ?? null) : (prices.yearlyTry ?? null);
}
@@ -349,6 +451,15 @@ export class ProductRegistry {
if (normalizedCurrency === 'inr') {
return duration === 'gift_1_month' ? (prices.gift1MonthInr ?? null) : (prices.gift1YearInr ?? null);
}
if (normalizedCurrency === 'dkk') {
return duration === 'gift_1_month' ? (prices.gift1MonthDkk ?? null) : (prices.gift1YearDkk ?? null);
}
if (normalizedCurrency === 'nok') {
return duration === 'gift_1_month' ? (prices.gift1MonthNok ?? null) : (prices.gift1YearNok ?? null);
}
if (normalizedCurrency === 'sek') {
return duration === 'gift_1_month' ? (prices.gift1MonthSek ?? null) : (prices.gift1YearSek ?? null);
}
if (normalizedCurrency === 'pln') {
return duration === 'gift_1_month' ? (prices.gift1MonthPln ?? null) : (prices.gift1YearPln ?? null);
}
+7 -22
View File
@@ -92,7 +92,6 @@ export function StripeController(app: HonoApp) {
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
payment_method,
is_business,
} = ctx.req.valid('json');
@@ -105,7 +104,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
paymentMethod: payment_method,
isBusiness: is_business,
});
@@ -129,14 +127,8 @@ export function StripeController(app: HonoApp) {
}),
Validator('json', CreateCheckoutSessionRequest),
async (ctx) => {
const {
price_id,
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
is_business,
} = ctx.req.valid('json');
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
ctx.req.valid('json');
const userId = ctx.get('user').id;
const checkoutUrl = await ctx.get('stripeService').createLocalizedCardPreapprovalSession({
userId,
@@ -145,7 +137,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
isBusiness: is_business,
});
return ctx.json({url: checkoutUrl});
@@ -187,14 +178,8 @@ export function StripeController(app: HonoApp) {
}),
Validator('json', CreateCheckoutSessionRequest),
async (ctx) => {
const {
price_id,
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
is_business,
} = ctx.req.valid('json');
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
ctx.req.valid('json');
const userId = ctx.get('user').id;
const checkoutUrl = await ctx.get('stripeService').createCheckoutSession({
userId,
@@ -204,7 +189,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
isBusiness: is_business,
});
return ctx.json({url: checkoutUrl});
@@ -301,8 +285,9 @@ export function StripeController(app: HonoApp) {
tags: 'Premium',
}),
async (ctx) => {
const {country_code, pricing_mode} = ctx.req.valid('query');
const priceIds = await ctx.get('stripeService').getPriceIds(country_code, pricing_mode);
const {country_code} = ctx.req.valid('query');
const geoipCountryCode = await getPurchaseGeoipCountryCode(ctx.req.raw);
const priceIds = await ctx.get('stripeService').getPriceIds(geoipCountryCode ?? country_code);
return ctx.json(priceIds);
},
);
+2 -7
View File
@@ -4,7 +4,6 @@ import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/Pr
import type {
CurrentSubscriptionPriceResponse,
PremiumStateResponse,
PricingMode,
SelfServeRefundEligibilityResponse,
SelfServeRefundResponse,
SwitchToListPriceResponse,
@@ -144,7 +143,6 @@ export class StripeService {
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'pricingMode'
| 'purchaseGeoipCountryCode'
| 'userId'
>,
@@ -160,10 +158,7 @@ export class StripeService {
return this.checkoutService.createCustomerPortalSession(userId);
}
async getPriceIds(
countryCode?: string,
pricingMode: PricingMode = 'localized',
): Promise<{
async getPriceIds(countryCode?: string): Promise<{
monthly: string | null;
yearly: string | null;
gift_1_month: string | null;
@@ -175,7 +170,7 @@ export class StripeService {
gift_1_month_amount_minor: number | null;
gift_1_year_amount_minor: number | null;
}> {
return this.checkoutService.getPriceIds(countryCode, pricingMode);
return this.checkoutService.getPriceIds(countryCode);
}
async getCurrentSubscriptionPrice(userId: UserID): Promise<CurrentSubscriptionPriceResponse> {
@@ -13,7 +13,6 @@ import type {
PremiumPricingState,
PremiumStateResponse,
PriceIdsResponse,
PricingMode,
SelfServeRefundEligibilityResponse,
SelfServeRefundIneligibilityReason,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
@@ -33,13 +32,7 @@ import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {checkHasActivePaidPremium} from '../../user/UserHelpers';
import {mapUserToPrivateResponse} from '../../user/UserMappers';
import {
type Currency,
getBaseCurrencyPreferences,
getBaseGiftCurrencyPreferences,
getCurrencyPreferences,
getGiftCurrencyPreferences,
} from '../../utils/CurrencyUtils';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
import type {RecurringBillingCycle} from '../ProductRegistry';
import {ProductRegistry} from '../ProductRegistry';
import {getPrimarySubscriptionItem} from '../StripeSubscriptionPeriod';
@@ -111,8 +104,11 @@ function normalizeCurrency(value: string | null | undefined): Currency | null {
currency === 'USD' ||
currency === 'EUR' ||
currency === 'BRL' ||
currency === 'DKK' ||
currency === 'INR' ||
currency === 'NOK' ||
currency === 'PLN' ||
currency === 'SEK' ||
currency === 'TRY'
) {
return currency;
@@ -743,22 +739,15 @@ export class PremiumStateService {
private async resolvePricing(countryCode: string | null | undefined): Promise<PremiumPricingState> {
const normalizedCountryCode = normalizeCountryCode(countryCode);
const [localized, base] = await Promise.all([
this.resolvePriceIds(normalizedCountryCode, 'localized'),
this.resolvePriceIds(normalizedCountryCode, 'base'),
]);
const localized = await this.resolvePriceIds(normalizedCountryCode);
return {
country_code: normalizedCountryCode,
localized,
base,
};
}
private async resolvePriceIds(
countryCode: string | null,
pricingMode: PricingMode,
): Promise<PriceIdsResponse | null> {
const resolved = this.resolveConfiguredPriceIds(countryCode, pricingMode);
private async resolvePriceIds(countryCode: string | null): Promise<PriceIdsResponse | null> {
const resolved = this.resolveConfiguredPriceIds(countryCode);
if (!resolved) return null;
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
resolved.monthly ? this.billingRepository.prices.findById(resolved.monthly) : null,
@@ -775,11 +764,9 @@ export class PremiumStateService {
};
}
private resolveConfiguredPriceIds(countryCode: string | null, pricingMode: PricingMode): ResolvedPriceIds | null {
const recurringCurrencyPreferences =
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
const giftCurrencyPreferences =
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
private resolveConfiguredPriceIds(countryCode: string | null): ResolvedPriceIds | null {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
if (!recurringPrices || !giftPrices) return null;
@@ -13,7 +13,6 @@ import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment
import {UnclaimedAccountCannotMakePurchasesError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotMakePurchasesError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {CheckoutPaymentMethod} from '@fluxer/schema/src/domains/premium/GiftCodeSchemas';
import type {PricingMode} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {seconds} from 'itty-time';
import type Stripe from 'stripe';
@@ -25,13 +24,7 @@ import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {
type Currency,
getBaseCurrencyPreferences,
getBaseGiftCurrencyPreferences,
getCurrencyPreferences,
getGiftCurrencyPreferences,
} from '../../utils/CurrencyUtils';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
import type {ProductInfo, ProductRegistry} from '../ProductRegistry';
import {
canProvisionPremiumFromSubscriptionStatus,
@@ -86,7 +79,6 @@ export interface CreateCheckoutSessionParams {
clientGeoipCountryCode?: string | null;
purchaseGeoipCountryCode?: string | null;
euWithdrawalWaiverAccepted?: boolean;
pricingMode?: PricingMode;
paymentMethod?: CheckoutPaymentMethod;
isBusiness?: boolean;
}
@@ -182,7 +174,6 @@ export class StripeCheckoutService {
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
pricingMode = 'localized',
paymentMethod = 'card',
isBusiness = false,
}: CreateCheckoutSessionParams): Promise<string> {
@@ -191,7 +182,7 @@ export class StripeCheckoutService {
priceId,
isGift,
countryCode,
pricingMode,
purchaseGeoipCountryCode,
});
const isRecurringSubscription = this.productRegistry.isRecurringSubscription(productInfo);
const checkoutMode: CheckoutSessionMode = isRecurringSubscription ? 'subscription' : 'payment';
@@ -221,7 +212,6 @@ export class StripeCheckoutService {
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION,
pricing_mode: pricingMode,
payment_method: paymentMethod,
};
const checkoutParams: CheckoutSessionCreateParams = {
@@ -301,7 +291,6 @@ export class StripeCheckoutService {
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
pricingMode = 'localized',
isBusiness = false,
}: Pick<
CreateCheckoutSessionParams,
@@ -310,31 +299,22 @@ export class StripeCheckoutService {
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'pricingMode'
| 'purchaseGeoipCountryCode'
| 'userId'
>): Promise<string> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
const normalizedCountryCode = countryCode?.trim().toUpperCase();
const normalizedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (!normalizedCountryCode) {
Logger.error({priceId, userId}, 'Localized card preapproval requires a country code');
throw new StripeInvalidProductConfigurationError();
}
if (pricingMode !== 'localized') {
Logger.error(
{priceId, userId, pricingMode},
'Localized card preapproval requested for non-localized pricing mode',
);
throw new StripeInvalidProductConfigurationError();
}
const {customerId, productInfo} = await this.prepareCheckoutContext({
userId,
priceId,
isGift: false,
countryCode: normalizedCountryCode,
pricingMode,
});
if (!this.requiresLocalizedCardPreapproval(productInfo)) {
Logger.error(
@@ -364,7 +344,6 @@ export class StripeCheckoutService {
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
...(waiverContext.required ? {eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION} : {}),
pricing_mode: pricingMode,
setup_type: 'localized_card_preapproval',
localized_card_preapproval_currency: productInfo.currency,
localized_card_preapproval_token: token,
@@ -560,7 +539,7 @@ export class StripeCheckoutService {
priceId,
isGift = false,
countryCode,
pricingMode = 'localized',
purchaseGeoipCountryCode,
}: CreateCheckoutSessionParams): Promise<{
customerId: string;
productInfo: ProductInfo;
@@ -581,12 +560,13 @@ export class StripeCheckoutService {
);
throw new StripeInvalidProductConfigurationError();
}
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !countryCode) {
const enforcedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !enforcedCountryCode) {
Logger.error({priceId, userId, currency: productInfo.currency}, 'Localized price requested without country code');
throw new StripeInvalidProductConfigurationError();
}
if (countryCode) {
this.assertPriceMatchesCountryCatalog({countryCode, priceId, isGift, pricingMode, userId});
if (enforcedCountryCode) {
this.assertPriceMatchesCountryCatalog({countryCode: enforcedCountryCode, priceId, isGift, userId});
}
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -703,20 +683,25 @@ export class StripeCheckoutService {
return normalized && /^[A-Z]{2}$/.test(normalized) ? normalized : null;
}
private resolveEnforcedPricingCountryCode({
countryCode,
purchaseGeoipCountryCode,
}: Pick<CreateCheckoutSessionParams, 'countryCode' | 'purchaseGeoipCountryCode'>): string | null {
return this.normalizeCountryCode(purchaseGeoipCountryCode) ?? this.normalizeCountryCode(countryCode);
}
private assertPriceMatchesCountryCatalog({
countryCode,
priceId,
isGift,
pricingMode = 'localized',
userId,
}: {
countryCode: string;
priceId: string;
isGift: boolean;
pricingMode?: PricingMode;
userId: UserID;
}): void {
const localizedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
const localizedPrices = this.resolveConfiguredPriceIds(countryCode);
const allowedPriceIds = new Set(
(isGift
? [localizedPrices.gift_1_month, localizedPrices.gift_1_year]
@@ -731,7 +716,6 @@ export class StripeCheckoutService {
userId,
currency: isGift ? localizedPrices.gift_currency : localizedPrices.currency,
isGift,
pricingMode,
},
'Checkout price mismatch for country',
);
@@ -1022,8 +1006,8 @@ export class StripeCheckoutService {
}
}
async getPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): Promise<PriceIdsResponse> {
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
async getPriceIds(countryCode?: string): Promise<PriceIdsResponse> {
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode);
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
this.getStripePriceSummary(resolvedPrices.monthly),
this.getStripePriceSummary(resolvedPrices.yearly),
@@ -1057,11 +1041,9 @@ export class StripeCheckoutService {
private static readonly PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
private resolveConfiguredPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): ResolvedPriceIds {
const recurringCurrencyPreferences =
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
const giftCurrencyPreferences =
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
return {
@@ -1126,6 +1108,15 @@ export class StripeCheckoutService {
yearly: prices.yearlyBrl,
currency,
};
case 'DKK':
if (!prices.monthlyDkk || !prices.yearlyDkk) {
return null;
}
return {
monthly: prices.monthlyDkk,
yearly: prices.yearlyDkk,
currency,
};
case 'INR':
if (!prices.monthlyInr || !prices.yearlyInr) {
return null;
@@ -1135,6 +1126,15 @@ export class StripeCheckoutService {
yearly: prices.yearlyInr,
currency,
};
case 'NOK':
if (!prices.monthlyNok || !prices.yearlyNok) {
return null;
}
return {
monthly: prices.monthlyNok,
yearly: prices.yearlyNok,
currency,
};
case 'PLN':
if (!prices.monthlyPln || !prices.yearlyPln) {
return null;
@@ -1144,6 +1144,15 @@ export class StripeCheckoutService {
yearly: prices.yearlyPln,
currency,
};
case 'SEK':
if (!prices.monthlySek || !prices.yearlySek) {
return null;
}
return {
monthly: prices.monthlySek,
yearly: prices.yearlySek,
currency,
};
case 'TRY':
if (!prices.monthlyTry || !prices.yearlyTry) {
return null;
@@ -1193,6 +1202,33 @@ export class StripeCheckoutService {
gift_1_year: prices.gift1YearInr,
gift_currency: 'INR',
};
case 'DKK':
if (!prices.gift1MonthDkk || !prices.gift1YearDkk) {
return null;
}
return {
gift_1_month: prices.gift1MonthDkk,
gift_1_year: prices.gift1YearDkk,
gift_currency: 'DKK',
};
case 'NOK':
if (!prices.gift1MonthNok || !prices.gift1YearNok) {
return null;
}
return {
gift_1_month: prices.gift1MonthNok,
gift_1_year: prices.gift1YearNok,
gift_currency: 'NOK',
};
case 'SEK':
if (!prices.gift1MonthSek || !prices.gift1YearSek) {
return null;
}
return {
gift_1_month: prices.gift1MonthSek,
gift_1_year: prices.gift1YearSek,
gift_currency: 'SEK',
};
case 'PLN':
if (!prices.gift1MonthPln || !prices.gift1YearPln) {
return null;
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import Stripe from 'stripe';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {STRIPE_API_VERSION} from '../StripeApiVersion';
const MOCK_PRICES = {
monthlyUsd: 'price_state_pricing_monthly_usd',
yearlyUsd: 'price_state_pricing_yearly_usd',
monthlyBrl: 'price_state_pricing_monthly_brl',
yearlyBrl: 'price_state_pricing_yearly_brl',
gift1MonthUsd: 'price_state_pricing_gift_1_month_usd',
gift1YearUsd: 'price_state_pricing_gift_1_year_usd',
gift1MonthBrl: 'price_state_pricing_gift_1_month_brl',
gift1YearBrl: 'price_state_pricing_gift_1_year_brl',
};
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},
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
[MOCK_PRICES.gift1MonthUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.gift1YearUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.gift1MonthBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.gift1YearBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
};
describe('PremiumStatePricing', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function mirrorPrices(): Promise<void> {
const stripe = new Stripe(Config.stripe.secretKey ?? 'sk_test_fluxer', {
apiVersion: STRIPE_API_VERSION,
httpClient: Stripe.createFetchHttpClient(),
});
for (const priceId of Object.keys(MOCK_PRICE_SEEDS)) {
const price = await stripe.prices.retrieve(priceId);
await getBillingRepository().prices.upsertFromStripe(price);
}
}
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;
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
server.use(...stripeHandlers.handlers);
await mirrorPrices();
});
test('resolves the localized BRL catalog for a Brazilian request', async () => {
const account = await createTestAccount(harness);
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state?country_code=BR')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.country_code).toBe('BR');
expect(state.pricing.localized?.currency).toBe('BRL');
expect(state.pricing.localized?.monthly_amount_minor).toBe(1890);
expect(state.pricing.localized?.yearly_amount_minor).toBe(18900);
expect(state.pricing.localized?.monthly).toBe(MOCK_PRICES.monthlyBrl);
expect(state.pricing.localized?.yearly).toBe(MOCK_PRICES.yearlyBrl);
});
test('resolves the USD catalog when the request declares no country', async () => {
const account = await createTestAccount(harness);
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.country_code).toBeNull();
expect(state.pricing.localized?.currency).toBe('USD');
expect(state.pricing.localized?.monthly_amount_minor).toBe(499);
});
});
@@ -119,16 +119,22 @@ describe('ProductRegistry - legacy prices', () => {
monthly_usd: ['legacy_usd'],
monthly_eur: ['legacy_eur'],
monthly_brl: ['legacy_brl'],
monthly_dkk: ['legacy_dkk'],
monthly_inr: ['legacy_inr'],
monthly_nok: ['legacy_nok'],
monthly_pln: ['legacy_pln'],
monthly_sek: ['legacy_sek'],
monthly_try: ['legacy_try'],
},
);
expect(registry.getProduct('legacy_usd')?.currency).toBe('USD');
expect(registry.getProduct('legacy_eur')?.currency).toBe('EUR');
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_nok')?.currency).toBe('NOK');
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
expect(registry.getProduct('legacy_sek')?.currency).toBe('SEK');
expect(registry.getProduct('legacy_try')?.currency).toBe('TRY');
});
@@ -205,7 +211,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', 'INR', 'PLN', 'TRY'];
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'];
const offered: Array<string> = [];
for (const currency of currencies) {
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
const {lookupGeoipMock} = vi.hoisted(() => ({
lookupGeoipMock: vi.fn(),
}));
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
lookupGeoip: lookupGeoipMock,
}));
const MOCK_PRICES = {
monthlyUsd: 'price_enforce_monthly_usd',
yearlyUsd: 'price_enforce_yearly_usd',
monthlyBrl: 'price_enforce_monthly_brl',
yearlyBrl: 'price_enforce_yearly_brl',
gift1MonthUsd: 'price_enforce_gift_1_month_usd',
gift1YearUsd: 'price_enforce_gift_1_year_usd',
gift1MonthBrl: 'price_enforce_gift_1_month_brl',
gift1YearBrl: 'price_enforce_gift_1_year_brl',
};
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},
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
};
function geoipCountry(countryCode: string | null): GeoipResult {
return {
countryCode,
normalizedIp: '203.0.113.10',
city: null,
region: null,
countryName: null,
};
}
describe('StripeCheckoutCountryEnforcement', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function createPurchaser(): Promise<string> {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
return account.token;
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(() => {
Config.stripe.prices = MOCK_PRICES;
lookupGeoipMock.mockReset();
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
server.use(...stripeHandlers.handlers);
});
test('rejects a base-currency subscription price when the request geolocates to a localized market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a base-currency subscription price when country_code is omitted entirely', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a localized gift price when the request geolocates outside that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('US'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a localized gift price even from inside that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts the base gift price from inside a localized market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthUsd, country_code: 'BR'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts the localized price for a request that geolocates to that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('falls back to the declared country when the request cannot be geolocated', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
});
test('resolves price ids from the request geolocation rather than the query parameter', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const priceIds = await createBuilder<{currency: string; monthly: string | null}>(harness, '')
.get('/premium/price-ids?country_code=US')
.expect(HTTP_STATUS.OK)
.execute();
expect(priceIds.currency).toBe('BRL');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyBrl);
});
});
@@ -0,0 +1,288 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
CurrentSubscriptionPriceResponse,
PriceIdsResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
const {lookupGeoipMock} = vi.hoisted(() => ({
lookupGeoipMock: vi.fn(),
}));
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
lookupGeoip: lookupGeoipMock,
}));
const MOCK_PRICES = {
monthlyUsd: 'price_nordic_monthly_usd',
yearlyUsd: 'price_nordic_yearly_usd',
monthlyEur: 'price_nordic_monthly_eur',
yearlyEur: 'price_nordic_yearly_eur',
monthlyDkk: 'price_nordic_monthly_dkk',
yearlyDkk: 'price_nordic_yearly_dkk',
monthlyNok: 'price_nordic_monthly_nok',
yearlyNok: 'price_nordic_yearly_nok',
monthlyPln: 'price_nordic_monthly_pln',
yearlyPln: 'price_nordic_yearly_pln',
monthlySek: 'price_nordic_monthly_sek',
yearlySek: 'price_nordic_yearly_sek',
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',
};
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},
[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.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},
};
const EUR_SUBSCRIPTION_ID = 'sub_nordic_legacy_eur';
function geoipCountry(countryCode: string | null): GeoipResult {
return {
countryCode,
normalizedIp: '203.0.113.10',
city: null,
region: null,
countryName: null,
};
}
describe('Nordic localized currencies', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function createPurchaser(): Promise<string> {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
return account.token;
}
function getPriceIds(countryCode: string | null): Promise<PriceIdsResponse> {
lookupGeoipMock.mockResolvedValue(geoipCountry(countryCode));
return createBuilder<PriceIdsResponse>(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(() => {
Config.stripe.prices = MOCK_PRICES;
lookupGeoipMock.mockReset();
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
stripeHandlers = createStripeApiHandlers({
prices: MOCK_PRICE_SEEDS,
subscriptionsListEmpty: true,
subscriptions: {
[EUR_SUBSCRIPTION_ID]: {
customer: `cus_${EUR_SUBSCRIPTION_ID}`,
price_id: MOCK_PRICES.monthlyEur,
unit_amount: 499,
currency: 'eur',
interval: 'month',
item_id: `si_${EUR_SUBSCRIPTION_ID}`,
},
},
});
server.use(...stripeHandlers.handlers);
});
describe('country routing', () => {
test('Sweden resolves to the SEK subscription catalog', async () => {
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('SEK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlySek);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlySek);
});
test('Denmark resolves to the DKK subscription catalog', async () => {
const priceIds = await getPriceIds('DK');
expect(priceIds.currency).toBe('DKK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyDkk);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyDkk);
});
test('Norway resolves to the NOK subscription catalog', async () => {
const priceIds = await getPriceIds('NO');
expect(priceIds.currency).toBe('NOK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyNok);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyNok);
});
test('Poland still resolves to the PLN subscription catalog', async () => {
const priceIds = await getPriceIds('PL');
expect(priceIds.currency).toBe('PLN');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyPln);
});
test.each(['DE', 'NL', 'FI', 'IE', 'IS'])('%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) => {
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);
});
test('a Nordic gift falls through to USD when no EUR gift price is configured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
gift1MonthEur: undefined,
gift1YearEur: undefined,
};
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('SEK');
expect(priceIds.gift_currency).toBe('USD');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthUsd);
});
test('Sweden falls back to EUR while the SEK prices are still unconfigured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
monthlySek: undefined,
yearlySek: undefined,
};
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.gift_currency).toBe('EUR');
});
});
describe('checkout enforcement', () => {
test('rejects the EUR subscription price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyEur, country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects the USD subscription price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a neighbouring Nordic price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyDkk, country_code: 'DK'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts the SEK subscription price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts the base 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'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('rejects the SEK subscription price on the gift endpoint', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
});
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'));
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
stripe_subscription_id: EUR_SUBSCRIPTION_ID,
premium_type: 1,
premium_billing_cycle: 'monthly',
premium_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
premium_will_cancel: false,
})
.execute();
const price = await createBuilder<CurrentSubscriptionPriceResponse>(harness, account.token)
.get('/premium/current-subscription-price')
.expect(HTTP_STATUS.OK)
.execute();
expect(price).toMatchObject({
price_id: MOCK_PRICES.monthlyEur,
currency: 'EUR',
billing_cycle: 'monthly',
is_grandfathered: false,
list_price_id: MOCK_PRICES.monthlyEur,
});
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0);
});
});
});
@@ -113,7 +113,10 @@ export function setupSyncStripeWebhookWorker(): void {
setInjectedWorkerService(new SyncTaskWorkerService({processStripeWebhook}));
}
let originalWebhookSecretDescriptor: PropertyDescriptor | undefined;
export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
originalWebhookSecretDescriptor ??= Object.getOwnPropertyDescriptor(Config.stripe, 'webhookSecret');
Object.defineProperty(Config.stripe, 'webhookSecret', {
get: () => secret,
configurable: true,
@@ -121,6 +124,11 @@ export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
}
export function restoreStripeWebhookSecret(): void {
if (originalWebhookSecretDescriptor) {
Object.defineProperty(Config.stripe, 'webhookSecret', originalWebhookSecretDescriptor);
originalWebhookSecretDescriptor = undefined;
return;
}
delete (
Config.stripe as {
webhookSecret?: string;
@@ -353,6 +353,16 @@ interface MockStripeSubscriptionSchedule {
}>;
}
const PRICE_ID_CURRENCY_MARKERS = ['eur', 'brl', 'dkk', 'inr', 'nok', 'pln', 'sek', 'try'] as const;
function inferPriceIdCurrency(normalizedPriceId: string): string {
return PRICE_ID_CURRENCY_MARKERS.find((marker) => normalizedPriceId.includes(marker)) ?? 'usd';
}
function inferPriceIdInterval(normalizedPriceId: string): 'month' | 'year' {
return normalizedPriceId.includes('year') ? 'year' : 'month';
}
function parseFormDataToObject<T extends object = Record<string, unknown>>(formData: FormData): T {
const result: Record<string, unknown> = {};
const isArrayIndex = (segment: string): boolean => /^\d+$/.test(segment);
@@ -797,18 +807,8 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
function inferSubscriptionPriceState(priceId: string): Pick<MockStripeSubscriptionState, 'currency' | 'interval'> {
const normalizedPriceId = priceId.toLowerCase();
return {
currency: normalizedPriceId.includes('eur')
? 'eur'
: normalizedPriceId.includes('brl')
? 'brl'
: normalizedPriceId.includes('inr')
? 'inr'
: normalizedPriceId.includes('pln')
? 'pln'
: normalizedPriceId.includes('try')
? 'try'
: 'usd',
interval: normalizedPriceId.includes('year') ? 'year' : 'month',
currency: inferPriceIdCurrency(normalizedPriceId),
interval: inferPriceIdInterval(normalizedPriceId),
};
}
function createDefaultSubscriptionState(): MockStripeSubscriptionState {
@@ -1718,23 +1718,11 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
id,
object: 'price',
active: true,
currency:
overrides?.currency ??
(normalizedPriceId.includes('eur')
? 'eur'
: normalizedPriceId.includes('brl')
? 'brl'
: normalizedPriceId.includes('inr')
? 'inr'
: normalizedPriceId.includes('pln')
? 'pln'
: normalizedPriceId.includes('try')
? 'try'
: 'usd'),
unit_amount: overrides?.unit_amount ?? (normalizedPriceId.includes('year') ? 4999 : 499),
currency: overrides?.currency ?? inferPriceIdCurrency(normalizedPriceId),
unit_amount: overrides?.unit_amount ?? (inferPriceIdInterval(normalizedPriceId) === 'year' ? 4999 : 499),
type: 'recurring',
recurring: {
interval: overrides?.interval ?? (normalizedPriceId.includes('year') ? 'year' : 'month'),
interval: overrides?.interval ?? inferPriceIdInterval(normalizedPriceId),
interval_count: 1,
},
product: overrides?.product ?? 'prod_test_1',
@@ -125,12 +125,13 @@ describe('User Settings synced_preferences', () => {
});
test('reports TOO_LARGE for a snapshot inside the encoded-length bound but over the byte cap', async () => {
const account = await createTestAccount(harness);
const baseEntries = Math.floor((SYNCED_PREFERENCES_MAX_BYTES - 8192) / 1006);
const build = (tailLength: number) =>
encodeSyncedPreferences(
create(SyncedPreferencesSchema, {
localSpamOverrides: create(LocalUserSpamOverridesSchema, {
spammerUserIds: [
...Array.from({length: 260}, (_, i) => `${i}-${'x'.repeat(1000)}`),
...Array.from({length: baseEntries}, (_, i) => `${i}-${'x'.repeat(1000)}`),
'y'.repeat(tailLength),
],
}),
@@ -138,7 +139,13 @@ describe('User Settings synced_preferences', () => {
);
let tail = 1;
let encoded = build(tail);
while (encodedSyncedPreferencesByteLength(encoded) < SYNCED_PREFERENCES_MAX_BYTES + 1 && tail < 8000) {
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
tail += 512;
encoded = build(tail);
}
tail = Math.max(1, tail - 512);
encoded = build(tail);
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
tail += 1;
encoded = build(tail);
}
+14 -16
View File
@@ -2,7 +2,7 @@
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
export type Currency = 'USD' | 'EUR' | 'BRL' | 'INR' | 'PLN' | 'TRY';
export type Currency = 'USD' | 'EUR' | 'BRL' | 'DKK' | 'INR' | 'NOK' | 'PLN' | 'SEK' | 'TRY';
export function getCurrency(countryCode: string | null | undefined): Currency {
return getCurrencyPreferences(countryCode)[0];
@@ -16,12 +16,21 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
if (upperCode === 'BR') {
return ['BRL', 'USD', 'EUR'];
}
if (upperCode === 'DK') {
return ['DKK', 'EUR', 'USD'];
}
if (upperCode === 'IN') {
return ['INR', 'USD', 'EUR'];
}
if (upperCode === 'NO') {
return ['NOK', 'EUR', 'USD'];
}
if (upperCode === 'PL') {
return ['PLN', 'EUR', 'USD'];
}
if (upperCode === 'SE') {
return ['SEK', 'EUR', 'USD'];
}
if (upperCode === 'TR') {
return ['TRY', 'USD', 'EUR'];
}
@@ -31,21 +40,10 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
return ['USD', 'EUR'];
}
export function getBaseCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
if (!countryCode) {
return ['USD', 'EUR'];
}
const upperCode = countryCode.toUpperCase();
if (isEuEeaCountryCode(upperCode)) {
return ['EUR', 'USD'];
}
return ['USD', 'EUR'];
}
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
return getCurrencyPreferences(countryCode);
}
export function getBaseGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
return getBaseCurrencyPreferences(countryCode);
return getCurrencyPreferences(countryCode).filter(
(currency) => currency === 'USD' || currency === 'EUR' || GIFT_ELIGIBLE_LOCALIZED_CURRENCIES.has(currency),
);
}
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {getCurrency} from '../CurrencyUtils';
import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../CurrencyUtils';
describe('getCurrency', () => {
describe('returns USD for non-EEA countries', () => {
@@ -55,11 +55,11 @@ describe('getCurrency', () => {
it('returns EUR for Finland', () => {
expect(getCurrency('FI')).toBe('EUR');
});
it('returns EUR for Sweden', () => {
expect(getCurrency('SE')).toBe('EUR');
it('returns SEK for Sweden', () => {
expect(getCurrency('SE')).toBe('SEK');
});
it('returns EUR for Denmark', () => {
expect(getCurrency('DK')).toBe('EUR');
it('returns DKK for Denmark', () => {
expect(getCurrency('DK')).toBe('DKK');
});
it('returns PLN for Poland', () => {
expect(getCurrency('PL')).toBe('PLN');
@@ -76,8 +76,8 @@ describe('getCurrency', () => {
it('returns EUR for Romania', () => {
expect(getCurrency('RO')).toBe('EUR');
});
it('returns EUR for Norway (EEA but not EU)', () => {
expect(getCurrency('NO')).toBe('EUR');
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');
@@ -128,7 +128,6 @@ describe('getCurrency', () => {
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
@@ -147,10 +146,8 @@ describe('getCurrency', () => {
'SK',
'SI',
'ES',
'SE',
'IS',
'LI',
'NO',
];
for (const country of eeaCountries) {
it(`returns EUR for ${country}`, () => {
@@ -160,5 +157,45 @@ describe('getCurrency', () => {
it('uses local currency for Poland', () => {
expect(getCurrency('PL')).toBe('PLN');
});
it('uses local currency for Sweden', () => {
expect(getCurrency('SE')).toBe('SEK');
});
it('uses local currency for Denmark', () => {
expect(getCurrency('DK')).toBe('DKK');
});
it('uses local currency for Norway', () => {
expect(getCurrency('NO')).toBe('NOK');
});
});
});
describe('getGiftCurrencyPreferences', () => {
it('never offers a localized currency that is cheaper than the base price', () => {
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', () => {
expect(getGiftCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('DK')).toEqual(['DKK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('NO')).toEqual(['NOK', 'EUR', 'USD']);
});
it('uses EUR for other EEA countries', () => {
expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']);
expect(getGiftCurrencyPreferences('PL')).toEqual(['EUR', 'USD']);
});
it('uses USD everywhere else', () => {
expect(getGiftCurrencyPreferences('BR')).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences('IN')).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences('TR')).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences('US')).toEqual(['USD', 'EUR']);
});
it('uses USD when the country is unknown', () => {
expect(getGiftCurrencyPreferences(null)).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences(undefined)).toEqual(['USD', 'EUR']);
});
it('is case insensitive', () => {
expect(getGiftCurrencyPreferences('se')).toEqual(['SEK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('br')).toEqual(['USD', 'EUR']);
});
});
@@ -3,6 +3,8 @@
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {GuildID, UserID} from '../BrandedTypes';
import type {VoiceRegionAvailability, VoiceRegionMetadata, VoiceRegionRecord, VoiceServerRecord} from './VoiceModel';
import {preferServersUnderSoftLimit} from './VoiceRegionSelection';
import type {VoiceServerLoadSource} from './VoiceServerLoad';
import type {VoiceTopology} from './VoiceTopology';
export interface VoiceAccessContext {
@@ -11,10 +13,19 @@ export interface VoiceAccessContext {
guildFeatures?: Set<string>;
}
const EMPTY_CONNECTION_COUNTS: ReadonlyMap<string, number> = new Map();
export class VoiceAvailabilityService {
private rotationIndex: Map<string, number> = new Map();
constructor(private topology: VoiceTopology) {}
constructor(
private topology: VoiceTopology,
private loadSource: VoiceServerLoadSource | null = null,
) {}
getServerConnectionCounts(): ReadonlyMap<string, number> {
return this.loadSource?.getConnectionCounts() ?? EMPTY_CONNECTION_COUNTS;
}
getRegionMetadata(): Array<VoiceRegionMetadata> {
return this.topology.getRegionMetadataList();
@@ -140,9 +151,10 @@ export class VoiceAvailabilityService {
if (accessibleServers.length === 0) {
return null;
}
const candidateServers = preferServersUnderSoftLimit(accessibleServers, this.getServerConnectionCounts());
const index = this.rotationIndex.get(regionId) ?? 0;
const server = accessibleServers[index % accessibleServers.length];
this.rotationIndex.set(regionId, (index + 1) % accessibleServers.length);
const server = candidateServers[index % candidateServers.length];
this.rotationIndex.set(regionId, (index + 1) % candidateServers.length);
return server;
}
@@ -56,6 +56,7 @@ export class VoiceDataInitializer {
latitude: null,
longitude: null,
isActive: true,
softConnectionLimit: null,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
+1
View File
@@ -30,6 +30,7 @@ export interface VoiceServerRecord {
latitude: number | null;
longitude: number | null;
isActive: boolean;
softConnectionLimit: number | null;
restrictions: VoiceRestriction;
createdAt: Date | null;
updatedAt: Date | null;
@@ -77,12 +77,14 @@ export function selectVoiceRegionId({
export function selectClosestPseudoRegionServer({
mode,
accessibleServers,
connectionCounts,
latitude,
longitude,
selectionKey,
}: {
mode: VoiceRegionPreference['mode'];
accessibleServers: Array<VoiceServerRecord>;
connectionCounts: ReadonlyMap<string, number>;
latitude?: string;
longitude?: string;
selectionKey: string;
@@ -95,10 +97,31 @@ export function selectClosestPseudoRegionServer({
if (userLat === null || userLon === null) {
return null;
}
const closestServers = findClosestServers(accessibleServers, userLat, userLon);
const preferredServers = preferServersUnderSoftLimit(accessibleServers, connectionCounts);
const closestServers = findClosestServers(preferredServers, userLat, userLon);
return selectBalancedServer(closestServers, selectionKey);
}
export function preferServersUnderSoftLimit(
servers: Array<VoiceServerRecord>,
connectionCounts: ReadonlyMap<string, number>,
): Array<VoiceServerRecord> {
const serversUnderLimit = servers.filter((server) => !isServerAtSoftLimit(server, connectionCounts));
return serversUnderLimit.length > 0 ? serversUnderLimit : servers;
}
function isServerAtSoftLimit(server: VoiceServerRecord, connectionCounts: ReadonlyMap<string, number>): boolean {
const limit = server.softConnectionLimit;
if (limit === null || limit <= 0) {
return false;
}
const connectionCount = connectionCounts.get(server.serverId);
if (connectionCount === undefined) {
return false;
}
return connectionCount >= limit;
}
function findClosestRegionIds(
latitude: string | undefined,
longitude: string | undefined,
@@ -147,6 +147,7 @@ export class VoiceRepository implements IVoiceRepository {
latitude: server.latitude ?? null,
longitude: server.longitude ?? null,
is_active: server.isActive,
soft_connection_limit: server.softConnectionLimit ?? null,
vip_only: server.restrictions.vipOnly,
required_guild_features: new Set(server.restrictions.requiredGuildFeatures),
allowed_guild_ids: new Set(Array.from(server.restrictions.allowedGuildIds).map((id) => BigInt(id))),
@@ -190,6 +191,7 @@ export class VoiceRepository implements IVoiceRepository {
latitude: row.latitude ?? null,
longitude: row.longitude ?? null,
isActive: row.is_active ?? true,
softConnectionLimit: row.soft_connection_limit ?? null,
restrictions: {
vipOnly: row.vip_only ?? false,
requiredGuildFeatures: new Set(toIterable<string>(row.required_guild_features)),
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IGatewayService} from '../infrastructure/IGatewayService';
import {Logger} from '../Logger';
const DEFAULT_REFRESH_INTERVAL_MS = 15000;
const STALE_REFRESH_INTERVALS = 4;
const EMPTY_CONNECTION_COUNTS: ReadonlyMap<string, number> = new Map();
export interface VoiceServerLoadSource {
getConnectionCounts(): ReadonlyMap<string, number>;
}
export class VoiceServerLoadTracker implements VoiceServerLoadSource {
private readonly gatewayService: IGatewayService;
private readonly refreshIntervalMs: number;
private readonly staleAfterMs: number;
private readonly now: () => number;
private connectionCounts: ReadonlyMap<string, number> = EMPTY_CONNECTION_COUNTS;
private lastAttemptAt = 0;
private lastSuccessAt = 0;
private refreshing: Promise<void> | null = null;
constructor(options: {
gatewayService: IGatewayService;
refreshIntervalMs?: number;
now?: () => number;
}) {
this.gatewayService = options.gatewayService;
this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
this.staleAfterMs = this.refreshIntervalMs * STALE_REFRESH_INTERVALS;
this.now = options.now ?? Date.now;
}
getConnectionCounts(): ReadonlyMap<string, number> {
const now = this.now();
if (now - this.lastAttemptAt >= this.refreshIntervalMs) {
void this.refresh();
}
if (this.lastSuccessAt === 0 || now - this.lastSuccessAt > this.staleAfterMs) {
return EMPTY_CONNECTION_COUNTS;
}
return this.connectionCounts;
}
async refresh(): Promise<void> {
if (this.refreshing) {
return this.refreshing;
}
this.lastAttemptAt = this.now();
this.refreshing = this.gatewayService
.getVoiceStateCounts()
.then((counts) => {
const nextCounts = new Map<string, number>();
for (const server of counts.servers) {
nextCounts.set(server.server_id, server.voice_state_count);
}
this.connectionCounts = nextCounts;
this.lastSuccessAt = this.now();
})
.catch((error) => {
Logger.warn({error}, 'Failed to refresh voice server connection counts');
})
.finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
}
+1
View File
@@ -161,6 +161,7 @@ export class VoiceService {
const pseudoRegionServer = selectClosestPseudoRegionServer({
mode: regionPreference.mode,
accessibleServers,
connectionCounts: this.voiceAvailabilityService.getServerConnectionCounts(),
latitude: params.latitude,
longitude: params.longitude,
selectionKey,
@@ -38,6 +38,7 @@ function createMockServer(overrides: Partial<VoiceServerRecord> = {}): VoiceServ
latitude: null,
longitude: null,
isActive: true,
softConnectionLimit: null,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
@@ -384,5 +385,37 @@ describe('VoiceAvailabilityService', () => {
expect(first!.serverId).toBe('server-1');
expect(second!.serverId).toBe('server-2');
});
it('rotates only between servers below their soft connection limit', () => {
const region = createMockRegion();
const server1 = createMockServer({serverId: 'server-1', softConnectionLimit: 50});
const server2 = createMockServer({serverId: 'server-2'});
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
service = new VoiceAvailabilityService(topology, {
getConnectionCounts: () => new Map([['server-1', 50]]),
});
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
});
it('rotates across every server when all of them are at their soft connection limit', () => {
const region = createMockRegion();
const server1 = createMockServer({serverId: 'server-1', softConnectionLimit: 50});
const server2 = createMockServer({serverId: 'server-2', softConnectionLimit: 50});
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
service = new VoiceAvailabilityService(topology, {
getConnectionCounts: () =>
new Map([
['server-1', 90],
['server-2', 90],
]),
});
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.selectServer('us-default', context)!.serverId).toBe('server-1');
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
});
});
});
@@ -3,6 +3,7 @@
import {describe, expect, it} from 'vitest';
import type {VoiceRegionAvailability, VoiceServerRecord} from '../VoiceModel';
import {
preferServersUnderSoftLimit,
resolveVoiceRegionPreference,
selectClosestPseudoRegionServer,
selectVoiceRegionId,
@@ -45,11 +46,13 @@ function createVoiceServer({
serverId,
latitude,
longitude,
softConnectionLimit = null,
}: {
regionId: string;
serverId: string;
latitude: number | null;
longitude: number | null;
softConnectionLimit?: number | null;
}): VoiceServerRecord {
return {
regionId,
@@ -60,6 +63,7 @@ function createVoiceServer({
latitude,
longitude,
isActive: true,
softConnectionLimit,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
@@ -123,6 +127,7 @@ describe('VoiceRegionSelection', () => {
const selectedServer = selectClosestPseudoRegionServer({
mode: 'automatic',
accessibleServers: [serverA, serverB],
connectionCounts: new Map(),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:1',
@@ -136,6 +141,7 @@ describe('VoiceRegionSelection', () => {
const selectedFromForwardOrder = selectClosestPseudoRegionServer({
mode: 'automatic',
accessibleServers: [serverB, serverA],
connectionCounts: new Map(),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:1',
@@ -143,6 +149,7 @@ describe('VoiceRegionSelection', () => {
const selectedFromReverseOrder = selectClosestPseudoRegionServer({
mode: 'automatic',
accessibleServers: [serverA, serverB],
connectionCounts: new Map(),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:1',
@@ -150,6 +157,7 @@ describe('VoiceRegionSelection', () => {
const selectedForAnotherRoom = selectClosestPseudoRegionServer({
mode: 'automatic',
accessibleServers: [serverB, serverA],
connectionCounts: new Map(),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:2',
@@ -164,6 +172,7 @@ describe('VoiceRegionSelection', () => {
const selectedServer = selectClosestPseudoRegionServer({
mode: 'explicit',
accessibleServers: [serverA, serverB],
connectionCounts: new Map(),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:1',
@@ -206,4 +215,91 @@ describe('VoiceRegionSelection', () => {
expect(selectedFromReverseOrder).toBe('b');
expect(selectedForAnotherRoom).toBe('a');
});
it('skips a pseudo-region server that reached its soft connection limit', () => {
const nearServer = createVoiceServer({
regionId: 'a',
serverId: 'a1',
latitude: 51,
longitude: 51,
softConnectionLimit: 100,
});
const farServer = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: 0, longitude: 0});
const selectedServer = selectClosestPseudoRegionServer({
mode: 'automatic',
accessibleServers: [nearServer, farServer],
connectionCounts: new Map([['a1', 100]]),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:1',
});
expect(selectedServer?.serverId).toBe('b1');
});
it('keeps a pseudo-region server that is still below its soft connection limit', () => {
const nearServer = createVoiceServer({
regionId: 'a',
serverId: 'a1',
latitude: 51,
longitude: 51,
softConnectionLimit: 100,
});
const farServer = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: 0, longitude: 0});
const selectedServer = selectClosestPseudoRegionServer({
mode: 'automatic',
accessibleServers: [nearServer, farServer],
connectionCounts: new Map([['a1', 99]]),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:1',
});
expect(selectedServer?.serverId).toBe('a1');
});
it('falls back to a server over its soft connection limit when every candidate is over', () => {
const serverA = createVoiceServer({
regionId: 'a',
serverId: 'a1',
latitude: 51,
longitude: 51,
softConnectionLimit: 10,
});
const serverB = createVoiceServer({
regionId: 'b',
serverId: 'b1',
latitude: 0,
longitude: 0,
softConnectionLimit: 10,
});
const selectedServer = selectClosestPseudoRegionServer({
mode: 'automatic',
accessibleServers: [serverA, serverB],
connectionCounts: new Map([
['a1', 40],
['b1', 40],
]),
latitude: '50',
longitude: '50',
selectionKey: 'guild:1:channel:1',
});
expect(selectedServer?.serverId).toBe('a1');
});
it('ignores a soft connection limit when no count is known for the server', () => {
const serverA = createVoiceServer({
regionId: 'a',
serverId: 'a1',
latitude: null,
longitude: null,
softConnectionLimit: 1,
});
const serverB = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: null, longitude: null});
expect(preferServersUnderSoftLimit([serverA, serverB], new Map())).toEqual([serverA, serverB]);
});
it('ignores a soft connection limit that is not positive', () => {
const serverA = createVoiceServer({
regionId: 'a',
serverId: 'a1',
latitude: null,
longitude: null,
softConnectionLimit: 0,
});
expect(preferServersUnderSoftLimit([serverA], new Map([['a1', 500]]))).toEqual([serverA]);
});
});
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import type {GatewayVoiceStateCounts, IGatewayService} from '../../infrastructure/IGatewayService';
import {VoiceServerLoadTracker} from '../VoiceServerLoad';
function createGatewayService(respond: () => Promise<GatewayVoiceStateCounts>): {
gatewayService: IGatewayService;
callCount: () => number;
} {
let calls = 0;
const gatewayService = {
getVoiceStateCounts: () => {
calls += 1;
return respond();
},
} as IGatewayService;
return {gatewayService, callCount: () => calls};
}
function counts(servers: Array<{server_id: string; voice_state_count: number}>): GatewayVoiceStateCounts {
return {
total_voice_states: servers.reduce((total, server) => total + server.voice_state_count, 0),
regions: [],
servers,
};
}
describe('VoiceServerLoadTracker', () => {
it('reports no counts until the first refresh resolves', () => {
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
const tracker = new VoiceServerLoadTracker({gatewayService});
expect(tracker.getConnectionCounts().size).toBe(0);
});
it('reports the counts the gateway returned', async () => {
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
const tracker = new VoiceServerLoadTracker({gatewayService});
await tracker.refresh();
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
});
it('keeps the last counts when a refresh fails', async () => {
let shouldFail = false;
const {gatewayService} = createGatewayService(async () => {
if (shouldFail) {
throw new Error('gateway unavailable');
}
return counts([{server_id: 'server-1', voice_state_count: 7}]);
});
const tracker = new VoiceServerLoadTracker({gatewayService});
await tracker.refresh();
shouldFail = true;
await tracker.refresh();
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
});
it('refreshes no more often than the refresh interval', async () => {
let currentTime = 1000;
const {gatewayService, callCount} = createGatewayService(async () =>
counts([{server_id: 'server-1', voice_state_count: 7}]),
);
const tracker = new VoiceServerLoadTracker({
gatewayService,
refreshIntervalMs: 5000,
now: () => currentTime,
});
await tracker.refresh();
tracker.getConnectionCounts();
currentTime += 4999;
tracker.getConnectionCounts();
expect(callCount()).toBe(1);
currentTime += 1;
tracker.getConnectionCounts();
expect(callCount()).toBe(2);
});
it('drops counts that are too old to place against', async () => {
let currentTime = 1000;
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
const tracker = new VoiceServerLoadTracker({
gatewayService,
refreshIntervalMs: 5000,
now: () => currentTime,
});
await tracker.refresh();
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
currentTime += 20001;
expect(tracker.getConnectionCounts().size).toBe(0);
});
});
+2
View File
@@ -26,6 +26,8 @@ const configuredMaxConcurrency = parseParallelInteger(process.env.API_TEST_MAX_C
const MODULE_REGISTRY_TEST_FILES = [
'src/api/gif/GifRequestCountry.test.ts',
'src/api/risk/__tests__/AccountPolicyService.test.ts',
'src/api/stripe/tests/StripeCheckoutCountryEnforcement.test.ts',
'src/api/stripe/tests/StripeNordicCurrencies.test.ts',
];
const INSTANCE_POLICY_TEST_FILES = [
-2
View File
@@ -55,7 +55,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
@@ -149,7 +148,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
};
}, []);
useEffect(() => startScreenSharePiPController(), []);
useEffect(() => startMediaDeviceStartupPreload(), []);
useServiceWorkerBadge();
useKeybindManager(i18n);
useDesktopElectronBridges();
@@ -29,7 +29,6 @@ import {
FREE_VS_PREMIUM_DESCRIPTOR,
VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import MobileLayout from '@app/features/ui/state/MobileLayout';
@@ -51,7 +50,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const locale = LocaleUtils.getCurrentLocale();
const mobileLayoutState = MobileLayout;
const [isGiftMode, setIsGiftMode] = useState(defaultGiftMode);
const [pricingMode, setPricingMode] = useState<PricingMode>('localized');
const giftSectionRef = useRef<HTMLDivElement | null>(null);
const perksSectionRef = useRef<HTMLDivElement | null>(null);
const countryCode = GeoIP.countryCode;
@@ -66,14 +64,11 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
yearlyPrice,
giftMonthlyPrice,
giftYearlyPrice,
hasPricingChoice,
localizedCurrency,
baseCurrency,
currentSubscriptionPrice,
currentSubscriptionPriceLabel,
currentSubscriptionListPriceLabel,
isCurrentSubscriptionGrandfathered,
} = usePremiumData(countryCode, pricingMode, {premiumState});
} = usePremiumData({premiumState});
const {
loadingPortal,
loadingCancel,
@@ -92,15 +87,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const {loadingCheckout, handleSelectPlan} = useCheckoutActions(
priceIds,
countryCode,
pricingMode,
subscriptionStatus.isGiftSubscription,
mobileLayoutState.enabled,
);
useEffect(() => {
if (!hasPricingChoice && pricingMode === 'base') {
setPricingMode('localized');
}
}, [hasPricingChoice, pricingMode]);
useEffect(() => {
if (!currentUser?.id) return;
void PremiumCommands.refreshPremiumState(countryCode ?? undefined);
@@ -175,12 +164,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
<PlutoniumUpsellBanner data-flx="app.plutonium-content.plutonium-upsell-banner" />
<GiftSection
giftSectionRef={giftSectionRef}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
@@ -289,12 +272,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
<PricingSection
isGiftMode={isGiftMode}
setIsGiftMode={setIsGiftMode}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
@@ -308,12 +285,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
) : (
<GiftSection
giftSectionRef={giftSectionRef}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
@@ -340,12 +311,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
{!subscriptionStatus.isPremium && (
<BottomCTASection
isGiftMode={isGiftMode}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
@@ -2,10 +2,8 @@
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
import styles from '@app/features/app/components/dialogs/components/plutonium/BottomCTASection.module.css';
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
import {PREMIUM_PRODUCT_FULL_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import {Button} from '@app/features/ui/button/Button';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
@@ -20,12 +18,6 @@ const CLAIM_YOUR_ACCOUNT_TO_PURCHASE_DESCRIPTOR = msg({
interface BottomCTASectionProps {
isGiftMode: boolean;
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (value: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
monthlyPrice: string;
yearlyPrice: string;
giftMonthlyPrice: string;
@@ -39,12 +31,6 @@ interface BottomCTASectionProps {
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
({
isGiftMode,
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
monthlyPrice,
yearlyPrice,
giftMonthlyPrice,
@@ -63,17 +49,6 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
<h2 className={styles.title} data-flx="app.plutonium.bottom-cta-section.title">
{isGiftMode ? <Trans>Ready to buy a gift?</Trans> : <Trans>Ready to upgrade?</Trans>}
</h2>
<PricingContextPanel
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
isGiftMode={isGiftMode}
compact
data-flx="app.plutonium.bottom-cta-section.pricing-context-panel"
/>
<div className={styles.buttonContainer} data-flx="app.plutonium.bottom-cta-section.button-container">
{!isGiftMode ? (
<>
@@ -5,7 +5,6 @@ import gridStyles from '@app/features/app/components/dialogs/components/PricingG
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
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 {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
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 {
@@ -16,7 +15,6 @@ import {
SHARE_PREMIUM_EXPERIENCE_DESCRIPTOR,
VIEW_PREMIUM_PERKS_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowDownIcon} from '@phosphor-icons/react';
@@ -38,12 +36,6 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
interface GiftSectionProps {
giftSectionRef: React.RefObject<HTMLDivElement | null>;
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (value: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
giftMonthlyPrice: string;
giftYearlyPrice: string;
loadingCheckout: boolean;
@@ -55,12 +47,6 @@ interface GiftSectionProps {
export const GiftSection: React.FC<GiftSectionProps> = observer(
({
giftSectionRef,
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
giftMonthlyPrice,
giftYearlyPrice,
loadingCheckout,
@@ -82,16 +68,6 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
})}
data-flx="app.plutonium.gift-section.section-header"
/>
<PricingContextPanel
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
isGiftMode
data-flx="app.plutonium.gift-section.pricing-context-panel"
/>
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.gift-section.div--2">
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.gift-section.div--3">
<PurchaseDisabledWrapper
@@ -1,53 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toggleContainer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.panel {
border: 0.0625rem solid var(--border-color);
border-radius: 0.75rem;
background-color: var(--background-tertiary);
padding: 0.875rem 1rem;
}
.panelCompact {
padding: 0.75rem 0.875rem;
}
.title {
margin: 0 0 0.5rem;
font-weight: 700;
font-size: 0.95rem;
line-height: 1.35rem;
color: var(--text-primary);
}
.list {
margin: 0;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
color: var(--text-primary-muted);
font-size: 0.875rem;
line-height: 1.35rem;
}
.summary {
margin: 0;
color: var(--text-primary-muted);
font-size: 0.875rem;
line-height: 1.35rem;
text-align: center;
}
@@ -1,490 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel.module.css';
import {ToggleButton} from '@app/features/app/components/dialogs/components/ToggleButton';
import {
BLIK_PAYMENT_METHOD,
MB_WAY_PAYMENT_METHOD,
PAYMENT_PROVIDER_NAME,
PIX_PAYMENT_METHOD,
UPI_PAYMENT_METHOD,
} from '@app/features/app/config/I18nDisplayConstants';
import {getCurrencyCodeLabel, type PricingMode} from '@app/features/premium/utils/PricingUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useMemo} from 'react';
const BRL_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized BRL gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Brazil who selected localized BRL pricing.',
});
const BRL_GIFT_PIX_DETAIL_DESCRIPTOR = msg({
message:
'{pixPaymentMethod} can be used for one-time BRL payments when {paymentProviderName} offers it on the checkout page.',
comment:
'Plutonium gift checkout guidance for Brazil. Explains that Pix is available for one-time gift purchases via the payment provider.',
});
const BRL_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message: 'If you pay with a card in BRL, it still needs to be issued in Brazil for the purchase to stay eligible.',
comment:
'Plutonium gift checkout guidance for Brazil. Clarifies that BRL card payments require a Brazil-issued card.',
});
const INR_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized INR gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in India who selected localized INR pricing.',
});
const INR_GIFT_UPI_DETAIL_DESCRIPTOR = msg({
message:
'{upiPaymentMethod} can be used for one-time INR payments when {paymentProviderName} offers it on the checkout page.',
comment:
'Plutonium gift checkout guidance for India. Explains that UPI is available for one-time gift purchases via the payment provider.',
});
const INR_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message: 'If you pay with a card in INR, it still needs to be issued in India for the purchase to stay eligible.',
comment: 'Plutonium gift checkout guidance for India. Clarifies that INR card payments require an India-issued card.',
});
const PLN_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized PLN gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Poland who selected localized PLN pricing.',
});
const PLN_GIFT_BLIK_DETAIL_DESCRIPTOR = msg({
message:
'{blikPaymentMethod} can be used for one-time PLN gift payments, even though it does not support subscriptions in {paymentProviderName} checkout.',
comment:
'Plutonium gift checkout guidance for Poland. Explains that BLIK works for gifts but not for recurring subscriptions.',
});
const PLN_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message: 'If you pay with a card in PLN, it still needs to be issued in Poland for the purchase to stay eligible.',
comment:
'Plutonium gift checkout guidance for Poland. Clarifies that PLN card payments require a Poland-issued card.',
});
const TRY_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized TRY gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Türkiye who selected localized TRY pricing.',
});
const TRY_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message:
'TRY gifts are one-time payments. Cards charged in TRY still need to be issued in Türkiye for the purchase to stay eligible.',
comment:
'Plutonium gift checkout guidance for Türkiye. Clarifies one-time-only nature and Türkiye card issuance requirement.',
});
const TRY_GIFT_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR = msg({
message: 'There is no app-based local payment method surfaced here today, so card checkout is the main path.',
comment:
'Plutonium gift checkout guidance for Türkiye. Notes that no local wallet method is currently available; card is the main option.',
});
const EUR_GIFT_TITLE_DESCRIPTOR = msg({
message: 'EUR gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Portugal paying with EUR.',
});
const EUR_GIFT_MBWAY_DETAIL_DESCRIPTOR = msg({
message:
'{mbWayPaymentMethod} can be used for one-time EUR gift payments when {paymentProviderName} offers it on the checkout page.',
comment:
'Plutonium gift checkout guidance for Portugal. Explains that MB WAY can be used for one-time EUR gift purchases.',
});
const STANDARD_GIFT_TITLE_DESCRIPTOR = msg({
message: 'Standard {baseCurrencyLabel} gift checkout',
comment:
'Plutonium gift checkout panel title when the buyer opted into the standard (non-localized) currency. The currency code is interpolated.',
});
const STANDARD_GIFT_SWITCH_BACK_DETAIL_DESCRIPTOR = msg({
message: 'Switch back to {localizedCurrencyLabel} if you prefer the localized price instead.',
comment:
'Plutonium gift checkout guidance. Reminds the buyer they can revert to localized pricing. The currency code is interpolated.',
});
const EUR_SUBSCRIPTION_TITLE_DESCRIPTOR = msg({
message: 'EUR subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Portugal paying with EUR.',
});
const EUR_SUBSCRIPTION_MBWAY_ONE_TIME_DETAIL_DESCRIPTOR = msg({
message:
'{mbWayPaymentMethod} is a one-time payment method in {paymentProviderName} checkout, so it is not a recurring subscription path here.',
comment:
'Plutonium subscription checkout guidance for Portugal. Explains that MB WAY cannot be used for recurring subscriptions.',
});
const EUR_SUBSCRIPTION_USE_CARD_OR_GIFT_DETAIL_DESCRIPTOR = msg({
message: 'Use a card for the subscription, or buy a gift instead if you want a one-time EUR payment method.',
comment:
'Plutonium subscription checkout guidance for Portugal. Suggests using a card for recurring billing or a gift for one-time EUR payments.',
});
const STANDARD_PRICING_TITLE_DESCRIPTOR = msg({
message: 'Standard {baseCurrencyLabel} pricing',
comment:
'Plutonium subscription checkout panel title when the subscriber opted into the standard (non-localized) currency. The currency code is interpolated.',
});
const STANDARD_PRICING_FULL_PRICE_DETAIL_DESCRIPTOR = msg({
message: 'You are using the full standard {baseCurrencyLabel} price instead of localized pricing.',
comment:
'Plutonium subscription checkout guidance. States that the standard (non-localized) price applies. The currency code is interpolated.',
});
const STANDARD_PRICING_SKIPS_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'This skips localized card verification. Switch back to {localizedCurrencyLabel} if you prefer the local price.',
comment:
'Plutonium subscription checkout guidance. Notes that the standard path skips localized verification and offers a way back.',
});
const BRL_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized BRL subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Brazil with localized BRL pricing.',
});
const BRL_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep BRL pricing after a quick {paymentProviderName} verification, but the card must be issued in Brazil.',
comment:
'Plutonium subscription checkout guidance for Brazil. Explains that BRL card subscriptions need a verification step and a Brazil-issued card.',
});
const BRL_SUBSCRIPTION_PIX_RECURRING_DETAIL_DESCRIPTOR = msg({
message:
'{pixPaymentMethod} can skip the card verification step when {paymentProviderName} offers recurring {pixPaymentMethod2} for your checkout.',
comment: 'Plutonium subscription checkout guidance for Brazil. Explains that recurring Pix avoids card verification.',
});
const BRL_SUBSCRIPTION_PIX_FALLBACK_DETAIL_DESCRIPTOR = msg({
message: 'If {pixPaymentMethod} is unavailable or you prefer standard pricing, you can switch to USD at any time.',
comment:
'Plutonium subscription checkout guidance for Brazil. Offers USD as a fallback when Pix is not available or standard pricing is preferred.',
});
const INR_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized INR subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in India with localized INR pricing.',
});
const INR_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep INR pricing after a quick {paymentProviderName} verification, but the card must be issued in India.',
comment:
'Plutonium subscription checkout guidance for India. Explains that INR card subscriptions need a verification step and an India-issued card.',
});
const INR_SUBSCRIPTION_UPI_RECURRING_DETAIL_DESCRIPTOR = msg({
message:
'{upiPaymentMethod} can skip the card verification step when {paymentProviderName} offers recurring {upiPaymentMethod2} for your checkout.',
comment: 'Plutonium subscription checkout guidance for India. Explains that recurring UPI avoids card verification.',
});
const INR_SUBSCRIPTION_UPI_FALLBACK_DETAIL_DESCRIPTOR = msg({
message:
'If recurring {upiPaymentMethod} is unavailable or you prefer standard pricing, you can switch to USD at any time.',
comment:
'Plutonium subscription checkout guidance for India. Offers USD as a fallback when recurring UPI is not available.',
});
const PLN_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized PLN subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Poland with localized PLN pricing.',
});
const PLN_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep PLN pricing after a quick {paymentProviderName} verification, but the card must be issued in Poland.',
comment:
'Plutonium subscription checkout guidance for Poland. Explains that PLN card subscriptions need a verification step and a Poland-issued card.',
});
const PLN_SUBSCRIPTION_BLIK_UNSUPPORTED_DETAIL_DESCRIPTOR = msg({
message: '{blikPaymentMethod} does not support subscriptions in {paymentProviderName} checkout.',
comment:
'Plutonium subscription checkout guidance for Poland. Clarifies that BLIK is not available for recurring subscriptions.',
});
const PLN_SUBSCRIPTION_BLIK_GIFT_FALLBACK_DETAIL_DESCRIPTOR = msg({
message: 'If you want to use {blikPaymentMethod}, buy a PLN gift instead, or switch to standard EUR pricing.',
comment:
'Plutonium subscription checkout guidance for Poland. Suggests a PLN gift or standard EUR if the subscriber wants BLIK.',
});
const TRY_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized TRY subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Türkiye with localized TRY pricing.',
});
const TRY_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep TRY pricing after a quick {paymentProviderName} verification, but the card must be issued in Türkiye.',
comment:
'Plutonium subscription checkout guidance for Türkiye. Explains that TRY card subscriptions need a verification step and a Türkiye-issued card.',
});
const TRY_SUBSCRIPTION_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR = msg({
message:
'There is no app-based local subscription method surfaced here today, so local card checkout is the main path.',
comment:
'Plutonium subscription checkout guidance for Türkiye. Notes that no local wallet method is available; card is the main option.',
});
const SWITCH_TO_STANDARD_USD_DETAIL_DESCRIPTOR = msg({
message: 'If you prefer, you can switch to standard USD pricing instead.',
comment: 'Plutonium subscription checkout guidance. Offers USD as a fallback option.',
});
const USING_LOCALIZED_PRICING_SUMMARY_DESCRIPTOR = msg({
message: 'Using localized {localizedCurrencyLabel} pricing.',
comment:
'Compact summary line shown in Plutonium checkout when localized pricing is active. The currency code is interpolated.',
});
const USING_STANDARD_PRICING_SUMMARY_DESCRIPTOR = msg({
message: 'Using standard {baseCurrencyLabel} pricing.',
comment:
'Compact summary line shown in Plutonium checkout when standard pricing is active. The currency code is interpolated.',
});
const PRICING_PREFERENCE_GROUP_LABEL_DESCRIPTOR = msg({
message: 'Pricing preference',
comment:
'Accessible group label for the pricing toggle (localized vs standard) in Plutonium checkout. Not visible on screen.',
});
interface PricingContextPanelProps {
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (mode: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
isGiftMode: boolean;
compact?: boolean;
}
function getCountryContext(countryCode: string | null): 'BR' | 'IN' | 'PL' | 'PT' | 'TR' | 'OTHER' {
const upperCountryCode = countryCode?.toUpperCase();
switch (upperCountryCode) {
case 'BR':
case 'IN':
case 'PL':
case 'PT':
case 'TR':
return upperCountryCode;
default:
return 'OTHER';
}
}
export const PricingContextPanel: React.FC<PricingContextPanelProps> = observer(
({
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
isGiftMode,
compact = false,
}) => {
const {i18n} = useLingui();
const countryContext = getCountryContext(countryCode);
const localizedCurrencyLabel = getCurrencyCodeLabel(localizedCurrency);
const baseCurrencyLabel = getCurrencyCodeLabel(baseCurrency);
const guidance = useMemo(() => {
if (isGiftMode) {
if (pricingMode === 'localized') {
switch (countryContext) {
case 'BR':
return {
title: i18n._(BRL_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(BRL_GIFT_PIX_DETAIL_DESCRIPTOR, {
pixPaymentMethod: PIX_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(BRL_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
],
};
case 'IN':
return {
title: i18n._(INR_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(INR_GIFT_UPI_DETAIL_DESCRIPTOR, {
upiPaymentMethod: UPI_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(INR_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
],
};
case 'PL':
return {
title: i18n._(PLN_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(PLN_GIFT_BLIK_DETAIL_DESCRIPTOR, {
blikPaymentMethod: BLIK_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(PLN_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
],
};
case 'TR':
return {
title: i18n._(TRY_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(TRY_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
i18n._(TRY_GIFT_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR),
],
};
}
}
if (countryContext === 'PT' && baseCurrency === 'EUR') {
return {
title: i18n._(EUR_GIFT_TITLE_DESCRIPTOR),
items: [
i18n._(EUR_GIFT_MBWAY_DETAIL_DESCRIPTOR, {
mbWayPaymentMethod: MB_WAY_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
],
};
}
if (hasPricingChoice && pricingMode === 'base') {
return {
title: i18n._(STANDARD_GIFT_TITLE_DESCRIPTOR, {baseCurrencyLabel}),
items: [i18n._(STANDARD_GIFT_SWITCH_BACK_DETAIL_DESCRIPTOR, {localizedCurrencyLabel})],
};
}
return null;
}
if (countryContext === 'PT' && baseCurrency === 'EUR') {
return {
title: i18n._(EUR_SUBSCRIPTION_TITLE_DESCRIPTOR),
items: [
i18n._(EUR_SUBSCRIPTION_MBWAY_ONE_TIME_DETAIL_DESCRIPTOR, {
mbWayPaymentMethod: MB_WAY_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(EUR_SUBSCRIPTION_USE_CARD_OR_GIFT_DETAIL_DESCRIPTOR),
],
};
}
if (pricingMode === 'base') {
if (hasPricingChoice) {
return {
title: i18n._(STANDARD_PRICING_TITLE_DESCRIPTOR, {baseCurrencyLabel}),
items: [
i18n._(STANDARD_PRICING_FULL_PRICE_DETAIL_DESCRIPTOR, {baseCurrencyLabel}),
i18n._(STANDARD_PRICING_SKIPS_VERIFICATION_DETAIL_DESCRIPTOR, {localizedCurrencyLabel}),
],
};
}
return null;
}
switch (countryContext) {
case 'BR':
return {
title: i18n._(BRL_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(BRL_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(BRL_SUBSCRIPTION_PIX_RECURRING_DETAIL_DESCRIPTOR, {
pixPaymentMethod: PIX_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
pixPaymentMethod2: PIX_PAYMENT_METHOD,
}),
i18n._(BRL_SUBSCRIPTION_PIX_FALLBACK_DETAIL_DESCRIPTOR, {
pixPaymentMethod: PIX_PAYMENT_METHOD,
}),
],
};
case 'IN':
return {
title: i18n._(INR_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(INR_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(INR_SUBSCRIPTION_UPI_RECURRING_DETAIL_DESCRIPTOR, {
upiPaymentMethod: UPI_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
upiPaymentMethod2: UPI_PAYMENT_METHOD,
}),
i18n._(INR_SUBSCRIPTION_UPI_FALLBACK_DETAIL_DESCRIPTOR, {
upiPaymentMethod: UPI_PAYMENT_METHOD,
}),
],
};
case 'PL':
return {
title: i18n._(PLN_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(PLN_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(PLN_SUBSCRIPTION_BLIK_UNSUPPORTED_DETAIL_DESCRIPTOR, {
blikPaymentMethod: BLIK_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(PLN_SUBSCRIPTION_BLIK_GIFT_FALLBACK_DETAIL_DESCRIPTOR, {blikPaymentMethod: BLIK_PAYMENT_METHOD}),
],
};
case 'TR':
return {
title: i18n._(TRY_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(TRY_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(TRY_SUBSCRIPTION_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR),
i18n._(SWITCH_TO_STANDARD_USD_DETAIL_DESCRIPTOR),
],
};
default:
return null;
}
}, [
baseCurrency,
baseCurrencyLabel,
countryContext,
hasPricingChoice,
isGiftMode,
localizedCurrencyLabel,
pricingMode,
i18n.locale,
]);
const summary = useMemo(() => {
if (pricingMode === 'localized') {
if (hasPricingChoice) {
return i18n._(USING_LOCALIZED_PRICING_SUMMARY_DESCRIPTOR, {localizedCurrencyLabel});
}
return null;
}
if (hasPricingChoice) {
return i18n._(USING_STANDARD_PRICING_SUMMARY_DESCRIPTOR, {baseCurrencyLabel});
}
return null;
}, [baseCurrencyLabel, hasPricingChoice, localizedCurrencyLabel, pricingMode, i18n.locale]);
return (
<div className={styles.container} data-flx="app.plutonium.pricing-context-panel.container">
{hasPricingChoice && (
<div
className={styles.toggleContainer}
role="group"
aria-label={i18n._(PRICING_PREFERENCE_GROUP_LABEL_DESCRIPTOR)}
data-flx="app.plutonium.pricing-context-panel.toggle-container"
>
<ToggleButton
active={pricingMode === 'localized'}
onClick={() => setPricingMode('localized')}
label={<Trans>Local {localizedCurrencyLabel}</Trans>}
data-flx="app.plutonium.pricing-context-panel.toggle-button.set-pricing-mode"
/>
<ToggleButton
active={pricingMode === 'base'}
onClick={() => setPricingMode('base')}
label={<Trans>Standard {baseCurrencyLabel}</Trans>}
data-flx="app.plutonium.pricing-context-panel.toggle-button.set-pricing-mode--2"
/>
</div>
)}
{summary && compact && (
<p className={styles.summary} data-flx="app.plutonium.pricing-context-panel.summary">
{summary}
</p>
)}
{guidance && !compact && (
<div
className={clsx(styles.panel, compact && styles.panelCompact)}
data-flx="app.plutonium.pricing-context-panel.panel"
>
<h3 className={styles.title} data-flx="app.plutonium.pricing-context-panel.title">
{guidance.title}
</h3>
<ul className={styles.list} data-flx="app.plutonium.pricing-context-panel.list">
{guidance.items.map((item) => (
<li key={item} data-flx="app.plutonium.pricing-context-panel.li">
{item}
</li>
))}
</ul>
</div>
)}
</div>
);
},
);
@@ -7,7 +7,6 @@
}
.toggleContainer {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
@@ -3,7 +3,6 @@
import {PricingCard} from '@app/features/app/components/dialogs/components/PricingCard';
import gridStyles from '@app/features/app/components/dialogs/components/PricingGrid.module.css';
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
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';
@@ -14,7 +13,6 @@ import {
ONE_TIME_PURCHASE_DESCRIPTOR,
VIEW_PREMIUM_PERKS_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowDownIcon} from '@phosphor-icons/react';
@@ -73,12 +71,6 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
interface PricingSectionProps {
isGiftMode: boolean;
setIsGiftMode: (value: boolean) => void;
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (value: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
monthlyPrice: string;
yearlyPrice: string;
giftMonthlyPrice: string;
@@ -93,12 +85,6 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
({
isGiftMode,
setIsGiftMode,
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
monthlyPrice,
yearlyPrice,
giftMonthlyPrice,
@@ -133,16 +119,6 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
/>
</div>
<PricingContextPanel
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
isGiftMode={isGiftMode}
data-flx="app.plutonium.pricing-section.pricing-context-panel"
/>
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.pricing-section.div">
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.pricing-section.div--2">
{!isGiftMode ? (
@@ -49,12 +49,6 @@
color: var(--text-primary);
}
.description {
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
@media (min-width: 768px) {
.description {
font-size: 0.875rem;
@@ -19,7 +19,7 @@ interface PurchaseHistorySectionProps {
export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = observer(
({premiumState, loadingPortal, handleOpenCustomerPortal}) => {
const invoices = premiumState?.billing.invoices ?? [];
const [invoicesExpanded, setInvoicesExpanded] = useState(invoices.length > 0);
const [invoicesExpanded, setInvoicesExpanded] = useState(false);
return (
<section className={styles.section} data-flx="app.plutonium.purchase-history-section.section">
<div className={styles.card} data-flx="app.plutonium.purchase-history-section.card">
@@ -28,12 +28,6 @@ export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = obs
<h3 className={styles.title} data-flx="app.plutonium.purchase-history-section.title">
<Trans>Purchase history</Trans>
</h3>
<p className={styles.description} data-flx="app.plutonium.purchase-history-section.description">
<Trans>
Your recent invoices. To change the payment method for your subscription, add or choose one in the
billing portal and make it the default.
</Trans>
</p>
</div>
<Button
variant="secondary"
@@ -18,7 +18,6 @@ import type {CheckoutPaymentMethod, PriceIds} from '@app/features/premium/comman
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
import {recordPremiumCheckoutReturnIntent} from '@app/features/premium/utils/PremiumCheckoutReturnIntent';
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
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';
@@ -246,7 +245,6 @@ function alternativePaymentMethodForCurrency(
export const useCheckoutActions = (
priceIds: PriceIds | null,
countryCode: string | null,
pricingMode: PricingMode,
isGiftSubscription: boolean,
mobileEnabled: boolean,
) => {
@@ -552,7 +550,6 @@ export const useCheckoutActions = (
priceId,
countryCode ?? undefined,
isGift,
pricingMode,
paymentMethod,
);
await openCheckoutUrl(checkoutUrl, {promptKind: 'payment', skipMobilePrompt});
@@ -572,11 +569,7 @@ export const useCheckoutActions = (
}
setLoadingCheckout(true);
try {
const checkoutUrl = await PremiumCommands.createLocalizedCardPreapprovalSession(
priceId,
countryCode,
pricingMode,
);
const checkoutUrl = await PremiumCommands.createLocalizedCardPreapprovalSession(priceId, countryCode);
await openCheckoutUrl(checkoutUrl, {
promptKind: 'localized_card_preapproval',
skipMobilePrompt,
@@ -641,7 +634,6 @@ export const useCheckoutActions = (
getAlternativePaymentMethodPrompt,
isGiftSubscription,
mobileEnabled,
pricingMode,
i18n,
],
);
@@ -2,7 +2,7 @@
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {CurrentSubscriptionPrice, PriceIds} from '@app/features/premium/commands/PremiumCommands';
import {formatMinorUnitPrice, type PricingMode} from '@app/features/premium/utils/PricingUtils';
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import {useMemo} from 'react';
@@ -35,35 +35,19 @@ export interface PremiumData {
yearlyPrice: string;
giftMonthlyPrice: string;
giftYearlyPrice: string;
localizedPriceIds: PriceIds | null;
basePriceIds: PriceIds | null;
hasPricingChoice: boolean;
selectedPricingMode: PricingMode;
localizedCurrency: string | null;
baseCurrency: string | null;
currentSubscriptionPrice: CurrentSubscriptionPrice | null;
currentSubscriptionPriceLabel: string | null;
currentSubscriptionListPriceLabel: string | null;
isCurrentSubscriptionGrandfathered: boolean;
}
export const usePremiumData = (
_countryCode: string | null,
pricingMode: PricingMode = 'localized',
{
premiumState = null,
}: {
premiumState?: PremiumStateResponse | null;
} = {},
): PremiumData => {
export const usePremiumData = ({
premiumState = null,
}: {
premiumState?: PremiumStateResponse | null;
} = {}): PremiumData => {
const locale = LocaleUtils.getCurrentLocale();
const localizedPriceIds = premiumState?.pricing.localized ?? null;
const basePriceIds = premiumState?.pricing.base ?? null;
const hasPricingChoice =
typeof localizedPriceIds?.currency === 'string' &&
typeof basePriceIds?.currency === 'string' &&
localizedPriceIds.currency !== basePriceIds.currency;
const priceIds = pricingMode === 'base' && hasPricingChoice ? basePriceIds : localizedPriceIds;
const priceIds = premiumState?.pricing.localized ?? null;
const monthlyPrice = useMemo(() => {
return formatPriceLabel(priceIds?.monthly_amount_minor, priceIds?.currency, locale);
}, [locale, priceIds?.currency, priceIds?.monthly_amount_minor]);
@@ -97,12 +81,6 @@ export const usePremiumData = (
yearlyPrice,
giftMonthlyPrice,
giftYearlyPrice,
localizedPriceIds,
basePriceIds,
hasPricingChoice,
selectedPricingMode: pricingMode,
localizedCurrency: localizedPriceIds?.currency ?? null,
baseCurrency: hasPricingChoice ? (basePriceIds?.currency ?? null) : null,
currentSubscriptionPrice,
currentSubscriptionPriceLabel,
currentSubscriptionListPriceLabel,
@@ -8,6 +8,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
import AccountManager from '@app/features/auth/state/AccountManager';
import Authentication from '@app/features/auth/state/Authentication';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {MediaDeviceStartupPreloadManager} from '@app/features/voice/components/MediaDeviceStartupPreloadManager';
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
import {clsx} from 'clsx';
@@ -35,6 +36,9 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
return (
<>
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
{isAuthenticated && (
<MediaDeviceStartupPreloadManager data-flx="app.app-layout.media-device-startup-preload-manager" />
)}
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
<div
@@ -23,7 +23,6 @@ export const MACOS_SCREEN_RECORDING_PERMISSION_NAME = 'Screen Recording';
export const PIX_PAYMENT_METHOD = 'Pix';
export const UPI_PAYMENT_METHOD = 'UPI';
export const BLIK_PAYMENT_METHOD = 'BLIK';
export const MB_WAY_PAYMENT_METHOD = 'MB WAY';
export const SUPPORT_EMAIL = '[email protected]';
export const SUPPORT_EMAIL_MAILTO = `mailto:${SUPPORT_EMAIL}`;
export const I18N_EMAIL = '[email protected]';
+216 -15
View File
@@ -1,21 +1,39 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {UpdaterEvent} from '@app/types/electron.d';
import {afterEach, describe, expect, test, vi} from 'vitest';
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
interface ManualUpdatePrompt {
options: ReadonlyArray<UpdaterDownloadOption>;
onDownload: (option: UpdaterDownloadOption) => void | Promise<void>;
}
const {
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal,
} = vi.hoisted(() => ({
pushManualUpdateAvailableModal: vi.fn<(prompt: ManualUpdatePrompt) => void>(),
pushUnsupportedUpdateModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateReadyModal: vi.fn(),
pushUpToDateModal: vi.fn(),
}));
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
pushDesktopUpdateDownloadFailedModal: vi.fn(),
pushDesktopUpdateInstallFailedModal: vi.fn(),
pushManualUpdateAvailableModal: vi.fn(),
pushUnsupportedUpdateModal: vi.fn(),
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateAvailableModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal: vi.fn(),
pushUpToDateModal,
}));
vi.mock('@lingui/core/macro', () => ({
@@ -29,15 +47,30 @@ vi.mock('@app/features/platform/utils/ClientInfo', () => ({
installVoiceMenuTestBootstrap();
const REPORTED_VERSION = '2026.908.173325';
const NEWER_VERSION = '2026.909.202036';
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
const REPORTED_DEB_DOWNLOAD = [
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
`Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
];
const NEWER_DEB_DOWNLOAD = [`${CANARY_X64}/${NEWER_VERSION}/deb`, `Fluxer-Canary-${NEWER_VERSION}-linux-amd64.deb`];
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
let onUpdaterCheck: (() => void) | null = null;
let onUpdaterCheck: ((context: string) => void | Promise<void>) | null = null;
let loadedUpdater: {dispose: () => void} | null = null;
function installElectronApi(): void {
const updaterCheck = vi.fn((context: string) => Promise.resolve(onUpdaterCheck?.(context)));
const downloadFile = vi.fn<(url: string, suggestedName: string) => Promise<{success: boolean}>>(() =>
Promise.resolve({success: true}),
);
const openExternal = vi.fn<(url: string) => Promise<void>>(() => Promise.resolve());
function installElectronApi(platform: string): void {
nativeEventListener = null;
onUpdaterCheck = null;
(window as unknown as {electron: unknown}).electron = {
platform: 'win32',
platform,
buildChannel: 'canary',
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
nativeEventListener = listener;
@@ -45,12 +78,11 @@ function installElectronApi(): void {
nativeEventListener = null;
};
},
updaterCheck: () => {
onUpdaterCheck?.();
return Promise.resolve();
},
updaterCheck,
updaterDownload: () => Promise.resolve(),
updaterInstall: () => Promise.resolve(),
downloadFile,
openExternal,
};
}
@@ -59,16 +91,16 @@ function emit(event: UpdaterEvent): void {
nativeEventListener(event);
}
async function loadUpdater() {
async function loadUpdater(platform = 'win32') {
vi.resetModules();
installElectronApi();
installElectronApi(platform);
const {default: Updater} = await import('@app/features/app/state/Updater');
loadedUpdater = Updater;
await vi.waitFor(() => {
expect(nativeEventListener).not.toBeNull();
expect(Updater.lastCheckedAt).not.toBeNull();
});
pushUpdateReadyModal.mockClear();
vi.clearAllMocks();
return Updater;
}
@@ -77,7 +109,37 @@ function emitUserDownloadCompletion(version: string): void {
emit({type: 'downloaded', context: 'user', version});
}
function emitLinuxManualUpdate(context: 'user' | 'background', version: string): void {
emit({
type: 'available',
context,
version,
downloadStarted: false,
downloadUrl: `${CANARY_X64}/latest/appimage`,
downloadOptions: [
{
format: 'deb',
label: 'DEB package',
url: `${CANARY_X64}/latest/deb`,
suggestedName: `Fluxer-Canary-${version}-linux-amd64.deb`,
sha256: `deb-${version}`,
},
],
});
}
async function openLinuxManualUpdatePrompt() {
const Updater = await loadUpdater('linux');
emitLinuxManualUpdate('background', REPORTED_VERSION);
await Updater.applyUpdate();
const prompt = pushManualUpdateAvailableModal.mock.lastCall?.[0];
const debOption = prompt?.options.find((option) => option.format === 'deb');
if (!prompt || !debOption) throw new Error('Manual update prompt never offered a deb package');
return {Updater, download: () => Promise.resolve(prompt.onDownload(debOption))};
}
afterEach(() => {
vi.useRealTimers();
loadedUpdater?.dispose();
loadedUpdater = null;
});
@@ -110,3 +172,142 @@ describe('updater update-ready surface', () => {
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
});
});
describe('updater manual Linux package download', () => {
test('refreshes the check as the user and saves a newer build under its own name', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => {
emit({type: 'checking', context: 'user'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(Updater.displayVersion).toBe(NEWER_VERSION);
expect(Updater.isChecking).toBe(false);
});
test('saves the build the prompt named when the refresh fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh finds no update', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'not-available', context: 'user'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpToDateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh reports an unsupported install', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'unsupported', context: 'user', reason: 'managed-package'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUnsupportedUpdateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh does not answer in time', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(Updater.isChecking).toBe(false);
});
test.each([
['a newer build', () => emitLinuxManualUpdate('user', NEWER_VERSION)],
['no update', () => emit({type: 'not-available', context: 'user'})],
['a failure', () => emit({type: 'error', context: 'user', message: 'fetch failed'})],
])('stays quiet when a timed out refresh answers with %s after the download', async (_answer, answerLate) => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
answerLate();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpToDateModal).not.toHaveBeenCalled();
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the refreshed build when a late failure from an earlier refresh lands inside the next one', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const firstDownload = download();
await vi.advanceTimersByTimeAsync(5_000);
await firstDownload;
onUpdaterCheck = () => {
emit({type: 'error', context: 'user', message: 'fetch failed'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD, NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('still shows the failure of a user check made while a package is being saved', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockImplementationOnce(() => new Promise<{success: boolean}>(() => {}));
void download();
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1));
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await Updater.checkForUpdates(true, true);
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(pushUpdateCheckFailedModal).toHaveBeenCalledTimes(1);
});
test('does not refresh when a check is already running at the click', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
void Updater.checkForUpdates(true);
await download();
expect(updaterCheck).not.toHaveBeenCalled();
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
});
test('saves one package when the button is pressed twice during the refresh', async () => {
const {download} = await openLinuxManualUpdatePrompt();
let answerRefresh = () => {};
onUpdaterCheck = () =>
new Promise<void>((resolve) => {
answerRefresh = () => {
emitLinuxManualUpdate('user', NEWER_VERSION);
resolve();
};
});
const firstClick = download();
const secondClick = download();
answerRefresh();
await Promise.all([firstClick, secondClick]);
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
});
test('opens the pinned link and never the download page when saving fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockResolvedValueOnce({success: false});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(openExternal.mock.calls).toEqual([[`${CANARY_X64}/${REPORTED_VERSION}/deb`]]);
});
});
+50 -2
View File
@@ -51,6 +51,7 @@ export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
const logger = new Logger('Updater');
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
const VERSION_ENDPOINT = '/version.json';
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
const ALLOWED_WEB_UPDATE_HOSTS = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
@@ -132,6 +133,7 @@ class Updater {
private backgroundCheckCleanups: Array<() => void> = [];
private unsubscribeNativeEvents: (() => void) | null = null;
private updateReadyNagbarDismissedVersion: string | null = null;
private pendingManualDownloadRefreshes = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -288,7 +290,17 @@ class Updater {
}
private handleNativeEvent(event: UpdaterEvent): void {
const isUserCheck = event.context === 'user';
const isManualDownloadRefreshResult =
event.context === 'user' &&
this.pendingManualDownloadRefreshes > 0 &&
(event.type === 'available' ||
event.type === 'not-available' ||
event.type === 'error' ||
event.type === 'unsupported');
if (isManualDownloadRefreshResult) {
this.pendingManualDownloadRefreshes -= 1;
}
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
@@ -643,7 +655,7 @@ class Updater {
currentVersion: this.currentVersion,
version: this.updateInfo.native.version,
options: this.nativeManualDownloadOptions,
onDownload: (option) => this.downloadManualNativeUpdateOrOpen(option.url, option.suggestedName),
onDownload: (option) => this.downloadManualNativeUpdateOption(option),
});
return;
}
@@ -651,6 +663,42 @@ class Updater {
pushUpdateAvailableModal(this.updateInfo.native.version, () => this.downloadManualNativeUpdateOrOpen(url));
}
private async refreshManualNativeDownloadOption(option: UpdaterDownloadOption): Promise<UpdaterDownloadOption> {
if (this.checkInProgress) {
return option;
}
this.transition({type: 'check.started'});
let timeoutId: number | undefined;
const timedOut = new Promise<boolean>((resolve) => {
timeoutId = window.setTimeout(() => resolve(false), MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS);
});
try {
this.pendingManualDownloadRefreshes += 1;
const checked = await Promise.race([this.checkNativeUpdate('user'), timedOut]);
if (!checked) {
return option;
}
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
} finally {
window.clearTimeout(timeoutId);
this.transition({type: 'check.finished', now: Date.now()});
}
}
private async downloadManualNativeUpdateOption(option: UpdaterDownloadOption): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
}
this.transition({type: 'manualDownload.started'});
let currentOption = option;
try {
currentOption = await this.refreshManualNativeDownloadOption(option);
} finally {
this.transition({type: 'manualDownload.finished'});
}
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
}
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
@@ -92,7 +92,11 @@ import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentPermissionUtils';
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
import {hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
import {
canSubmitComposerContent,
getComposerMessageContent,
hasVisibleMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {
resolveTypedEmojiShortcodes,
@@ -267,6 +271,7 @@ export const LexicalChannelTextareaContent = observer(
const referencedMessage = MessageReply.getReferencedMessage(channel.id);
const editingMessage = editingMobileMessageId ? Messages.getMessage(channel.id, editingMobileMessageId) : null;
const editingMessageForComposer = editingMessage === undefined ? null : editingMessage;
const isEditingMessageOnMobile = editingMessageForComposer !== null && mobileLayout.enabled;
const maxMessageLength = Limits.getMaxMessageLength();
const premiumMaxLength = Limits.getStockValue('max_message_length', maxMessageLength);
const maxAttachments = Limits.getMaxAttachmentsPerMessage();
@@ -496,6 +501,7 @@ export const LexicalChannelTextareaContent = observer(
isSlotMenu,
onCursorMove,
handleSelect,
specialMentionsAllowed,
} = useLexicalAutocomplete({
channel,
handleRef,
@@ -526,7 +532,11 @@ export const LexicalChannelTextareaContent = observer(
() => resolveTypedEmojiContent(wireValue.trim()),
[resolveTypedEmojiContent, wireValue],
);
const hasMessageContent = useMemo(() => hasVisibleMessageContent(trimmedMessageContent), [trimmedMessageContent]);
const composerMessageContent = useMemo(
() => getComposerMessageContent(trimmedMessageContent, isEditingMessageOnMobile),
[isEditingMessageOnMobile, trimmedMessageContent],
);
const hasMessageContent = useMemo(() => hasVisibleMessageContent(composerMessageContent), [composerMessageContent]);
const isSubmissionBlockedBySlowmode = useMemo(() => {
if (!isSlowmodeActive || isEditingMessageInComposer) {
return false;
@@ -708,12 +718,16 @@ export const LexicalChannelTextareaContent = observer(
}, [channel.id, hasAttachments, hasPendingSticker]);
const showAttachments = hasAttachments;
const showStickers = hasPendingSticker;
const isOverCharacterLimit = trimmedMessageContent.length > maxMessageLength;
const canSubmit =
!textareaInputDisabled &&
!isSubmissionBlockedBySlowmode &&
!isOverCharacterLimit &&
(hasMessageContent || hasAttachments || hasPendingSticker);
const isOverCharacterLimit = composerMessageContent.length > maxMessageLength;
const canSubmit = canSubmitComposerContent({
inputDisabled: textareaInputDisabled,
isSubmissionBlockedBySlowmode,
isOverCharacterLimit,
hasMessageContent,
hasAttachments,
hasPendingSticker,
isEditingMessageOnMobile,
});
const {onSubmit} = useTextareaSubmit({
channelId: channel.id,
guildId: channel.guildId === undefined ? null : channel.guildId,
@@ -1343,9 +1357,11 @@ export const LexicalChannelTextareaContent = observer(
initialSegments={initialDraftRef.current.segments}
slotResolvers={slotResolvers}
emojiShortcodeResolver={composerEmojiResolver}
specialMentionsAllowed={specialMentionsAllowed}
channelId={channel.id}
guildId={channel.guildId}
submitOnEnter={!mobileLayout.enabled}
silentMessagePrefix={!isEditingMessageOnMobile}
focusRingTarget={containerRef}
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
className={lexicalStyles.composerEditable}
@@ -1406,7 +1422,7 @@ export const LexicalChannelTextareaContent = observer(
styles.inputSection,
)}
<MessageCharacterCounter
currentLength={trimmedMessageContent.length}
currentLength={composerMessageContent.length}
maxLength={maxMessageLength}
canUpgrade={maxMessageLength < premiumMaxLength}
premiumMaxLength={premiumMaxLength}
@@ -19,6 +19,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
import {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils';
import Guilds from '@app/features/guild/state/Guilds';
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import GuildMembers from '@app/features/member/state/GuildMembers';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
@@ -29,6 +30,7 @@ import {hasStyleableMessageText} from '@app/features/messaging/utils/FailedMessa
import {
buildExistingAttachmentEditReferences,
canSubmitEmptyMessageEdit,
isAttachmentOnlyMessage,
} from '@app/features/messaging/utils/MessageEditContentUtils';
import {retryFailedMessage} from '@app/features/messaging/utils/MessageRetryUtils';
import {NodeType} from '@app/features/messaging/utils/markdown/parser/Enums';
@@ -193,13 +195,13 @@ export const UserMessage = observer(() => {
if (message.messageSnapshots) {
return;
}
const content = (actualContent ?? '').trim();
const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim();
if (!content) {
if (isAttachmentOnlyMessage(message)) {
finishEditing();
return;
}
if (canSubmitEmptyMessageEdit(message)) {
if (message.content.length === 0) {
finishEditing();
return;
}
finishEditing();
void MessageCommands.edit(
channel.id,
@@ -5,6 +5,7 @@ import {YOUTUBE_PROVIDER_NAME} from '@app/features/app/config/I18nDisplayConstan
import styles from '@app/features/channel/components/embeds/media/EmbedYouTube.module.css';
import {OverlayActionButton, OverlayPlayButton} from '@app/features/channel/components/embeds/media/MediaButtons';
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
import ActiveIframeEmbed from '@app/features/messaging/state/ActiveIframeEmbed';
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {
@@ -21,7 +22,7 @@ import {useLingui} from '@lingui/react/macro';
import {ArrowSquareOutIcon, PlayIcon} from '@phosphor-icons/react';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import {type FC, useCallback, useEffect, useMemo, useState} from 'react';
import {type FC, useCallback, useEffect, useId, useMemo, useState} from 'react';
const PLAY_VIDEO_DESCRIPTOR = msg({
message: 'Play video',
@@ -147,7 +148,8 @@ const Thumbnail: FC<ThumbnailProps> = observer(
);
export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOUTUBE_CONFIG.DEFAULT_WIDTH}) => {
const {i18n} = useLingui();
const [hasInteracted, setHasInteracted] = useState(false);
const embedId = useId();
const hasInteracted = ActiveIframeEmbed.isActive(embedId);
const posterSrc = embed.thumbnail?.proxy_url || '';
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({rememberKey: posterSrc});
const [posterCacheAtMount] = useState(() => ({src: posterSrc, cached: ImageCacheUtils.hasImage(posterSrc)}));
@@ -171,10 +173,14 @@ export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOU
);
return cleanup;
}, [isNearViewport, loadedPosterSrc, posterSrc]);
const handleInitialPlay = useCallback((event: React.MouseEvent | React.KeyboardEvent) => {
event.stopPropagation();
setHasInteracted(true);
}, []);
const handleInitialPlay = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
event.stopPropagation();
ActiveIframeEmbed.claim(embedId);
},
[embedId],
);
useEffect(() => () => ActiveIframeEmbed.release(embedId), [embedId]);
const handleOpenInNewTab = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
event.stopPropagation();
@@ -250,6 +256,7 @@ export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOU
sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
src={embedVideoUrl.toString()}
className={styles.iframe}
scrolling="no"
data-embed-media="true"
aria-label={embed.title || i18n._(VIDEO_DESCRIPTOR, {youtubeProviderName: YOUTUBE_PROVIDER_NAME})}
data-flx="channel.embeds.media.embed-you-tube.iframe"
@@ -16,8 +16,10 @@ import {MatureContentSection} from '@app/features/channel/components/modals/chan
import {RtcRegionSelect} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect';
import {SlowmodeControl} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl';
import {
BITRATE_KBPS_DEFAULT,
CHANNEL_OVERVIEW_TAB_ID,
type FormInputs,
getMaxBitrateKbps,
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
import {
VoiceConnectionLimitControl,
@@ -81,6 +83,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
guildId !== null ? Permission.can(Permissions.UPDATE_RTC_REGION, {guildId, channelId}) : false;
const canManageChannel = guildId !== null ? Permission.can(Permissions.MANAGE_CHANNELS, {guildId, channelId}) : false;
const isVoiceChannel = channel?.type === ChannelTypes.GUILD_VOICE;
const maxBitrateKbps = getMaxBitrateKbps(guild?.features);
const [rtcRegions, setRtcRegions] = useState<Array<ChannelRtcRegion>>([]);
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
const form = useForm<FormInputs>({
@@ -92,7 +95,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
nsfw_override: null,
content_warning_level: ContentWarningLevel.INHERIT,
content_warning_text: '',
bitrate: 64,
bitrate: BITRATE_KBPS_DEFAULT,
user_limit: 0,
voice_connection_limit: VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
rtc_region: null,
@@ -107,7 +110,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
nsfw_override: channel.nsfwOverride,
content_warning_level: channel.contentWarningLevel ?? ContentWarningLevel.INHERIT,
content_warning_text: channel.contentWarningText ?? '',
bitrate: channel.bitrate ? Math.round(channel.bitrate / 1000) : 64,
bitrate: Math.min(channel.bitrate ? Math.round(channel.bitrate / 1000) : BITRATE_KBPS_DEFAULT, maxBitrateKbps),
user_limit: channel.userLimit ?? 0,
voice_connection_limit: channel.voiceConnectionLimit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
rtc_region: channel.rtcRegion ?? null,
@@ -184,7 +187,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
updateData.rate_limit_per_user = data.slowmode;
}
if (channel.type === ChannelTypes.GUILD_VOICE) {
updateData.bitrate = (data.bitrate ?? 64) * 1000;
updateData.bitrate = Math.min(data.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps) * 1000;
updateData.user_limit = data.user_limit;
updateData.voice_connection_limit = data.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT;
} else if (channel.type === ChannelTypes.GUILD_LINK) {
@@ -216,7 +219,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
nsfw_override: data.nsfw_override,
content_warning_level: data.content_warning_level,
content_warning_text: data.content_warning_text ?? '',
bitrate: data.bitrate ?? currentValues.bitrate ?? 64,
bitrate: Math.min(data.bitrate ?? currentValues.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps),
user_limit: data.user_limit ?? currentValues.user_limit ?? 0,
voice_connection_limit:
data.voice_connection_limit ?? currentValues.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
@@ -224,7 +227,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
});
ToastCommands.createToast({type: 'success', children: <Trans>Channel updated</Trans>});
},
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues],
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues, maxBitrateKbps],
);
const {handleSubmit: handleSave} = useFormSubmit({
form,
@@ -309,7 +312,11 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
{showVoiceSection && (
<div className={styles.settingsGroup} data-flx="channel.channel-tabs.channel-overview-tab.settings-group--3">
{canManageChannel && (
<VoiceSettings form={form} data-flx="channel.channel-tabs.channel-overview-tab.voice-settings" />
<VoiceSettings
form={form}
maxBitrateKbps={maxBitrateKbps}
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings"
/>
)}
{canUpdateRtcRegion && (
<RtcRegionSelect
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
@@ -145,7 +146,7 @@ export const ChannelOverviewTopicEditor = observer(
});
}, [actualTopic, form, isTopicInitialized]);
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
setActualTopic(wire);
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
}, []);
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
const composer = composerRef.current;
@@ -3,10 +3,10 @@
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
import {SettingsControlRow} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SettingsControlRow';
import {
BITRATE_KBPS_MARKERS,
BITRATE_KBPS_MAX,
BITRATE_KBPS_DEFAULT,
BITRATE_KBPS_MIN,
type FormInputs,
getBitrateKbpsMarkers,
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
import {
@@ -50,6 +50,7 @@ const CONNECTION_LIMIT_VALUE_DESCRIPTOR = msg({
interface VoiceSettingsProps {
form: UseFormReturn<FormInputs>;
maxBitrateKbps: number;
}
const formatIntegerValue = (value: number): string => String(Math.round(value));
@@ -66,15 +67,14 @@ const formatConnectionLimitMarker = (value: number): string | null => {
const VoiceBitrateSlider: React.FC<{
value: number | undefined;
maxBitrateKbps: number;
onChange: (value: number) => void;
}> = ({value, onChange}) => {
}> = ({value, maxBitrateKbps, onChange}) => {
const {i18n} = useLingui();
const voiceQualityLabel = i18n._(VOICE_QUALITY_DESCRIPTOR);
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
let currentValue = value;
if (typeof currentValue !== 'number') {
currentValue = 64;
}
const factoryDefaultValue = Math.min(BITRATE_KBPS_DEFAULT, maxBitrateKbps);
const currentValue = Math.min(typeof value === 'number' ? value : BITRATE_KBPS_DEFAULT, maxBitrateKbps);
return (
<SettingsControlRow
label={voiceQualityLabel}
@@ -88,18 +88,18 @@ const VoiceBitrateSlider: React.FC<{
<Slider
value={currentValue}
defaultValue={currentValue}
factoryDefaultValue={64}
factoryDefaultValue={factoryDefaultValue}
minValue={BITRATE_KBPS_MIN}
maxValue={BITRATE_KBPS_MAX}
maxValue={maxBitrateKbps}
step={1}
markers={[...BITRATE_KBPS_MARKERS]}
markers={getBitrateKbpsMarkers(maxBitrateKbps)}
ariaLabel={voiceQualityLabel}
ariaValueText={i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(currentValue)})}
onMarkerRender={formatIntegerValue}
onValueRender={(kilobits) => i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(kilobits)})}
onValueChange={(kilobits) => onChange(Math.round(kilobits))}
showResetButton={true}
onReset={() => onChange(64)}
onReset={() => onChange(factoryDefaultValue)}
resetTooltip={resetSliderLabel}
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.slider"
/>
@@ -108,7 +108,7 @@ const VoiceBitrateSlider: React.FC<{
);
};
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form, maxBitrateKbps}) => {
const {i18n} = useLingui();
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
const participantLimitLabel = i18n._(PARTICIPANT_LIMIT_LABEL_DESCRIPTOR);
@@ -121,6 +121,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
render={({field}) => (
<VoiceBitrateSlider
value={field.value}
maxBitrateKbps={maxBitrateKbps}
onChange={field.onChange}
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.change"
/>
@@ -172,7 +173,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
);
};
export const VoiceConnectionLimitControl: React.FC<VoiceSettingsProps> = ({form}) => {
export const VoiceConnectionLimitControl: React.FC<{form: UseFormReturn<FormInputs>}> = ({form}) => {
const {i18n} = useLingui();
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
const connectionLimitLabel = i18n._(CONNECTION_LIMIT_LABEL_DESCRIPTOR);
@@ -2,7 +2,8 @@
import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {VOICE_CHANNEL_BITRATE_MAX, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
import {getMaxVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
import {VOICE_CHANNEL_BITRATE_DEFAULT, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
export interface FormInputs {
name: string;
@@ -20,8 +21,17 @@ export interface FormInputs {
export const CHANNEL_OVERVIEW_TAB_ID = 'overview';
export const BITRATE_KBPS_MIN = VOICE_CHANNEL_BITRATE_MIN / 1000;
export const BITRATE_KBPS_MAX = VOICE_CHANNEL_BITRATE_MAX / 1000;
export const BITRATE_KBPS_MARKERS: ReadonlyArray<number> = [BITRATE_KBPS_MIN, 64, 128, 256, BITRATE_KBPS_MAX];
export const BITRATE_KBPS_DEFAULT = VOICE_CHANNEL_BITRATE_DEFAULT / 1000;
export function getMaxBitrateKbps(guildFeatures: Iterable<string> | null | undefined): number {
return getMaxVoiceChannelBitrate(guildFeatures) / 1000;
}
export function getBitrateKbpsMarkers(maxKbps: number): Array<number> {
const markers = [BITRATE_KBPS_MIN, BITRATE_KBPS_DEFAULT, 128, 256].filter((marker) => marker < maxKbps);
markers.push(maxKbps);
return markers;
}
export const MAX_TOPIC_LENGTH = 1024;
export interface RtcRegionOption extends ComboboxOption<string | null> {
@@ -1,50 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.description {
color: var(--text-secondary);
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
}
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.listItem {
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.4;
padding-inline-start: 0.75rem;
position: relative;
}
.listItem::before {
content: '•';
position: absolute;
left: 0;
color: var(--text-chat-muted);
}
.listItem strong {
color: var(--text-primary);
font-weight: 600;
}
.hint {
color: var(--text-chat-muted);
font-size: 0.75rem;
margin: 0;
line-height: 1.4;
}
@@ -1,116 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import styles from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal.module.css';
import FavoriteGif from '@app/features/expressions/state/FavoriteGif';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {formatUserSettingsPath} from '@app/features/user/components/settings_utils/SettingsConstants';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useRef} from 'react';
const HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR = msg({
message: 'How should we save your GIF favorites?',
comment: 'Confirmation prompt in the channel and chat favorite gif first time prompt modal.',
});
interface FavoriteGifFirstTimePromptModalProps {
onConfirm: () => void;
}
export const FavoriteGifFirstTimePromptModal = observer(function FavoriteGifFirstTimePromptModal({
onConfirm,
}: FavoriteGifFirstTimePromptModalProps) {
const {i18n} = useLingui();
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const mediaSettingsPath = formatUserSettingsPath(i18n, 'chat_settings', 'media');
const handleConfirm = () => {
FavoriteGif.setSaveGifFavoritesAsSavedMedia(false);
FavoriteGif.markFirstTimePromptSeen();
ModalCommands.pop();
onConfirm();
};
const handleUseSavedMedia = () => {
FavoriteGif.setSaveGifFavoritesAsSavedMedia(true);
FavoriteGif.markFirstTimePromptSeen();
ModalCommands.pop();
onConfirm();
};
const handleCancel = () => {
ModalCommands.pop();
};
return (
<Modal.Root
size="small"
centered
initialFocusRef={initialFocusRef}
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-root"
>
<Modal.Header
title={i18n._(HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR)}
onClose={handleCancel}
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-header"
/>
<Modal.Content data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-content">
<div className={styles.body} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.body">
<p
className={styles.description}
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.description"
>
<Trans>
You can store starred GIFs as URL-only favorites or upload them to your saved media. Pick the one that
fits how you use them. You can change it any time in {mediaSettingsPath}.
</Trans>
</p>
<ul className={styles.list} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list">
<li
className={styles.listItem}
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item"
>
<Trans>
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong">
URL-only favorites (default)
</strong>
: synced across your devices, no upload, doesn't count against saved media. The original media may
disappear if its host removes it.
</Trans>
</li>
<li
className={styles.listItem}
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item--2"
>
<Trans>
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong--2">
Saved media
</strong>
: uploaded, taggable, searchable, and persistent, but counts against your saved media limit.
</Trans>
</li>
</ul>
<p className={styles.hint} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.hint">
<Trans>We'll only ask once.</Trans>
</p>
</div>
</Modal.Content>
<Modal.Footer data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-footer">
<Button
variant="secondary"
onClick={handleUseSavedMedia}
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.use-saved-media"
>
<Trans>Use saved media</Trans>
</Button>
<Button
variant="primary"
onClick={handleConfirm}
ref={initialFocusRef}
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.confirm"
>
<Trans>Use URL-only (recommended)</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {type FavoriteGifEntry, slimFavoriteGifEntry} from './FavoriteGifTypes';
function format(src: string, width: number) {
return {src, proxy_src: `https://media.test/external/sig/${src}`, width, height: Math.round(width * 0.84)};
}
const FAT: FavoriteGifEntry = {
url: 'https://klipy.com/gifs/reaction',
proxy_url: 'https://media.test/external/sig/tiny.webm',
width: 220,
height: 185,
media: {
nanowebm: format('nano.webm', 90),
tinywebm: format('tiny.webm', 220),
mediumwebm: format('medium.webm', 320),
webm: format('full.webm', 498),
gif: format('full.gif', 498),
},
content_type: 'video/webm',
placeholder: 'placeholder',
};
describe('slimFavoriteGifEntry', () => {
it('drops the media map so a favorite is cheap to sync', () => {
expect(slimFavoriteGifEntry(FAT).media).toEqual({});
});
it('promotes a preview that covers a 200px tile at 2x into the top-level fields', () => {
const slim = slimFavoriteGifEntry(FAT);
expect(slim.proxy_url).toBe(FAT.media.webm.proxy_src);
expect(slim.width).toBe(498);
expect(slim.content_type).toBe('video/webm');
});
it('keeps url and placeholder untouched', () => {
const slim = slimFavoriteGifEntry(FAT);
expect(slim.url).toBe(FAT.url);
expect(slim.placeholder).toBe(FAT.placeholder);
});
it('is idempotent so the synced roundtrip stays stable', () => {
const once = slimFavoriteGifEntry(FAT);
expect(slimFavoriteGifEntry(once)).toEqual(once);
});
it('leaves an entry that already carries no media alone', () => {
const urlOnly: FavoriteGifEntry = {...FAT, media: {}};
expect(slimFavoriteGifEntry(urlOnly)).toBe(urlOnly);
});
it('does not invent a preview when every format is unusable', () => {
const broken: FavoriteGifEntry = {...FAT, media: {webm: {src: '', proxy_src: '', width: 0, height: 0}}};
const slim = slimFavoriteGifEntry(broken);
expect(slim.media).toEqual({});
expect(slim.proxy_url).toBe(FAT.proxy_url);
});
});
@@ -105,4 +105,24 @@ export function pickCanonicalPreviewFormat(
return null;
}
export const STORED_PREVIEW_DEVICE_PIXEL_RATIO = 2;
export function slimFavoriteGifEntry(entry: FavoriteGifEntry): FavoriteGifEntry {
const best = pickBestPreviewFormat(entry.media, 'any', {
cssWidth: PREVIEW_TILE_CSS_WIDTH,
devicePixelRatio: STORED_PREVIEW_DEVICE_PIXEL_RATIO,
});
if (best == null) {
return Object.keys(entry.media).length === 0 ? entry : {...entry, media: {}};
}
return {
...entry,
proxy_url: best.format.proxy_src,
width: best.format.width,
height: best.format.height,
content_type: inferFormatContentType(best.key),
media: {},
};
}
export {inferFormatContentType};
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import type {FavoriteGifEntry} from './FavoriteGifTypes';
import {buildGifPickerGridData} from './GifPickerGridData';
import type {FavoriteAwareGif} from './GifPickerTypes';
@@ -19,7 +20,84 @@ function gif(id: string, width = 200, height = 120): FavoriteAwareGif {
};
}
const OPAQUE_PROXY_URL =
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
function slimFavorite(overrides: Partial<FavoriteGifEntry> = {}): FavoriteGifEntry {
return {
url: 'https://klipy.com/gifs/doc-brown-bttf',
proxy_url: OPAQUE_PROXY_URL,
width: 640,
height: 360,
media: {},
content_type: 'video/webm',
placeholder: null,
...overrides,
};
}
describe('GifPickerGridData', () => {
it('carries the stored content type of a slimmed favorite into its tile', () => {
const data = buildGifPickerGridData({
surface: 'favorites',
loading: false,
columns: 3,
provider: 'klipy',
featured: {gifs: [], categories: []},
gifs: [],
favoriteGifs: [slimFavorite()],
favoriteMemes: [],
useSavedMediaForGifFavorites: false,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data[0]).toMatchObject({
type: 'gif',
gif: {proxy_src: OPAQUE_PROXY_URL, contentType: 'video/webm'},
});
});
it('carries the stored content type of a slimmed favorite into the featured favorites tile', () => {
const data = buildGifPickerGridData({
surface: 'featured',
loading: false,
columns: 3,
provider: 'klipy',
featured: {gifs: [gif('featured')], categories: []},
gifs: [],
favoriteGifs: [slimFavorite()],
favoriteMemes: [],
useSavedMediaForGifFavorites: false,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data[0]).toMatchObject({
type: 'category',
previewProxySrc: OPAQUE_PROXY_URL,
previewContentType: 'video/webm',
});
});
it('takes the content type of the saved media standing in for an empty favorites tile', () => {
const data = buildGifPickerGridData({
surface: 'featured',
loading: false,
columns: 3,
provider: 'klipy',
featured: {gifs: [gif('featured')], categories: []},
gifs: [],
favoriteGifs: [],
favoriteMemes: [{contentType: 'video/mp4', url: 'meme-0'}],
useSavedMediaForGifFavorites: true,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data[0]).toMatchObject({type: 'category', previewProxySrc: 'meme-0', previewContentType: 'video/mp4'});
});
it('keeps featured data deterministic while choosing preview tiles', () => {
const data = buildGifPickerGridData({
surface: 'featured',
@@ -2,6 +2,7 @@
import {
type FavoriteGifEntry,
inferFormatContentType,
pickBestPreviewFormat,
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
@@ -72,6 +73,7 @@ function buildFavoriteGifItems(
const previewProxySrc = best?.format.proxy_src ?? fallbackSrc;
const previewWidth = best?.format.width ?? entry.width;
const previewHeight = best?.format.height ?? entry.height;
const previewContentType = best ? inferFormatContentType(best.key) : entry.content_type;
items.push({
type: 'gif',
key: entry.url,
@@ -86,6 +88,7 @@ function buildFavoriteGifItems(
width: previewWidth > 0 ? previewWidth : DEFAULT_GIF_SIZE,
height: previewHeight > 0 ? previewHeight : DEFAULT_GIF_SIZE,
media: entry.media,
contentType: previewContentType,
favoriteGifLookup: {url: entry.url},
},
});
@@ -102,21 +105,21 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
const favoriteGifPreviewEntry =
favoriteGifPreviewIndex >= 0 ? input.favoriteGifs[favoriteGifPreviewIndex] : undefined;
const favoriteGifPreview = pickBestPreviewFormat(favoriteGifPreviewEntry?.media);
const favoriteMemePreview =
gifvMemes.length > 0
? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)]?.url ?? '')
: '';
const favoriteTilePreview =
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
? favoriteMemePreview
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
const favoriteTileProxyPreview =
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
? favoriteTilePreview
: favoriteGifPreview?.format.proxy_src ||
favoriteGifPreviewEntry?.proxy_url ||
favoriteGifPreviewEntry?.url ||
'';
const favoriteMemeCandidate =
gifvMemes.length > 0 ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)] ?? null) : null;
const favoriteMemePreview = favoriteMemeCandidate?.url ?? '';
const usesFavoriteMemePreview = input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0;
const favoriteTilePreview = usesFavoriteMemePreview
? favoriteMemePreview
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
const favoriteTileProxyPreview = usesFavoriteMemePreview
? favoriteTilePreview
: favoriteGifPreview?.format.proxy_src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
const favoriteTileContentType = (() => {
if (usesFavoriteMemePreview) return favoriteMemeCandidate?.contentType ?? '';
if (favoriteGifPreview) return inferFormatContentType(favoriteGifPreview.key);
return favoriteGifPreviewEntry?.proxy_url ? favoriteGifPreviewEntry.content_type : '';
})();
const favoritesTile: Array<GifPickerGridItemData> =
(input.includeFavoritesTile ?? true)
? [
@@ -128,6 +131,7 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
title: input.favoriteTitle,
previewUrl: favoriteTilePreview,
previewProxySrc: favoriteTileProxyPreview,
previewContentType: favoriteTileContentType,
width: CATEGORY_TILE_WIDTH,
height: CATEGORY_TILE_HEIGHT,
},
@@ -3,7 +3,7 @@
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import styles from '@app/features/channel/components/GifPicker.module.css';
import {safePause, safePlay, useGifVideoPool} from '@app/features/channel/components/GifVideoPool';
import {FavoriteGifFirstTimePromptModal} from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal';
import {resolvesToVideo} from '@app/features/channel/components/pickers/gif/GifPickerMediaKind';
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
import {PickerThumbnail} from '@app/features/channel/components/pickers/shared/PickerThumbnail';
import {usePooledVideo} from '@app/features/channel/components/pickers/shared/usePooledVideo';
@@ -24,7 +24,6 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {modal, push} from '@app/features/ui/commands/ModalCommands';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {msg} from '@lingui/core/macro';
@@ -54,30 +53,6 @@ const SELECT_GIF_DESCRIPTOR = msg({
comment:
'Button label in the gif picker grid item when picking a GIF for an avatar, banner, or video background. Keep it concise. Preserve {title}; it is inserted by code.',
});
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
function testSourcePath(value: string, pattern: RegExp): boolean {
try {
const url = new URL(value);
return pattern.test(url.pathname);
} catch {
return pattern.test(value);
}
}
function isVideoSourceUrl(value: string): boolean {
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
}
function statesItsMediaKind(value: string): boolean {
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
}
function resolvesToVideo(proxySrc: string, mediaSourceUrl: string | null): boolean {
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
}
export const GifPickerGridItem = observer(function GifPickerGridItem({
item,
@@ -128,6 +103,11 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
if (item.type === 'category') return item.previewUrl;
return null;
})();
const mediaContentType = (() => {
if (item.type === 'gif') return item.gif.contentType ?? '';
if (item.type === 'category') return item.previewContentType ?? '';
return '';
})();
const thumbnailPlaceholder = (() => {
if (item.type !== 'gif') return null;
if (item.gif.placeholder) return item.gif.placeholder;
@@ -135,7 +115,10 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
return FavoriteGif.findByUrl(lookupUrl)?.placeholder ?? null;
})();
const usesVideoElement =
!isSkeleton && proxySrc !== null && proxySrc.length > 0 && resolvesToVideo(proxySrc, mediaSourceUrl);
!isSkeleton &&
proxySrc !== null &&
proxySrc.length > 0 &&
resolvesToVideo(mediaContentType, proxySrc, mediaSourceUrl);
const videoThumbHashURL = decodeThumbHashDataURL(usesVideoElement ? thumbnailPlaceholder : null);
const hasThumbnailContent = (proxySrc !== null && proxySrc.length > 0) || thumbnailPlaceholder !== null;
const videoRef = usePooledVideo({
@@ -400,17 +383,6 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isFavoritePending) return;
if (!FavoriteGif.hasSeenFavoriteGifFirstTimePrompt && !isFavorited) {
push(
modal(() => (
<FavoriteGifFirstTimePromptModal
onConfirm={() => void performFavoriteToggle()}
data-flx="channel.pickers.gif.gif-picker-grid-item.handle-favorite-click.favorite-gif-first-time-prompt-modal"
/>
)),
);
return;
}
void performFavoriteToggle();
};
const favoriteTooltipText = (() => {
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {resolvesToVideo} from './GifPickerMediaKind';
const OPAQUE_PROXY_SRC =
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
const PLAIN_PROXY_SRC = 'https://media.test/external/sig/https/static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm';
describe('resolvesToVideo', () => {
it('trusts a stored video content type when no url states its kind', () => {
expect(resolvesToVideo('video/webm', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(true);
});
it('trusts a stored image content type when no url states its kind', () => {
expect(resolvesToVideo('image/gif', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
});
it('falls back to an image when nothing states the kind', () => {
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
});
it('reads the extension off a plain proxy path', () => {
expect(resolvesToVideo('', PLAIN_PROXY_SRC, null)).toBe(true);
});
it('reads the extension off the media source when the proxy path is opaque', () => {
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, 'https://static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm')).toBe(true);
});
it('keeps images as images when the proxy path states its kind', () => {
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.gif', null)).toBe(false);
});
it('ignores a query string when reading the extension', () => {
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.mp4?width=200', null)).toBe(true);
});
});
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
function testSourcePath(value: string, pattern: RegExp): boolean {
try {
const url = new URL(value);
return pattern.test(url.pathname);
} catch {
return pattern.test(value);
}
}
function isVideoSourceUrl(value: string): boolean {
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
}
function statesItsMediaKind(value: string): boolean {
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
}
export function resolvesToVideo(contentType: string, proxySrc: string, mediaSourceUrl: string | null): boolean {
if (contentType.startsWith('video/')) return true;
if (contentType.startsWith('image/')) return false;
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
}
@@ -10,6 +10,7 @@ export interface FavoriteGifLookup {
export type FavoriteAwareGif = Gif & {
favoriteGifLookup?: FavoriteGifLookup;
contentType?: string;
};
export type GifPickerGridItemData =
| {
@@ -20,6 +21,7 @@ export type GifPickerGridItemData =
categoryKind: 'favorites' | 'trending' | 'category';
previewUrl: string;
previewProxySrc: string;
previewContentType?: string;
width: number;
height: number;
}
@@ -32,7 +32,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import ContextMenuState from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import Users from '@app/features/user/state/Users';
import {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical';
import {$addUpdateTag, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, PASTE_TAG} from 'lexical';
import type React from 'react';
import {useCallback, useEffect} from 'react';
@@ -233,6 +233,7 @@ export function useChannelComposerPaste({
if (!insertPastedText(pastedText)) {
return false;
}
$addUpdateTag(PASTE_TAG);
event.preventDefault();
return true;
};
@@ -5,7 +5,10 @@ import type {Channel} from '@app/features/channel/models/Channel';
import {selectChannel} from '@app/features/navigation/commands/NavigationCommands';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants';
import {
VOICE_CHANNEL_BITRATE_DEFAULT,
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
} from '@fluxer/constants/src/LimitConstants';
export interface FormInputs {
name: string;
@@ -52,7 +55,7 @@ export async function createChannel(guildId: string, data: FormInputs, parentId?
url: data.url,
type: channelType,
parent_id: parentId || null,
bitrate: channelType === ChannelTypes.GUILD_VOICE ? 64000 : null,
bitrate: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_BITRATE_DEFAULT : null,
user_limit: channelType === ChannelTypes.GUILD_VOICE ? 0 : null,
voice_connection_limit: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null,
});
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
FavoriteGifEntry,
FavoriteGifMediaFormat,
import {
type FavoriteGifEntry,
type FavoriteGifMediaFormat,
slimFavoriteGifEntry,
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
import {makeSyncedField} from '@app/features/user/state/SyncedField';
import {FAVORITE_GIF_MAX_ENCODED_BYTES} from '@app/features/user/state/SyncedFieldBudget';
import type {FavoriteGifMediaFormat as FavoriteGifMediaFormatProto} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
import {FavoriteGifSettingsSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
import {makeAutoObservable} from 'mobx';
@@ -52,8 +54,9 @@ class FavoriteGif {
field: 'favoriteGifs',
schema: FavoriteGifSettingsSchema,
persist: ['favoriteGifs', 'saveGifFavoritesAsSavedMedia', 'hasSeenFavoriteGifFirstTimePrompt'],
maxEncodedBytes: FAVORITE_GIF_MAX_ENCODED_BYTES,
toMessage: (s) => ({
entries: s.favoriteGifs.map((entry) => ({
entries: s.favoriteGifs.map(slimFavoriteGifEntry).map((entry) => ({
url: entry.url,
proxyUrl: entry.proxy_url,
width: entry.width,
@@ -66,15 +69,17 @@ class FavoriteGif {
seenFirstTimePrompt: s.hasSeenFavoriteGifFirstTimePrompt,
}),
applyMessage: (s, m) => {
s.favoriteGifs = m.entries.map((entry) => ({
url: entry.url,
proxy_url: entry.proxyUrl,
width: entry.width,
height: entry.height,
media: mediaFromProto(entry.media),
content_type: entry.contentType,
placeholder: entry.placeholder ? entry.placeholder : null,
}));
s.favoriteGifs = m.entries.map((entry) =>
slimFavoriteGifEntry({
url: entry.url,
proxy_url: entry.proxyUrl,
width: entry.width,
height: entry.height,
media: mediaFromProto(entry.media),
content_type: entry.contentType,
placeholder: entry.placeholder ? entry.placeholder : null,
}),
);
s.saveGifFavoritesAsSavedMedia = m.saveAsSavedMedia;
s.hasSeenFavoriteGifFirstTimePrompt = m.seenFirstTimePrompt;
},
@@ -110,10 +115,6 @@ class FavoriteGif {
setSaveGifFavoritesAsSavedMedia(value: boolean): void {
this.saveGifFavoritesAsSavedMedia = value;
}
markFirstTimePromptSeen(): void {
this.hasSeenFavoriteGifFirstTimePrompt = true;
}
}
export default new FavoriteGif();
@@ -143,6 +143,21 @@ const VERIFIED_GUILD_DESCRIPTOR = msg({
message: 'Verified community',
comment: 'Audit log community feature label for verified communities.',
});
const AUDIO_BITRATE_128_KBPS_DESCRIPTOR = msg({
message: '128 kbps voice quality',
comment:
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
});
const AUDIO_BITRATE_256_KBPS_DESCRIPTOR = msg({
message: '256 kbps voice quality',
comment:
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
});
const AUDIO_BITRATE_384_KBPS_DESCRIPTOR = msg({
message: '384 kbps voice quality',
comment:
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
});
const VIP_VOICE_DESCRIPTOR = msg({
message: 'VIP voice',
comment: 'Audit log community feature label for elevated voice capacity/quality.',
@@ -404,6 +419,9 @@ export function getSplashAlignmentLabel(value: unknown, i18n: I18n): string | nu
const featureLabelMap: Record<string, MessageDescriptor> = {
[GuildFeatures.ANIMATED_ICON]: ANIMATED_ICON_DESCRIPTOR,
[GuildFeatures.ANIMATED_BANNER]: ANIMATED_BANNER_DESCRIPTOR,
[GuildFeatures.AUDIO_BITRATE_128_KBPS]: AUDIO_BITRATE_128_KBPS_DESCRIPTOR,
[GuildFeatures.AUDIO_BITRATE_256_KBPS]: AUDIO_BITRATE_256_KBPS_DESCRIPTOR,
[GuildFeatures.AUDIO_BITRATE_384_KBPS]: AUDIO_BITRATE_384_KBPS_DESCRIPTOR,
[GuildFeatures.BANNER]: BANNER_DESCRIPTOR,
[GuildFeatures.CLONE_EMOJI_DISABLED]: EMOJI_CLONING_DISABLED_DESCRIPTOR,
[GuildFeatures.CLONE_STICKER_DISABLED]: STICKER_CLONING_DISABLED_DESCRIPTOR,

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