Compare commits

..
Author SHA1 Message Date
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
322 changed files with 25314 additions and 17575 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
+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,
@@ -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,
@@ -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
+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;
@@ -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`]);
});
});
@@ -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);
},
);
@@ -20,7 +20,7 @@ interface MessageLookupRepository {
readonly messages: Pick<IMessageRepository, 'getMessage'>;
}
export interface MessageSearchLookupResult extends SearchResult<SearchableMessage> {
interface MessageSearchLookupResult extends SearchResult<SearchableMessage> {
messages: Array<Message>;
}
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -1416,9 +1416,15 @@
{
"msgid": "Clear command"
},
{
"msgid": "Clear message text"
},
{
"msgid": "Clearer text"
},
{
"msgid": "Clearing the text of a message with attachments asks to delete the message unless this is on"
},
{
"msgid": "Closed"
},
@@ -1482,6 +1488,9 @@
{
"msgid": "Delete other members' messages in this channel. Pinning is controlled separately."
},
{
"msgid": "Delete prompt"
},
{
"msgid": "Delete user"
},
@@ -1518,6 +1527,9 @@
{
"msgid": "Email us and a person will review your account. Tell us you cannot verify by SMS and we will take it from there. Replies come by email, so this is not instant."
},
{
"msgid": "Empty edit"
},
{
"msgid": "Emulate decode codec (testing)"
},
@@ -1641,6 +1653,12 @@
{
"msgid": "Keep Neko still"
},
{
"msgid": "Keep attachments"
},
{
"msgid": "Keep attachments when clearing an edit"
},
{
"msgid": "Kicked {targetUserTag} from the community"
},
@@ -1674,6 +1692,9 @@
{
"msgid": "Mention everyone or any role in this channel (even if the role isn't set to be mentionable)."
},
{
"msgid": "Message editing"
},
{
"msgid": "Microphone ({deviceLabel})"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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