mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 19:52:13 +09:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30a4f5d14 | ||
|
|
f254ed679b | ||
|
|
258fe6f742 | ||
|
|
cfa20b7093 | ||
|
|
569146c5bc | ||
|
|
1b1d48b05e | ||
|
|
cadca2c18e | ||
|
|
2e3f78b3c6 | ||
|
|
b57545b1a4 | ||
|
|
e490be2f35 | ||
|
|
ab07fd23cf | ||
|
|
c1fd2234b8 | ||
|
|
3af43b3366 | ||
|
|
0e470f532e | ||
|
|
c541b86c00 | ||
|
|
53b3fa2f4a | ||
|
|
67e01be34a | ||
|
|
81fd8c9aad | ||
|
|
bcef7b3123 | ||
|
|
a98d8ef679 | ||
|
|
a5af857564 | ||
|
|
2830221949 | ||
|
|
84aa8880f5 | ||
|
|
395ec1d60f | ||
|
|
e6ee3b8059 | ||
|
|
61a13e1c1a | ||
|
|
fc0e2628a4 | ||
|
|
87fdfd9c34 | ||
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 | ||
|
|
d17f320bd7 | ||
|
|
f708586c59 | ||
|
|
905af5dd5a | ||
|
|
5fea319f4e | ||
|
|
01fd11fea9 | ||
|
|
d028679b90 |
@@ -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
|
||||
|
||||
@@ -365,10 +365,16 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
||||
# users and messages routers and their shards. The Rust built-in defaults are 192
|
||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
||||
# users and messages routers and their shards. Leave it unset and each service
|
||||
# uses its own built-in default, which is what the numbers below describe. Set it
|
||||
# and the one value replaces the built-in default on all four, so size it for the
|
||||
# busiest of them rather than for the smallest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them, and the api
|
||||
# turns that rejection into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
|
||||
@@ -565,7 +565,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -583,7 +583,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -633,7 +633,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -651,7 +651,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(®ion_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(®ion_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(®ion_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(®ion_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") {
|
||||
|
||||
@@ -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(®ion_name),
|
||||
Some(&resp.servers),
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_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(®ion_name),
|
||||
None,
|
||||
Some(&msg),
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: None,
|
||||
connection_counts: &connection_counts,
|
||||
error: Some(&msg),
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -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}®ion_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}®ion_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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -372,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
|
||||
monthlyEur: master.integrations.stripe.prices.monthly_eur,
|
||||
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
|
||||
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
|
||||
monthlyInr: master.integrations.stripe.prices.monthly_inr,
|
||||
monthlyNok: master.integrations.stripe.prices.monthly_nok,
|
||||
monthlyPln: master.integrations.stripe.prices.monthly_pln,
|
||||
monthlySek: master.integrations.stripe.prices.monthly_sek,
|
||||
monthlyTry: master.integrations.stripe.prices.monthly_try,
|
||||
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
|
||||
yearlyEur: master.integrations.stripe.prices.yearly_eur,
|
||||
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
|
||||
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
|
||||
yearlyInr: master.integrations.stripe.prices.yearly_inr,
|
||||
yearlyNok: master.integrations.stripe.prices.yearly_nok,
|
||||
yearlyPln: master.integrations.stripe.prices.yearly_pln,
|
||||
yearlySek: master.integrations.stripe.prices.yearly_sek,
|
||||
yearlyTry: master.integrations.stripe.prices.yearly_try,
|
||||
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
|
||||
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
|
||||
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
|
||||
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
|
||||
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
|
||||
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
|
||||
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
|
||||
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
|
||||
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
|
||||
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
|
||||
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
|
||||
|
||||
@@ -229,14 +229,11 @@ export class AdminMessageService {
|
||||
hitsPerPage: limit,
|
||||
page: 1,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const resolvedMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) => this.getMessageResponseForAdmin(channelId, messageId)),
|
||||
);
|
||||
const messageResponses = resolvedMessages.filter((message): message is MessageResponse => message !== null);
|
||||
const messageResponses = await createMessageResponseDataService().buildMessages({
|
||||
userId: createUserID(0n),
|
||||
messages: result.messages,
|
||||
access: await this.getMessageResponseAccessForAdmin(channelId),
|
||||
});
|
||||
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
|
||||
const priorReports = await this.getPriorReportsForMessages(messageResponses);
|
||||
const adminMessages = messageResponses.map((message) =>
|
||||
@@ -282,19 +279,6 @@ export class AdminMessageService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getMessageResponseForAdmin(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
): Promise<MessageResponse | null> {
|
||||
const access = await this.getMessageResponseAccessForAdmin(channelId);
|
||||
return createMessageResponseDataService().getMessage({
|
||||
userId: createUserID(0n),
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
});
|
||||
}
|
||||
|
||||
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
|
||||
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
|
||||
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createGuildID, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {isSyntheticUserId} from '../../constants/Core';
|
||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '../../SearchFactory';
|
||||
@@ -129,12 +130,11 @@ export class AdminSearchService {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const query = data.query?.trim() || '';
|
||||
const isIdQuery = /^\d+$/.test(query);
|
||||
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
|
||||
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
|
||||
const [searchResult, directUser] = await Promise.all([
|
||||
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
|
||||
isIdQuery && data.offset === 0
|
||||
? userRepository.findUnique(createUserID(BigInt(query))).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
const {hits, total} = searchResult;
|
||||
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {isSyntheticUserId} from '../../constants/Core';
|
||||
import {Logger} from '../../Logger';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
|
||||
@@ -32,7 +33,7 @@ export class AdminUserLookupService {
|
||||
} else if (/^\d+$/.test(query)) {
|
||||
try {
|
||||
const userId = createUserID(BigInt(query));
|
||||
user = await userRepository.findUnique(userId);
|
||||
user = isSyntheticUserId(userId) ? null : await userRepository.findUnique(userId);
|
||||
} catch (error) {
|
||||
Logger.debug({query, error}, 'Failed to lookup user by numeric ID, invalid ID format');
|
||||
user = null;
|
||||
|
||||
@@ -235,6 +235,7 @@ export class AdminVoiceService {
|
||||
serverId: data.server_id,
|
||||
endpoint: data.endpoint,
|
||||
isActive: data.is_active ?? true,
|
||||
softConnectionLimit: data.soft_connection_limit ?? null,
|
||||
apiKey: data.api_key ?? null,
|
||||
apiSecret: data.api_secret ?? null,
|
||||
latitude: data.latitude ?? null,
|
||||
@@ -271,6 +272,7 @@ export class AdminVoiceService {
|
||||
if (data.latitude !== undefined) updates.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updates.longitude = data.longitude;
|
||||
if (data.is_active !== undefined) updates.isActive = data.is_active;
|
||||
if (data.soft_connection_limit !== undefined) updates.softConnectionLimit = data.soft_connection_limit;
|
||||
updates.restrictions = patchVoiceRestrictions(existing.restrictions, data);
|
||||
updates.updatedAt = new Date();
|
||||
await voiceRepository.upsertServer(updates);
|
||||
@@ -339,6 +341,7 @@ export class AdminVoiceService {
|
||||
latitude: server.latitude ?? null,
|
||||
longitude: server.longitude ?? null,
|
||||
is_active: server.isActive,
|
||||
soft_connection_limit: server.softConnectionLimit ?? null,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: allowedGuildIds,
|
||||
|
||||
@@ -21,6 +21,8 @@ interface UserListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
const SYNTHETIC_USER_IDS = ['0', '1'];
|
||||
|
||||
async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: string): Promise<void> {
|
||||
await createBuilder(harness, `${token}`)
|
||||
.get('/users/@me')
|
||||
@@ -170,5 +172,35 @@ describe('Admin user directory', () => {
|
||||
expect(result.users.map((user) => user.id)).toEqual([target.userId]);
|
||||
expect(result.users[0]?.email).toBeNull();
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the resolve selector', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users?resolve=${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the q selector', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users?q=${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users.map((user) => user.id)).not.toContain(userId);
|
||||
});
|
||||
});
|
||||
describe('GET /admin/users/:user_id', () => {
|
||||
test.each(SYNTHETIC_USER_IDS)('reports no user for the synthetic account %s', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,4 +266,161 @@ describe('VoiceAdminController', () => {
|
||||
expect(persisted?.apiKey).toBe(fixture.initialApiKey);
|
||||
expect(persisted?.apiSecret).toBe(fixture.initialApiSecret);
|
||||
});
|
||||
test('stores, keeps, and clears a voice server soft connection limit', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_LIST,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-soft-limit';
|
||||
const serverId = 'voice-server-soft-limit';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const created = await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-soft-limit.example.com/socket',
|
||||
api_key: 'soft-limit-api-key',
|
||||
api_secret: 'soft-limit-api-secret',
|
||||
soft_connection_limit: 250,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(created.server.soft_connection_limit).toBe(250);
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({endpoint: 'https://voice-soft-limit-2.example.com/socket'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({soft_connection_limit: null})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared.server.soft_connection_limit).toBeNull();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBeNull();
|
||||
});
|
||||
test('clears voice server restriction lists when empty arrays are supplied', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-clear-restrictions';
|
||||
const serverId = 'voice-server-clear-restrictions';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-clear.example.com/socket',
|
||||
api_key: 'clear-api-key',
|
||||
api_secret: 'clear-api-secret',
|
||||
required_guild_features: ['VIP_VOICE'],
|
||||
allowed_guild_ids: [1234567890123456789n.toString()],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const stored = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(Array.from(stored?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
|
||||
expect(stored?.restrictions.allowedGuildIds.size).toBe(1);
|
||||
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({required_guild_features: [], allowed_guild_ids: []})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared.server.required_guild_features).toEqual([]);
|
||||
expect(cleared.server.allowed_guild_ids).toEqual([]);
|
||||
const persisted = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(persisted?.restrictions.requiredGuildFeatures.size).toBe(0);
|
||||
expect(persisted?.restrictions.allowedGuildIds.size).toBe(0);
|
||||
});
|
||||
test('leaves voice server restriction lists unchanged when they are omitted', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-keep-restrictions';
|
||||
const serverId = 'voice-server-keep-restrictions';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-keep.example.com/socket',
|
||||
api_key: 'keep-api-key',
|
||||
api_secret: 'keep-api-secret',
|
||||
required_guild_features: ['VIP_VOICE'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({is_active: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const persisted = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(Array.from(persisted?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
|
||||
expect(persisted?.isActive).toBe(false);
|
||||
});
|
||||
test('rejects a voice server soft connection limit below one', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_REGION_CREATE, AdminACLs.VOICE_SERVER_CREATE]);
|
||||
const regionId = 'voice-region-soft-limit-invalid';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: 'voice-server-soft-limit-invalid',
|
||||
endpoint: 'https://voice-soft-limit-invalid.example.com/socket',
|
||||
api_key: 'soft-limit-invalid-api-key',
|
||||
api_secret: 'soft-limit-invalid-api-secret',
|
||||
soft_connection_limit: 0,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
||||
Permissions,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
@@ -260,13 +260,17 @@ export class ChannelOperationsService {
|
||||
validateCapacity: requestedParentId !== null && requestedParentId !== (channel.parentId ?? null),
|
||||
});
|
||||
}
|
||||
let nextBitrate = channel.bitrate;
|
||||
if (data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
nextBitrate = data.bitrate === null ? null : clampVoiceChannelBitrate(data.bitrate, guild.features ?? []);
|
||||
}
|
||||
const updatedChannelData = {
|
||||
...channel.toRow(),
|
||||
name: channelName,
|
||||
topic: data.topic !== undefined ? data.topic : channel.topic,
|
||||
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
|
||||
parent_id: requestedParentId,
|
||||
bitrate: data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.bitrate : channel.bitrate,
|
||||
bitrate: nextBitrate,
|
||||
user_limit:
|
||||
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.user_limit
|
||||
|
||||
@@ -4,11 +4,10 @@ import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {MessageResponse, MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {AttachmentDecayService} from '../../../attachment/AttachmentDecayService';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../../../BrandedTypes';
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
@@ -192,29 +191,19 @@ export class MessageRetrievalService {
|
||||
hitsPerPage,
|
||||
page,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const access = {
|
||||
sourceGuildId: channel.guildId,
|
||||
messageHistoryCutoff: !hasReadHistory ? (authChannel.guild?.message_history_cutoff ?? null) : null,
|
||||
canReadMessageHistory: hasReadHistory,
|
||||
};
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const foundMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) =>
|
||||
responseDataService.getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
}),
|
||||
),
|
||||
const builtMessages = await createMessageResponseDataService().buildMessages({
|
||||
userId,
|
||||
messages: result.messages,
|
||||
access,
|
||||
});
|
||||
const messageResponses = builtMessages.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const messageResponses = foundMessages
|
||||
.filter((message): message is MessageResponse => message !== null)
|
||||
.map(({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage);
|
||||
return {
|
||||
channels: messageResponses.length > 0 ? [await this.mapSearchChannelResponse(channel, userId, requestCache)] : [],
|
||||
messages: messageResponses,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID} from '../BrandedTypes';
|
||||
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
|
||||
export const SYSTEM_USER_ID = createUserID(0n);
|
||||
|
||||
export function isSyntheticUserId(userId: UserID): boolean {
|
||||
return userId === SYSTEM_USER_ID || userId === DELETED_USER_ID;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface VoiceServerRow {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
is_active: boolean | null;
|
||||
soft_connection_limit: number | null;
|
||||
vip_only: boolean | null;
|
||||
required_guild_features: Set<string> | null;
|
||||
allowed_guild_ids: Set<bigint> | null;
|
||||
@@ -56,6 +57,7 @@ export const VOICE_SERVER_COLUMNS = [
|
||||
'latitude',
|
||||
'longitude',
|
||||
'is_active',
|
||||
'soft_connection_limit',
|
||||
'vip_only',
|
||||
'required_guild_features',
|
||||
'allowed_guild_ids',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
StorageObjectRangeNotSatisfiableError,
|
||||
} from '../infrastructure/IStorageService';
|
||||
import {Logger} from '../Logger';
|
||||
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
|
||||
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {
|
||||
parseDesktopArtifactScope,
|
||||
parseDesktopReleaseDescriptor,
|
||||
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
|
||||
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
|
||||
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
|
||||
| {kind: 'awaiting_release'}
|
||||
| {kind: 'ready'; location: string};
|
||||
|
||||
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
|
||||
|
||||
type ListedDesktopVersion = {
|
||||
version: string;
|
||||
pub_date: Date;
|
||||
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
|
||||
};
|
||||
|
||||
export class DownloadService {
|
||||
constructor(private readonly storageService: IStorageService) {}
|
||||
|
||||
@@ -216,41 +225,28 @@ export class DownloadService {
|
||||
) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
|
||||
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
|
||||
if (descriptorText == null) {
|
||||
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
|
||||
if (release.kind === 'untracked') {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
|
||||
if (release.kind === 'unpublished') {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.channel !== scope.channel ||
|
||||
descriptor.version !== manifest.version ||
|
||||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
|
||||
throw new Error(
|
||||
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
|
||||
);
|
||||
}
|
||||
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
|
||||
if (!releaseAsset) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
|
||||
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
|
||||
if (marker == null) {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(marker);
|
||||
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== descriptor.channel ||
|
||||
readiness.version !== descriptor.version ||
|
||||
readiness.release_tag !== descriptor.release_tag ||
|
||||
readiness.source_sha !== descriptor.source_sha ||
|
||||
readiness.descriptor_sha256 !== descriptorSha256
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
|
||||
@@ -269,18 +265,22 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const entry = manifest.files[params.format];
|
||||
if (!entry) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const filename = this.extractFilename(entry);
|
||||
if (filename.trim().length === 0) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const resolvedFilename = await this.resolveManifestFilename({
|
||||
channel: params.channel,
|
||||
@@ -291,7 +291,11 @@ export class DownloadService {
|
||||
test: params.test,
|
||||
});
|
||||
if (!resolvedFilename) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
|
||||
if (parsed && parsed.version !== manifest.version) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
@@ -302,7 +306,7 @@ export class DownloadService {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -320,10 +324,14 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
|
||||
if (result) {
|
||||
@@ -331,11 +339,11 @@ export class DownloadService {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
|
||||
async listDesktopVersions(params: {
|
||||
@@ -351,158 +359,17 @@ export class DownloadService {
|
||||
versions: Array<VersionInfo>;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return {versions: [], hasMore: false};
|
||||
let listedVersions = await this.listDesktopVersionFiles(params);
|
||||
if (params.before) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
const versionMap = new Map<
|
||||
string,
|
||||
{
|
||||
pub_date: Date;
|
||||
files: Map<
|
||||
DesktopFormat,
|
||||
{
|
||||
filename: string;
|
||||
sha256Key: string | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
|
||||
let filteredVersions = sortedVersions;
|
||||
if (params.before) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
|
||||
}
|
||||
if (params.after) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
|
||||
}
|
||||
const hasMore = filteredVersions.length > params.limit;
|
||||
const paginatedVersions = filteredVersions.slice(0, params.limit);
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return {versions, hasMore};
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
throw error;
|
||||
if (params.after) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
|
||||
}
|
||||
return {
|
||||
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
|
||||
hasMore: listedVersions.length > params.limit,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveVersionedDesktopKey(params: {
|
||||
@@ -558,10 +425,10 @@ export class DownloadService {
|
||||
}): Promise<DesktopChecksumFile | null> {
|
||||
const version = await this.getLatestDesktopVersion(params);
|
||||
const file = version?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
const key = await this.resolveLatestDesktopKey(params);
|
||||
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
@@ -586,8 +453,16 @@ export class DownloadService {
|
||||
if (objectSha256) {
|
||||
return this.buildDesktopChecksumFile(key, filename, objectSha256);
|
||||
}
|
||||
const latest = await this.getLatestDesktopVersion(params);
|
||||
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
|
||||
const prefix = desktopArtifactPrefix(params);
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
|
||||
const versionInfo =
|
||||
isDesktopManifest(manifest) && manifest.version === params.version
|
||||
? await this.getLatestDesktopVersionFromManifest(params, manifest)
|
||||
: null;
|
||||
const file = versionInfo?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
@@ -768,6 +643,77 @@ export class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
|
||||
const [descriptorText, markerText] = await Promise.all([
|
||||
this.readOptionalTextFromStorage(descriptorKey),
|
||||
this.readOptionalTextFromStorage(markerKey),
|
||||
]);
|
||||
if (descriptorText == null) {
|
||||
return {kind: 'untracked'};
|
||||
}
|
||||
if (markerText == null) {
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== channel ||
|
||||
readiness.version !== version ||
|
||||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
|
||||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
|
||||
) {
|
||||
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
return {kind: 'published', descriptorText};
|
||||
}
|
||||
|
||||
private async isReleasableDesktopVersion(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
version: string,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<boolean> {
|
||||
if (params.test || Config.instance.selfHosted) {
|
||||
return true;
|
||||
}
|
||||
const checked = releasability.get(version);
|
||||
if (checked !== undefined) {
|
||||
return checked;
|
||||
}
|
||||
let releasable = true;
|
||||
try {
|
||||
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
|
||||
} catch (error) {
|
||||
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
|
||||
}
|
||||
releasability.set(version, releasable);
|
||||
return releasable;
|
||||
}
|
||||
|
||||
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
candidates: ReadonlyArray<T>,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<T | null> {
|
||||
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
|
||||
for (const candidate of newestCandidates) {
|
||||
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const [newest] = newestCandidates;
|
||||
if (!newest) {
|
||||
return null;
|
||||
}
|
||||
Logger.error(
|
||||
{channel: params.channel, version: newest.version},
|
||||
'No recent desktop version has a published release',
|
||||
);
|
||||
return newest;
|
||||
}
|
||||
|
||||
private isValidSha256(value: string): boolean {
|
||||
return /^[a-f0-9]{64}$/u.test(value);
|
||||
}
|
||||
@@ -789,7 +735,13 @@ export class DownloadService {
|
||||
) {
|
||||
return manifestFilename;
|
||||
}
|
||||
return this.findLatestFilenameForRequestedArch(params);
|
||||
for (const entry of await this.listDesktopVersionFiles(params)) {
|
||||
const file = entry.files.get(params.format);
|
||||
if (file) {
|
||||
return file.filename;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
|
||||
@@ -826,44 +778,145 @@ export class DownloadService {
|
||||
return params.filename.toLowerCase().endsWith('.exe');
|
||||
}
|
||||
|
||||
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
private async listDesktopVersionFiles(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
test?: boolean;
|
||||
}): Promise<Array<ListedDesktopVersion>> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return null;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
const versionMap = new Map<string, ListedDesktopVersion>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
version,
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let latestFilename: string | null = null;
|
||||
let latestVersion: string | null = null;
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
filename.includes('/') ||
|
||||
filename.endsWith('.sha256') ||
|
||||
filename.endsWith('.blockmap') ||
|
||||
filename.endsWith('.yml') ||
|
||||
filename === 'manifest.json' ||
|
||||
filename === 'RELEASES.json' ||
|
||||
filename === 'releases.json'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed || parsed.format !== params.format) {
|
||||
continue;
|
||||
}
|
||||
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
|
||||
latestVersion = parsed.version;
|
||||
latestFilename = filename;
|
||||
}
|
||||
|
||||
private async buildListedDesktopVersions(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
listedVersions: ReadonlyArray<ListedDesktopVersion>,
|
||||
): Promise<Array<VersionInfo>> {
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const entry of listedVersions) {
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return latestFilename;
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const entry of listedVersions) {
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version: entry.version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
@@ -1030,16 +1083,25 @@ export class DownloadService {
|
||||
return key;
|
||||
}
|
||||
|
||||
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
const filename = await this.findLatestFilenameForRequestedArch(params);
|
||||
if (!filename) {
|
||||
private async resolveLatestDesktopKeyFromObjects(
|
||||
params: LatestFilenameLookupParams,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<string | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(
|
||||
params,
|
||||
listedVersions.filter((entry) => entry.files.has(params.format)),
|
||||
releasability,
|
||||
);
|
||||
const file = latest?.files.get(params.format);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
filename,
|
||||
filename: file.filename,
|
||||
test: params.test,
|
||||
});
|
||||
}
|
||||
@@ -1189,22 +1251,23 @@ export class DownloadService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getLatestDesktopVersionFromObjects(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
}): Promise<VersionInfo | null> {
|
||||
const {versions} = await this.listDesktopVersions({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
limit: 1,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
});
|
||||
return versions[0] ?? null;
|
||||
private async getLatestDesktopVersionFromObjects(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<VersionInfo | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
|
||||
return versionInfo ?? null;
|
||||
}
|
||||
|
||||
private async resolveDesktopFileSha256(params: {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Readable} from 'node:stream';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getConfig} from '../../Config';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
|
||||
const RELEASES_PREFIX = 'desktop/canary/github-releases';
|
||||
const SOURCE_SHA = 'b'.repeat(40);
|
||||
const V904 = '2026.904.135113';
|
||||
const V908 = '2026.908.173325';
|
||||
const V909 = '2026.909.202036';
|
||||
|
||||
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
|
||||
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
|
||||
|
||||
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
|
||||
['darwin', 'arm64', 4],
|
||||
['darwin', 'x64', 4],
|
||||
['linux', 'arm64', 4],
|
||||
['linux', 'x64', 4],
|
||||
['win32', 'arm64', 6],
|
||||
['win32', 'x64', 6],
|
||||
];
|
||||
|
||||
type StoredObjects = Map<string, string>;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
|
||||
const prefix = options.prefix ?? PREFIX;
|
||||
const filename = appImageFilename(version);
|
||||
objects.set(`${prefix}/${filename}`, filename);
|
||||
if (options.checksum !== false) {
|
||||
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
|
||||
}
|
||||
objects.set(
|
||||
`${prefix}/manifest.json`,
|
||||
JSON.stringify({
|
||||
channel: 'canary',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
version,
|
||||
pub_date: '2026-09-08T18:06:00Z',
|
||||
files: {appimage: {filename, sha256: sha256Hex(filename)}},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
|
||||
const assets = routes.flatMap(([plat, arch, count]) =>
|
||||
Array.from({length: count}, (_, index) => {
|
||||
const filename =
|
||||
plat === 'linux' && arch === 'x64' && index === 0
|
||||
? appImageFilename(version)
|
||||
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
|
||||
return {
|
||||
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
|
||||
release_asset: filename,
|
||||
sha256: sha256Hex(filename),
|
||||
size: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const descriptor = JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
assets,
|
||||
});
|
||||
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
|
||||
objects.set(
|
||||
`${RELEASES_PREFIX}/${version}.ready.json`,
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
descriptor_sha256: sha256Hex(descriptor),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function releaseBuild(objects: StoredObjects, version: string) {
|
||||
const descriptor = publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
publishMarker(objects, version, descriptor);
|
||||
}
|
||||
|
||||
function incidentObjects(): StoredObjects {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
return objects;
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
|
||||
const reads: Array<string> = [];
|
||||
const listings: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
onRead?.(params.key);
|
||||
const body = objects.get(params.key);
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) => {
|
||||
listings.push(params.prefix);
|
||||
return Array.from(objects.keys())
|
||||
.filter((key) => key.startsWith(params.prefix))
|
||||
.sort()
|
||||
.map((key) => ({key}));
|
||||
},
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads, listings};
|
||||
}
|
||||
|
||||
async function resolveLatest(service: DownloadService, test?: boolean) {
|
||||
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
|
||||
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
|
||||
return {version: metadata?.version, key, checksum: checksum?.body};
|
||||
}
|
||||
|
||||
function latestOf(version: string, prefix = PREFIX) {
|
||||
const filename = appImageFilename(version);
|
||||
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
|
||||
}
|
||||
|
||||
describe('desktop release readiness', () => {
|
||||
it('offers a published manifest version after reading only its release state', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
releaseBuild(objects, V909);
|
||||
const {service, reads, listings} = createService(objects);
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.ready.json`,
|
||||
]);
|
||||
expect(listings).toEqual([]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
});
|
||||
|
||||
it('falls back to the newest published version while the manifest version awaits its release', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
|
||||
const {service, reads, listings} = createService(incidentObjects());
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.ready.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.ready.json`,
|
||||
`${PREFIX}/${appImageFilename(V904)}.sha256`,
|
||||
]);
|
||||
expect(listings).toEqual([`${PREFIX}/`]);
|
||||
});
|
||||
|
||||
it('offers a manifest version that has no release descriptor', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
uploadBuild(objects, V904);
|
||||
uploadBuild(objects, V908);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, 'another descriptor');
|
||||
const {service} = createService(objects);
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const descriptor = publishDescriptor(
|
||||
objects,
|
||||
V908,
|
||||
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
|
||||
);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, descriptor);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
|
||||
'Invalid GitHub desktop release descriptor',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
|
||||
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
|
||||
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
|
||||
const {service} = createService(incidentObjects(), (key) => {
|
||||
if (key === failingKey) {
|
||||
throw new S3ServiceException({
|
||||
name: 'SlowDown',
|
||||
$fault: 'server',
|
||||
$metadata: {httpStatusCode: 503},
|
||||
message: 'Please reduce your request rate.',
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('still resolves the unpublished version through versioned routes', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {checksum: false});
|
||||
const {service} = createService(objects);
|
||||
const params = {...APPIMAGE_PARAMS, version: V908};
|
||||
const filename = appImageFilename(V908);
|
||||
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
|
||||
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
|
||||
sha256: sha256Hex(filename),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps offering the manifest version on self-hosted instances', async () => {
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
config.instance.selfHosted = true;
|
||||
try {
|
||||
const {service, reads} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps offering the newest test build', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
|
||||
const objects = incidentObjects();
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
publishMarker(objects, V909, descriptor);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
|
||||
kind: 'ready',
|
||||
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the newest version when ten unpublished versions hide a published one', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const newest = '2026.908.170009';
|
||||
for (let build = 0; build < 10; build++) {
|
||||
const version = `2026.908.${170000 + build}`;
|
||||
publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
|
||||
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
|
||||
});
|
||||
|
||||
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
|
||||
const objects = incidentObjects();
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
let manifestReads = 0;
|
||||
const {service} = createService(objects, (key) => {
|
||||
if (key !== `${PREFIX}/manifest.json`) {
|
||||
return;
|
||||
}
|
||||
manifestReads += 1;
|
||||
if (manifestReads === 2) {
|
||||
publishMarker(objects, V909, descriptor);
|
||||
}
|
||||
});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
|
||||
expect(checksum?.body).toBe(latestOf(V904).checksum);
|
||||
});
|
||||
|
||||
it('lists an unpublished version while latest skips it', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
|
||||
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const BASE_URL = 'https://api.example.test';
|
||||
const V1 = '2026.901.100000';
|
||||
const V2 = '2026.902.100000';
|
||||
const V3 = '2026.903.100000';
|
||||
const V4 = '2026.904.100000';
|
||||
const V5 = '2026.905.100000';
|
||||
|
||||
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
|
||||
|
||||
type StoredObject = {body?: string; lastModified?: Date};
|
||||
type StoredObjects = Map<string, StoredObject>;
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function debFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-amd64.deb`;
|
||||
}
|
||||
|
||||
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
|
||||
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
|
||||
if (options.sha256 !== undefined) {
|
||||
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
|
||||
}
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects) {
|
||||
const reads: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
const object = objects.get(params.key);
|
||||
if (object?.body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(object.body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) =>
|
||||
Array.from(objects.entries())
|
||||
.filter(([key]) => key.startsWith(params.prefix))
|
||||
.sort(([left], [right]) => (left < right ? -1 : 1))
|
||||
.map(([key, object]) => ({key, lastModified: object.lastModified})),
|
||||
getObjectMetadata: async () => null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads};
|
||||
}
|
||||
|
||||
function versionNumbers(versions: Array<{version: string}>): Array<string> {
|
||||
return versions.map((entry) => entry.version);
|
||||
}
|
||||
|
||||
describe('desktop version listing', () => {
|
||||
it('lists versions newest first with the files of each version', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
|
||||
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
|
||||
expect(listed.hasMore).toBe(false);
|
||||
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
|
||||
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
|
||||
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
|
||||
});
|
||||
|
||||
it('excludes names that are not artefacts for the requested coordinate', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
|
||||
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
|
||||
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
|
||||
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V3]);
|
||||
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
|
||||
});
|
||||
|
||||
it('pages with limit, before and after and reports whether more remain', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version));
|
||||
}
|
||||
const {service} = createService(objects);
|
||||
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
|
||||
expect(firstPage.hasMore).toBe(true);
|
||||
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
|
||||
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
|
||||
expect(olderPage.hasMore).toBe(false);
|
||||
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
|
||||
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
|
||||
expect(newerPage.hasMore).toBe(false);
|
||||
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
|
||||
expect(versionNumbers(between.versions)).toEqual([V4]);
|
||||
expect(between.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
|
||||
const hash = 'b'.repeat(64);
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: hash});
|
||||
addArtifact(objects, appImageFilename(V2));
|
||||
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(listed.versions[0].files.appimage).toEqual({
|
||||
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
|
||||
sha256: hash,
|
||||
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
|
||||
});
|
||||
expect(listed.versions[1].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a checksum only for the versions it returns', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
|
||||
});
|
||||
});
|
||||
@@ -156,7 +156,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
@@ -238,7 +238,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, GuildFeatures, resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_CHANNELS_PER_CATEGORY,
|
||||
MAX_GUILD_CHANNELS,
|
||||
@@ -119,12 +119,16 @@ export class ChannelOperationsService {
|
||||
);
|
||||
}
|
||||
let channelName = params.data.name;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
let guildFeatures: Array<string> | null = null;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_VOICE) {
|
||||
const guildData = await this.gatewayService.getGuildData({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
});
|
||||
const hasFlexibleNamesEnabled = guildData.features.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
guildFeatures = guildData.features;
|
||||
}
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
const hasFlexibleNamesEnabled = (guildFeatures ?? []).includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(channelName);
|
||||
}
|
||||
@@ -156,7 +160,10 @@ export class ChannelOperationsService {
|
||||
content_warning_level: requestedContentWarningLevel,
|
||||
content_warning_text: requestedContentWarningText,
|
||||
rate_limit_per_user: params.data.rate_limit_per_user ?? 0,
|
||||
bitrate: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.bitrate ?? 64000) : null,
|
||||
bitrate:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
? resolveVoiceChannelBitrate(params.data.bitrate, guildFeatures)
|
||||
: null,
|
||||
user_limit: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.user_limit ?? 0) : null,
|
||||
voice_connection_limit:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
GuildSplashCardAlignment,
|
||||
GuildVerificationLevel,
|
||||
JoinSourceTypes,
|
||||
resolveVoiceChannelBitrate,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_GUILD_CHANNELS,
|
||||
MAX_GUILD_ROLES,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -934,7 +936,14 @@ export class GuildOperationsService {
|
||||
addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_TEXT_CATEGORY_NAME, null, 0);
|
||||
addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_VOICE_CATEGORY_NAME, null, 1);
|
||||
addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, DEFAULT_TEXT_CHANNEL_NAME, textCategoryId, 0);
|
||||
addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, DEFAULT_VOICE_CHANNEL_NAME, voiceCategoryId, 0, 64000);
|
||||
addChannel(
|
||||
generalVoiceId,
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
DEFAULT_VOICE_CHANNEL_NAME,
|
||||
voiceCategoryId,
|
||||
0,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildRoles.insert({
|
||||
guild_id: guildId,
|
||||
@@ -1105,7 +1114,7 @@ export class GuildOperationsService {
|
||||
content_warning_level: null,
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
|
||||
bitrate: isVoice ? (channel.bitrate ?? 64000) : null,
|
||||
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
|
||||
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
|
||||
voice_connection_limit: isVoice
|
||||
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
@@ -25,6 +26,14 @@ describe('Guild Channel Management', () => {
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
async function addGuildFeaturesForTesting(guildId: string, features: Array<string>): Promise<void> {
|
||||
await createBuilder<{
|
||||
success: boolean;
|
||||
}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: features})
|
||||
.execute();
|
||||
}
|
||||
describe('Channel Name Updates', () => {
|
||||
test('should normalize channel name with spaces to hyphens', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -582,7 +591,7 @@ describe('Guild Channel Management', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('should reject bitrate above maximum (320000)', async () => {
|
||||
test('should reject bitrate above maximum (384000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -594,7 +603,7 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320001})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384001})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
@@ -678,7 +687,7 @@ describe('Guild Channel Management', () => {
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(8000);
|
||||
});
|
||||
test('should accept maximum bitrate (320000)', async () => {
|
||||
test('should clamp bitrate to 96000 without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -690,9 +699,74 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320000})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(320000);
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should clamp bitrate to the feature the guild holds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_256_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(256000);
|
||||
});
|
||||
test('should accept maximum bitrate (384000) with the 384 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_384_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(384000);
|
||||
});
|
||||
test('should store the default bitrate on a new voice channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
expect(voiceChannel.bitrate).toBe(64000);
|
||||
});
|
||||
test('should clamp bitrate on create without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should keep bitrate on create with the 128 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 128000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(128000);
|
||||
});
|
||||
test('should accept maximum user limit (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -734,6 +808,7 @@ describe('Guild Channel Management', () => {
|
||||
test('should update both bitrate and user limit together', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {setInjectedSearchProvider} from '../SearchFactory';
|
||||
import type {ISearchProvider} from '../search/ISearchProvider';
|
||||
import {VoiceAvailabilityService} from '../voice/VoiceAvailabilityService';
|
||||
import {VoiceRepository} from '../voice/VoiceRepository';
|
||||
import {VoiceServerLoadTracker} from '../voice/VoiceServerLoad';
|
||||
import {VoiceTopology} from '../voice/VoiceTopology';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
|
||||
@@ -289,7 +290,10 @@ export async function ensureVoiceResourcesInitialized(): Promise<void> {
|
||||
const topology = new VoiceTopology(voiceRepository, voiceConfigSubscriber);
|
||||
await topology.initialize();
|
||||
voiceTopology = topology;
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(topology);
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(
|
||||
topology,
|
||||
new VoiceServerLoadTracker({gatewayService: getGatewayService()}),
|
||||
);
|
||||
liveKitServiceInstance = new LiveKitService(topology);
|
||||
voiceRoomStoreInstance = new VoiceRoomStore(getKVClient());
|
||||
})().finally(() => {
|
||||
|
||||
@@ -13342,13 +13342,10 @@
|
||||
"name": "country_code",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
|
||||
},
|
||||
{
|
||||
"name": "pricing_mode",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"$ref": "#/components/schemas/PricingModeEnum"}
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13623,7 +13620,10 @@
|
||||
"name": "country_code",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27348,8 +27348,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27442,8 +27442,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27526,8 +27526,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27610,8 +27610,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -28041,6 +28041,9 @@
|
||||
"x-enumNames": [
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
@@ -28071,6 +28074,9 @@
|
||||
"x-enumDescriptions": [
|
||||
"Guild can have an animated icon",
|
||||
"Guild can have an animated banner",
|
||||
"Guild can set a voice channel bitrate of up to 128 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 256 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 384 kbps",
|
||||
"Guild can have a banner",
|
||||
"Guild has the in-app one-click emoji clone shortcut disabled for non-members",
|
||||
"Guild has the in-app one-click sticker clone shortcut disabled for non-members",
|
||||
@@ -28098,7 +28104,7 @@
|
||||
"Guild has large guild overrides enabled",
|
||||
"Guild has increased member capacity enabled"
|
||||
],
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
},
|
||||
"ChannelPartialResponse": {
|
||||
"type": "object",
|
||||
@@ -30329,8 +30335,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30397,8 +30403,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30465,8 +30471,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30533,8 +30539,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -32064,9 +32070,9 @@
|
||||
"description": "The amount the user is actually charged, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of the charged amount"
|
||||
},
|
||||
"billing_cycle": {
|
||||
@@ -32345,9 +32351,9 @@
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -32418,9 +32424,9 @@
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -32622,13 +32628,9 @@
|
||||
"localized": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
|
||||
"description": "Localized checkout prices resolved from mirrored billing data"
|
||||
},
|
||||
"base": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
|
||||
"description": "Standard USD/EUR checkout prices resolved from mirrored billing data"
|
||||
}
|
||||
},
|
||||
"required": ["country_code", "localized", "base"]
|
||||
"required": ["country_code", "localized"]
|
||||
},
|
||||
"PriceIdsResponse": {
|
||||
"type": "object",
|
||||
@@ -32666,15 +32668,15 @@
|
||||
"description": "Gift 1 year price amount in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for the prices"
|
||||
},
|
||||
"gift_currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for gift prices"
|
||||
}
|
||||
},
|
||||
@@ -32687,7 +32689,6 @@
|
||||
},
|
||||
"required": ["disabled"]
|
||||
},
|
||||
"PricingModeEnum": {"enum": ["localized", "base"], "type": "string", "x-enumNames": ["localized", "base"]},
|
||||
"SelfServeRefundResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32754,9 +32755,9 @@
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
@@ -32793,9 +32794,9 @@
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
@@ -34016,7 +34017,10 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"price_id": {"type": "string", "description": "The Stripe price ID for the subscription plan"},
|
||||
"country_code": {"type": "string", "description": "Two-letter country code used for regional pricing"},
|
||||
"country_code": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code used for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
},
|
||||
"client_geoip_country_code": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code observed by the client GeoIP store before checkout"
|
||||
@@ -34025,7 +34029,6 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the EU/EEA digital content withdrawal waiver was expressly accepted before checkout"
|
||||
},
|
||||
"pricing_mode": {"$ref": "#/components/schemas/PricingModeEnum"},
|
||||
"payment_method": {"$ref": "#/components/schemas/CheckoutPaymentMethodEnum"},
|
||||
"is_business": {
|
||||
"type": "boolean",
|
||||
@@ -36836,7 +36839,7 @@
|
||||
"description": "Controls who sees the full profile: all guild members, only small-guild members, or only friends"
|
||||
},
|
||||
"synced_preferences": {
|
||||
"anyOf": [{"type": "string", "maxLength": 349528}, {"type": "null"}],
|
||||
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}],
|
||||
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoApp} from '../types/HonoEnv';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {Validator} from '../Validator';
|
||||
|
||||
export function PremiumController(app: HonoApp) {
|
||||
@@ -32,7 +33,8 @@ export function PremiumController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const {country_code} = ctx.req.valid('query');
|
||||
const state = await ctx.get('stripeService').getPremiumState(userId, country_code);
|
||||
const geoip = await lookupGeoip(ctx.req.raw);
|
||||
const state = await ctx.get('stripeService').getPremiumState(userId, geoip.countryCode ?? country_code);
|
||||
return ctx.json(state);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -253,7 +253,7 @@ export class GlobalSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
MessageResponse,
|
||||
MessageSearchResultsResponse,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserID} from '../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../BrandedTypes';
|
||||
import {createChannelID} from '../BrandedTypes';
|
||||
import {mapChannelToResponse} from '../channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '../channel/IChannelRepository';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
messageResponseAccessForChannel,
|
||||
} from '../channel/services/message/MessageResponseDataService';
|
||||
import {createMessageResponseDataService} from '../channel/services/message/MessageResponseDataService';
|
||||
import type {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../models/Channel';
|
||||
import type {Message} from '../models/Message';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
|
||||
const CHANNEL_LOOKUP_CONCURRENCY = 16;
|
||||
|
||||
export class MessageSearchResponseMapper {
|
||||
constructor(
|
||||
@@ -23,71 +21,46 @@ export class MessageSearchResponseMapper {
|
||||
) {}
|
||||
|
||||
async mapSearchResultToResponses(
|
||||
result: {
|
||||
hits: Array<{
|
||||
channelId: string;
|
||||
id: string;
|
||||
}>;
|
||||
total: number;
|
||||
},
|
||||
messages: Array<Message>,
|
||||
userId: UserID,
|
||||
requestCache: RequestCache,
|
||||
): Promise<{
|
||||
messages: Array<MessageSearchResultsResponse['messages'][number]>;
|
||||
channels: Array<MessageSearchResultsResponse['channels'][number]>;
|
||||
}> {
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const orderedChannelIds = new Set<string>();
|
||||
for (const entry of messageEntries) {
|
||||
orderedChannelIds.add(entry.channelId.toString());
|
||||
}
|
||||
const channels = await Promise.all(
|
||||
Array.from(orderedChannelIds).map((channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
),
|
||||
const orderedChannelIds = Array.from(new Set(messages.map((message) => message.channelId.toString())));
|
||||
const channels = await mapWithConcurrency(orderedChannelIds, CHANNEL_LOOKUP_CONCURRENCY, (channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
);
|
||||
const validChannels = channels.filter((channel): channel is Channel => channel !== null);
|
||||
const channelById = new Map(validChannels.map((channel) => [channel.id.toString(), channel] as const));
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const messageResponsesWithEntries = await Promise.all(
|
||||
messageEntries.map(async (entry) => {
|
||||
const channel = channelById.get(entry.channelId.toString());
|
||||
if (!channel) return null;
|
||||
const message = await responseDataService.getMessage({
|
||||
userId,
|
||||
channelId: entry.channelId,
|
||||
messageId: entry.messageId,
|
||||
access: messageResponseAccessForChannel(channel),
|
||||
});
|
||||
return message ? {message, channelId: entry.channelId.toString()} : null;
|
||||
}),
|
||||
const channelById = new Map(
|
||||
channels
|
||||
.filter((channel): channel is Channel => channel !== null)
|
||||
.map((channel) => [channel.id.toString(), channel] as const),
|
||||
);
|
||||
const validMessageResponseEntries = messageResponsesWithEntries.filter(
|
||||
(entry): entry is {message: MessageResponse; channelId: string} => entry !== null,
|
||||
);
|
||||
const messageResponses = validMessageResponseEntries.map((entry) => {
|
||||
const {referenced_message: _referencedMessage, ...searchMessage} = entry.message;
|
||||
return searchMessage;
|
||||
const renderableMessages = messages.filter((message) => channelById.has(message.channelId.toString()));
|
||||
const messageResponses = await createMessageResponseDataService().buildMessagesForChannels({
|
||||
userId,
|
||||
messages: renderableMessages,
|
||||
channelById,
|
||||
});
|
||||
const orderedResponseChannelIds = new Set(validMessageResponseEntries.map((entry) => entry.channelId));
|
||||
const orderedChannels = Array.from(orderedResponseChannelIds)
|
||||
const searchMessages = messageResponses.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const respondedChannelIds = new Set(searchMessages.map((message) => message.channel_id));
|
||||
const orderedChannels = orderedChannelIds
|
||||
.filter((channelId) => respondedChannelIds.has(channelId))
|
||||
.map((channelId) => channelById.get(channelId))
|
||||
.filter((channel): channel is Channel => channel !== undefined);
|
||||
const channelResponses = await Promise.all(
|
||||
orderedChannels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
const channelResponses = await mapWithConcurrency(orderedChannels, CHANNEL_LOOKUP_CONCURRENCY, (channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
messages: messageResponses,
|
||||
messages: searchMessages,
|
||||
channels: channelResponses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {type ChannelID, createChannelID, createMessageID, type MessageID} from '
|
||||
import type {IMessageRepository} from '../channel/repositories/IMessageRepository';
|
||||
import {Logger} from '../Logger';
|
||||
import type {Message} from '../models/Message';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
import type {IMessageSearchService} from './IMessageSearchService';
|
||||
import {deleteMessageSearchDocuments} from './MessageSearchIndexCleanup';
|
||||
|
||||
@@ -13,11 +14,16 @@ const RECONCILE_BATCH_SIZE = 250;
|
||||
const MAX_RECONCILE_PAGES = 40;
|
||||
const MAX_STALE_DELETE_ABSOLUTE = 250;
|
||||
const MAX_STALE_DELETE_RATIO = 0.5;
|
||||
const HIT_LOOKUP_CONCURRENCY = 32;
|
||||
|
||||
interface MessageLookupRepository {
|
||||
readonly messages: Pick<IMessageRepository, 'getMessage'>;
|
||||
}
|
||||
|
||||
interface MessageSearchLookupResult extends SearchResult<SearchableMessage> {
|
||||
messages: Array<Message>;
|
||||
}
|
||||
|
||||
interface SearchExistingMessagesParams {
|
||||
searchService: IMessageSearchService;
|
||||
messageRepository: MessageLookupRepository;
|
||||
@@ -28,12 +34,21 @@ interface SearchExistingMessagesParams {
|
||||
cursor?: Array<string>;
|
||||
}
|
||||
|
||||
interface ValidatedHit {
|
||||
hit: SearchableMessage;
|
||||
message: Message | null;
|
||||
}
|
||||
|
||||
interface ValidatedHits {
|
||||
validHits: Array<SearchableMessage>;
|
||||
validHits: Array<ValidatedHit>;
|
||||
staleMessageIds: Array<MessageID>;
|
||||
lookupErrorCount: number;
|
||||
}
|
||||
|
||||
function resolvedMessages(validHits: Array<ValidatedHit>): Array<Message> {
|
||||
return validHits.map((entry) => entry.message).filter((message): message is Message => message !== null);
|
||||
}
|
||||
|
||||
export async function searchExistingMessages({
|
||||
searchService,
|
||||
messageRepository,
|
||||
@@ -42,7 +57,7 @@ export async function searchExistingMessages({
|
||||
hitsPerPage,
|
||||
page,
|
||||
cursor,
|
||||
}: SearchExistingMessagesParams): Promise<SearchResult<SearchableMessage>> {
|
||||
}: SearchExistingMessagesParams): Promise<MessageSearchLookupResult> {
|
||||
const result = await searchService.searchMessages(query, filters, {
|
||||
hitsPerPage,
|
||||
page: cursor?.length ? undefined : page,
|
||||
@@ -50,7 +65,7 @@ export async function searchExistingMessages({
|
||||
});
|
||||
const validated = await validateSearchHits(messageRepository, result.hits);
|
||||
if (validated.staleMessageIds.length === 0) {
|
||||
return result;
|
||||
return {...result, messages: resolvedMessages(validated.validHits)};
|
||||
}
|
||||
if (cursor?.length) {
|
||||
if (validated.lookupErrorCount === 0) {
|
||||
@@ -58,8 +73,9 @@ export async function searchExistingMessages({
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
hits: validated.validHits,
|
||||
hits: validated.validHits.map((entry) => entry.hit),
|
||||
total: Math.max(validated.validHits.length, result.total - validated.staleMessageIds.length),
|
||||
messages: resolvedMessages(validated.validHits),
|
||||
};
|
||||
}
|
||||
return reconcileOffsetSearchResult({
|
||||
@@ -79,9 +95,9 @@ async function reconcileOffsetSearchResult({
|
||||
filters,
|
||||
hitsPerPage,
|
||||
page,
|
||||
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<SearchResult<SearchableMessage>> {
|
||||
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<MessageSearchLookupResult> {
|
||||
const requestedOffset = (page - 1) * hitsPerPage;
|
||||
const pageHits: Array<SearchableMessage> = [];
|
||||
const pageHits: Array<ValidatedHit> = [];
|
||||
const staleMessageIds: Array<MessageID> = [];
|
||||
let lookupErrorCount = 0;
|
||||
let examinedCount = 0;
|
||||
@@ -102,9 +118,9 @@ async function reconcileOffsetSearchResult({
|
||||
lookupErrorCount += validated.lookupErrorCount;
|
||||
examinedCount += result.hits.length;
|
||||
staleMessageIds.push(...validated.staleMessageIds);
|
||||
for (const hit of validated.validHits) {
|
||||
for (const entry of validated.validHits) {
|
||||
if (validTotal >= requestedOffset && pageHits.length < hitsPerPage) {
|
||||
pageHits.push(hit);
|
||||
pageHits.push(entry);
|
||||
}
|
||||
validTotal += 1;
|
||||
}
|
||||
@@ -121,8 +137,9 @@ async function reconcileOffsetSearchResult({
|
||||
await deleteStaleSearchDocuments(searchService, staleMessageIds, examinedCount);
|
||||
}
|
||||
return {
|
||||
hits: pageHits,
|
||||
hits: pageHits.map((entry) => entry.hit),
|
||||
total: Math.max(pageHits.length, corpusTotal - staleMessageIds.length),
|
||||
messages: resolvedMessages(pageHits),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,38 +147,36 @@ async function validateSearchHits(
|
||||
messageRepository: MessageLookupRepository,
|
||||
hits: Array<SearchableMessage>,
|
||||
): Promise<ValidatedHits> {
|
||||
const checked = await Promise.all(
|
||||
hits.map(async (hit) => {
|
||||
let channelId: ChannelID;
|
||||
let messageId: MessageID;
|
||||
try {
|
||||
channelId = createChannelID(BigInt(hit.channelId));
|
||||
messageId = createMessageID(BigInt(hit.id));
|
||||
} catch (_invalidId) {
|
||||
return {hit: null, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
let message: Message | null;
|
||||
try {
|
||||
message = await messageRepository.messages.getMessage(channelId, messageId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, messageId: hit.id, channelId: hit.channelId},
|
||||
'Search read repair lookup failed; keeping document',
|
||||
);
|
||||
return {hit, staleMessageId: null, lookupError: true};
|
||||
}
|
||||
if (message && message.channelId.toString() === hit.channelId) {
|
||||
return {hit, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
return {hit: null, staleMessageId: messageId, lookupError: false};
|
||||
}),
|
||||
);
|
||||
const validHits: Array<SearchableMessage> = [];
|
||||
const checked = await mapWithConcurrency(hits, HIT_LOOKUP_CONCURRENCY, async (hit) => {
|
||||
let channelId: ChannelID;
|
||||
let messageId: MessageID;
|
||||
try {
|
||||
channelId = createChannelID(BigInt(hit.channelId));
|
||||
messageId = createMessageID(BigInt(hit.id));
|
||||
} catch (_invalidId) {
|
||||
return {entry: null, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
let message: Message | null;
|
||||
try {
|
||||
message = await messageRepository.messages.getMessage(channelId, messageId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, messageId: hit.id, channelId: hit.channelId},
|
||||
'Search read repair lookup failed; keeping document',
|
||||
);
|
||||
return {entry: {hit, message: null}, staleMessageId: null, lookupError: true};
|
||||
}
|
||||
if (message && message.channelId.toString() === hit.channelId) {
|
||||
return {entry: {hit, message}, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
return {entry: null, staleMessageId: messageId, lookupError: false};
|
||||
});
|
||||
const validHits: Array<ValidatedHit> = [];
|
||||
const staleMessageIds: Array<MessageID> = [];
|
||||
let lookupErrorCount = 0;
|
||||
for (const item of checked) {
|
||||
if (item.hit) {
|
||||
validHits.push(item.hit);
|
||||
if (item.entry) {
|
||||
validHits.push(item.entry);
|
||||
}
|
||||
if (item.staleMessageId) {
|
||||
staleMessageIds.push(item.staleMessageId);
|
||||
|
||||
@@ -56,8 +56,11 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
|
||||
usd: 'USD',
|
||||
eur: 'EUR',
|
||||
brl: 'BRL',
|
||||
dkk: 'DKK',
|
||||
inr: 'INR',
|
||||
nok: 'NOK',
|
||||
pln: 'PLN',
|
||||
sek: 'SEK',
|
||||
try: 'TRY',
|
||||
};
|
||||
|
||||
@@ -109,6 +112,14 @@ export class ProductRegistry {
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyDkk, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyInr, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -117,6 +128,14 @@ export class ProductRegistry {
|
||||
currency: 'INR',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyNok, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyPln, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -125,6 +144,14 @@ export class ProductRegistry {
|
||||
currency: 'PLN',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlySek, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyTry, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -157,6 +184,14 @@ export class ProductRegistry {
|
||||
currency: 'BRL',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyDkk, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyInr, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -165,6 +200,14 @@ export class ProductRegistry {
|
||||
currency: 'INR',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyNok, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyPln, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -173,6 +216,14 @@ export class ProductRegistry {
|
||||
currency: 'PLN',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlySek, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyTry, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -209,6 +260,48 @@ export class ProductRegistry {
|
||||
isGift: true,
|
||||
currency: 'INR',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthDkk, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearDkk, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthNok, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearNok, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthSek, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearSek, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthPln, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -319,12 +412,21 @@ export class ProductRegistry {
|
||||
if (normalizedCurrency === 'brl') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyBrl ?? null) : (prices.yearlyBrl ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyDkk ?? null) : (prices.yearlyDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyInr ?? null) : (prices.yearlyInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyNok ?? null) : (prices.yearlyNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyPln ?? null) : (prices.yearlyPln ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlySek ?? null) : (prices.yearlySek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'try') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyTry ?? null) : (prices.yearlyTry ?? null);
|
||||
}
|
||||
@@ -349,6 +451,15 @@ export class ProductRegistry {
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthInr ?? null) : (prices.gift1YearInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthDkk ?? null) : (prices.gift1YearDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthNok ?? null) : (prices.gift1YearNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthSek ?? null) : (prices.gift1YearSek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthPln ?? null) : (prices.gift1YearPln ?? null);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
-25
@@ -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
|
||||
|
||||
-53
@@ -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;
|
||||
}
|
||||
-490
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
-1
@@ -7,7 +7,6 @@
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
-24
@@ -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 ? (
|
||||
|
||||
-6
@@ -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;
|
||||
|
||||
+1
-7
@@ -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"
|
||||
|
||||
+1
-9
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
+7
-29
@@ -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]';
|
||||
|
||||
@@ -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`]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
+13
-6
@@ -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
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+14
-13
@@ -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);
|
||||
|
||||
+13
-3
@@ -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> {
|
||||
|
||||
-50
@@ -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;
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import styles from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal.module.css';
|
||||
import FavoriteGif from '@app/features/expressions/state/FavoriteGif';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {formatUserSettingsPath} from '@app/features/user/components/settings_utils/SettingsConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useRef} from 'react';
|
||||
|
||||
const HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR = msg({
|
||||
message: 'How should we save your GIF favorites?',
|
||||
comment: 'Confirmation prompt in the channel and chat favorite gif first time prompt modal.',
|
||||
});
|
||||
|
||||
interface FavoriteGifFirstTimePromptModalProps {
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const FavoriteGifFirstTimePromptModal = observer(function FavoriteGifFirstTimePromptModal({
|
||||
onConfirm,
|
||||
}: FavoriteGifFirstTimePromptModalProps) {
|
||||
const {i18n} = useLingui();
|
||||
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const mediaSettingsPath = formatUserSettingsPath(i18n, 'chat_settings', 'media');
|
||||
const handleConfirm = () => {
|
||||
FavoriteGif.setSaveGifFavoritesAsSavedMedia(false);
|
||||
FavoriteGif.markFirstTimePromptSeen();
|
||||
ModalCommands.pop();
|
||||
onConfirm();
|
||||
};
|
||||
const handleUseSavedMedia = () => {
|
||||
FavoriteGif.setSaveGifFavoritesAsSavedMedia(true);
|
||||
FavoriteGif.markFirstTimePromptSeen();
|
||||
ModalCommands.pop();
|
||||
onConfirm();
|
||||
};
|
||||
const handleCancel = () => {
|
||||
ModalCommands.pop();
|
||||
};
|
||||
return (
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
initialFocusRef={initialFocusRef}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-root"
|
||||
>
|
||||
<Modal.Header
|
||||
title={i18n._(HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR)}
|
||||
onClose={handleCancel}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-header"
|
||||
/>
|
||||
<Modal.Content data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-content">
|
||||
<div className={styles.body} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.body">
|
||||
<p
|
||||
className={styles.description}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.description"
|
||||
>
|
||||
<Trans>
|
||||
You can store starred GIFs as URL-only favorites or upload them to your saved media. Pick the one that
|
||||
fits how you use them. You can change it any time in {mediaSettingsPath}.
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className={styles.list} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list">
|
||||
<li
|
||||
className={styles.listItem}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item"
|
||||
>
|
||||
<Trans>
|
||||
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong">
|
||||
URL-only favorites (default)
|
||||
</strong>
|
||||
: synced across your devices, no upload, doesn't count against saved media. The original media may
|
||||
disappear if its host removes it.
|
||||
</Trans>
|
||||
</li>
|
||||
<li
|
||||
className={styles.listItem}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item--2"
|
||||
>
|
||||
<Trans>
|
||||
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong--2">
|
||||
Saved media
|
||||
</strong>
|
||||
: uploaded, taggable, searchable, and persistent, but counts against your saved media limit.
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
<p className={styles.hint} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.hint">
|
||||
<Trans>We'll only ask once.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-footer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUseSavedMedia}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.use-saved-media"
|
||||
>
|
||||
<Trans>Use saved media</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
ref={initialFocusRef}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.confirm"
|
||||
>
|
||||
<Trans>Use URL-only (recommended)</Trans>
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {type FavoriteGifEntry, slimFavoriteGifEntry} from './FavoriteGifTypes';
|
||||
|
||||
function format(src: string, width: number) {
|
||||
return {src, proxy_src: `https://media.test/external/sig/${src}`, width, height: Math.round(width * 0.84)};
|
||||
}
|
||||
|
||||
const FAT: FavoriteGifEntry = {
|
||||
url: 'https://klipy.com/gifs/reaction',
|
||||
proxy_url: 'https://media.test/external/sig/tiny.webm',
|
||||
width: 220,
|
||||
height: 185,
|
||||
media: {
|
||||
nanowebm: format('nano.webm', 90),
|
||||
tinywebm: format('tiny.webm', 220),
|
||||
mediumwebm: format('medium.webm', 320),
|
||||
webm: format('full.webm', 498),
|
||||
gif: format('full.gif', 498),
|
||||
},
|
||||
content_type: 'video/webm',
|
||||
placeholder: 'placeholder',
|
||||
};
|
||||
|
||||
describe('slimFavoriteGifEntry', () => {
|
||||
it('drops the media map so a favorite is cheap to sync', () => {
|
||||
expect(slimFavoriteGifEntry(FAT).media).toEqual({});
|
||||
});
|
||||
|
||||
it('promotes a preview that covers a 200px tile at 2x into the top-level fields', () => {
|
||||
const slim = slimFavoriteGifEntry(FAT);
|
||||
expect(slim.proxy_url).toBe(FAT.media.webm.proxy_src);
|
||||
expect(slim.width).toBe(498);
|
||||
expect(slim.content_type).toBe('video/webm');
|
||||
});
|
||||
|
||||
it('keeps url and placeholder untouched', () => {
|
||||
const slim = slimFavoriteGifEntry(FAT);
|
||||
expect(slim.url).toBe(FAT.url);
|
||||
expect(slim.placeholder).toBe(FAT.placeholder);
|
||||
});
|
||||
|
||||
it('is idempotent so the synced roundtrip stays stable', () => {
|
||||
const once = slimFavoriteGifEntry(FAT);
|
||||
expect(slimFavoriteGifEntry(once)).toEqual(once);
|
||||
});
|
||||
|
||||
it('leaves an entry that already carries no media alone', () => {
|
||||
const urlOnly: FavoriteGifEntry = {...FAT, media: {}};
|
||||
expect(slimFavoriteGifEntry(urlOnly)).toBe(urlOnly);
|
||||
});
|
||||
|
||||
it('does not invent a preview when every format is unusable', () => {
|
||||
const broken: FavoriteGifEntry = {...FAT, media: {webm: {src: '', proxy_src: '', width: 0, height: 0}}};
|
||||
const slim = slimFavoriteGifEntry(broken);
|
||||
expect(slim.media).toEqual({});
|
||||
expect(slim.proxy_url).toBe(FAT.proxy_url);
|
||||
});
|
||||
});
|
||||
@@ -105,4 +105,24 @@ export function pickCanonicalPreviewFormat(
|
||||
return null;
|
||||
}
|
||||
|
||||
export const STORED_PREVIEW_DEVICE_PIXEL_RATIO = 2;
|
||||
|
||||
export function slimFavoriteGifEntry(entry: FavoriteGifEntry): FavoriteGifEntry {
|
||||
const best = pickBestPreviewFormat(entry.media, 'any', {
|
||||
cssWidth: PREVIEW_TILE_CSS_WIDTH,
|
||||
devicePixelRatio: STORED_PREVIEW_DEVICE_PIXEL_RATIO,
|
||||
});
|
||||
if (best == null) {
|
||||
return Object.keys(entry.media).length === 0 ? entry : {...entry, media: {}};
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
proxy_url: best.format.proxy_src,
|
||||
width: best.format.width,
|
||||
height: best.format.height,
|
||||
content_type: inferFormatContentType(best.key),
|
||||
media: {},
|
||||
};
|
||||
}
|
||||
|
||||
export {inferFormatContentType};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {FavoriteGifEntry} from './FavoriteGifTypes';
|
||||
import {buildGifPickerGridData} from './GifPickerGridData';
|
||||
import type {FavoriteAwareGif} from './GifPickerTypes';
|
||||
|
||||
@@ -19,7 +20,84 @@ function gif(id: string, width = 200, height = 120): FavoriteAwareGif {
|
||||
};
|
||||
}
|
||||
|
||||
const OPAQUE_PROXY_URL =
|
||||
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
|
||||
|
||||
function slimFavorite(overrides: Partial<FavoriteGifEntry> = {}): FavoriteGifEntry {
|
||||
return {
|
||||
url: 'https://klipy.com/gifs/doc-brown-bttf',
|
||||
proxy_url: OPAQUE_PROXY_URL,
|
||||
width: 640,
|
||||
height: 360,
|
||||
media: {},
|
||||
content_type: 'video/webm',
|
||||
placeholder: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GifPickerGridData', () => {
|
||||
it('carries the stored content type of a slimmed favorite into its tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'favorites',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [slimFavorite()],
|
||||
favoriteMemes: [],
|
||||
useSavedMediaForGifFavorites: false,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({
|
||||
type: 'gif',
|
||||
gif: {proxy_src: OPAQUE_PROXY_URL, contentType: 'video/webm'},
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the stored content type of a slimmed favorite into the featured favorites tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [gif('featured')], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [slimFavorite()],
|
||||
favoriteMemes: [],
|
||||
useSavedMediaForGifFavorites: false,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({
|
||||
type: 'category',
|
||||
previewProxySrc: OPAQUE_PROXY_URL,
|
||||
previewContentType: 'video/webm',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the content type of the saved media standing in for an empty favorites tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [gif('featured')], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [],
|
||||
favoriteMemes: [{contentType: 'video/mp4', url: 'meme-0'}],
|
||||
useSavedMediaForGifFavorites: true,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({type: 'category', previewProxySrc: 'meme-0', previewContentType: 'video/mp4'});
|
||||
});
|
||||
|
||||
it('keeps featured data deterministic while choosing preview tiles', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type FavoriteGifEntry,
|
||||
inferFormatContentType,
|
||||
pickBestPreviewFormat,
|
||||
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
@@ -72,6 +73,7 @@ function buildFavoriteGifItems(
|
||||
const previewProxySrc = best?.format.proxy_src ?? fallbackSrc;
|
||||
const previewWidth = best?.format.width ?? entry.width;
|
||||
const previewHeight = best?.format.height ?? entry.height;
|
||||
const previewContentType = best ? inferFormatContentType(best.key) : entry.content_type;
|
||||
items.push({
|
||||
type: 'gif',
|
||||
key: entry.url,
|
||||
@@ -86,6 +88,7 @@ function buildFavoriteGifItems(
|
||||
width: previewWidth > 0 ? previewWidth : DEFAULT_GIF_SIZE,
|
||||
height: previewHeight > 0 ? previewHeight : DEFAULT_GIF_SIZE,
|
||||
media: entry.media,
|
||||
contentType: previewContentType,
|
||||
favoriteGifLookup: {url: entry.url},
|
||||
},
|
||||
});
|
||||
@@ -102,21 +105,21 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
|
||||
const favoriteGifPreviewEntry =
|
||||
favoriteGifPreviewIndex >= 0 ? input.favoriteGifs[favoriteGifPreviewIndex] : undefined;
|
||||
const favoriteGifPreview = pickBestPreviewFormat(favoriteGifPreviewEntry?.media);
|
||||
const favoriteMemePreview =
|
||||
gifvMemes.length > 0
|
||||
? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)]?.url ?? '')
|
||||
: '';
|
||||
const favoriteTilePreview =
|
||||
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
|
||||
? favoriteMemePreview
|
||||
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileProxyPreview =
|
||||
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
|
||||
? favoriteTilePreview
|
||||
: favoriteGifPreview?.format.proxy_src ||
|
||||
favoriteGifPreviewEntry?.proxy_url ||
|
||||
favoriteGifPreviewEntry?.url ||
|
||||
'';
|
||||
const favoriteMemeCandidate =
|
||||
gifvMemes.length > 0 ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)] ?? null) : null;
|
||||
const favoriteMemePreview = favoriteMemeCandidate?.url ?? '';
|
||||
const usesFavoriteMemePreview = input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0;
|
||||
const favoriteTilePreview = usesFavoriteMemePreview
|
||||
? favoriteMemePreview
|
||||
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileProxyPreview = usesFavoriteMemePreview
|
||||
? favoriteTilePreview
|
||||
: favoriteGifPreview?.format.proxy_src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileContentType = (() => {
|
||||
if (usesFavoriteMemePreview) return favoriteMemeCandidate?.contentType ?? '';
|
||||
if (favoriteGifPreview) return inferFormatContentType(favoriteGifPreview.key);
|
||||
return favoriteGifPreviewEntry?.proxy_url ? favoriteGifPreviewEntry.content_type : '';
|
||||
})();
|
||||
const favoritesTile: Array<GifPickerGridItemData> =
|
||||
(input.includeFavoritesTile ?? true)
|
||||
? [
|
||||
@@ -128,6 +131,7 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
|
||||
title: input.favoriteTitle,
|
||||
previewUrl: favoriteTilePreview,
|
||||
previewProxySrc: favoriteTileProxyPreview,
|
||||
previewContentType: favoriteTileContentType,
|
||||
width: CATEGORY_TILE_WIDTH,
|
||||
height: CATEGORY_TILE_HEIGHT,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import styles from '@app/features/channel/components/GifPicker.module.css';
|
||||
import {safePause, safePlay, useGifVideoPool} from '@app/features/channel/components/GifVideoPool';
|
||||
import {FavoriteGifFirstTimePromptModal} from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal';
|
||||
import {resolvesToVideo} from '@app/features/channel/components/pickers/gif/GifPickerMediaKind';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
import {PickerThumbnail} from '@app/features/channel/components/pickers/shared/PickerThumbnail';
|
||||
import {usePooledVideo} from '@app/features/channel/components/pickers/shared/usePooledVideo';
|
||||
@@ -24,7 +24,6 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {modal, push} from '@app/features/ui/commands/ModalCommands';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -54,30 +53,6 @@ const SELECT_GIF_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Button label in the gif picker grid item when picking a GIF for an avatar, banner, or video background. Keep it concise. Preserve {title}; it is inserted by code.',
|
||||
});
|
||||
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
|
||||
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
|
||||
|
||||
function testSourcePath(value: string, pattern: RegExp): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return pattern.test(url.pathname);
|
||||
} catch {
|
||||
return pattern.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoSourceUrl(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function statesItsMediaKind(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function resolvesToVideo(proxySrc: string, mediaSourceUrl: string | null): boolean {
|
||||
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
|
||||
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
|
||||
}
|
||||
|
||||
export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
item,
|
||||
@@ -128,6 +103,11 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
if (item.type === 'category') return item.previewUrl;
|
||||
return null;
|
||||
})();
|
||||
const mediaContentType = (() => {
|
||||
if (item.type === 'gif') return item.gif.contentType ?? '';
|
||||
if (item.type === 'category') return item.previewContentType ?? '';
|
||||
return '';
|
||||
})();
|
||||
const thumbnailPlaceholder = (() => {
|
||||
if (item.type !== 'gif') return null;
|
||||
if (item.gif.placeholder) return item.gif.placeholder;
|
||||
@@ -135,7 +115,10 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
return FavoriteGif.findByUrl(lookupUrl)?.placeholder ?? null;
|
||||
})();
|
||||
const usesVideoElement =
|
||||
!isSkeleton && proxySrc !== null && proxySrc.length > 0 && resolvesToVideo(proxySrc, mediaSourceUrl);
|
||||
!isSkeleton &&
|
||||
proxySrc !== null &&
|
||||
proxySrc.length > 0 &&
|
||||
resolvesToVideo(mediaContentType, proxySrc, mediaSourceUrl);
|
||||
const videoThumbHashURL = decodeThumbHashDataURL(usesVideoElement ? thumbnailPlaceholder : null);
|
||||
const hasThumbnailContent = (proxySrc !== null && proxySrc.length > 0) || thumbnailPlaceholder !== null;
|
||||
const videoRef = usePooledVideo({
|
||||
@@ -400,17 +383,6 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isFavoritePending) return;
|
||||
if (!FavoriteGif.hasSeenFavoriteGifFirstTimePrompt && !isFavorited) {
|
||||
push(
|
||||
modal(() => (
|
||||
<FavoriteGifFirstTimePromptModal
|
||||
onConfirm={() => void performFavoriteToggle()}
|
||||
data-flx="channel.pickers.gif.gif-picker-grid-item.handle-favorite-click.favorite-gif-first-time-prompt-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void performFavoriteToggle();
|
||||
};
|
||||
const favoriteTooltipText = (() => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {resolvesToVideo} from './GifPickerMediaKind';
|
||||
|
||||
const OPAQUE_PROXY_SRC =
|
||||
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
|
||||
const PLAIN_PROXY_SRC = 'https://media.test/external/sig/https/static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm';
|
||||
|
||||
describe('resolvesToVideo', () => {
|
||||
it('trusts a stored video content type when no url states its kind', () => {
|
||||
expect(resolvesToVideo('video/webm', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts a stored image content type when no url states its kind', () => {
|
||||
expect(resolvesToVideo('image/gif', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to an image when nothing states the kind', () => {
|
||||
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the extension off a plain proxy path', () => {
|
||||
expect(resolvesToVideo('', PLAIN_PROXY_SRC, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the extension off the media source when the proxy path is opaque', () => {
|
||||
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, 'https://static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps images as images when the proxy path states its kind', () => {
|
||||
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.gif', null)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a query string when reading the extension', () => {
|
||||
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.mp4?width=200', null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
|
||||
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
|
||||
|
||||
function testSourcePath(value: string, pattern: RegExp): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return pattern.test(url.pathname);
|
||||
} catch {
|
||||
return pattern.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoSourceUrl(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function statesItsMediaKind(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
export function resolvesToVideo(contentType: string, proxySrc: string, mediaSourceUrl: string | null): boolean {
|
||||
if (contentType.startsWith('video/')) return true;
|
||||
if (contentType.startsWith('image/')) return false;
|
||||
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
|
||||
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface FavoriteGifLookup {
|
||||
|
||||
export type FavoriteAwareGif = Gif & {
|
||||
favoriteGifLookup?: FavoriteGifLookup;
|
||||
contentType?: string;
|
||||
};
|
||||
export type GifPickerGridItemData =
|
||||
| {
|
||||
@@ -20,6 +21,7 @@ export type GifPickerGridItemData =
|
||||
categoryKind: 'favorites' | 'trending' | 'category';
|
||||
previewUrl: string;
|
||||
previewProxySrc: string;
|
||||
previewContentType?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import ContextMenuState from '@app/features/ui/state/ContextMenu';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical';
|
||||
import {$addUpdateTag, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, PASTE_TAG} from 'lexical';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect} from 'react';
|
||||
|
||||
@@ -233,6 +233,7 @@ export function useChannelComposerPaste({
|
||||
if (!insertPastedText(pastedText)) {
|
||||
return false;
|
||||
}
|
||||
$addUpdateTag(PASTE_TAG);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {selectChannel} from '@app/features/navigation/commands/NavigationCommands';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
export interface FormInputs {
|
||||
name: string;
|
||||
@@ -52,7 +55,7 @@ export async function createChannel(guildId: string, data: FormInputs, parentId?
|
||||
url: data.url,
|
||||
type: channelType,
|
||||
parent_id: parentId || null,
|
||||
bitrate: channelType === ChannelTypes.GUILD_VOICE ? 64000 : null,
|
||||
bitrate: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_BITRATE_DEFAULT : null,
|
||||
user_limit: channelType === ChannelTypes.GUILD_VOICE ? 0 : null,
|
||||
voice_connection_limit: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
FavoriteGifEntry,
|
||||
FavoriteGifMediaFormat,
|
||||
import {
|
||||
type FavoriteGifEntry,
|
||||
type FavoriteGifMediaFormat,
|
||||
slimFavoriteGifEntry,
|
||||
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
|
||||
import {makeSyncedField} from '@app/features/user/state/SyncedField';
|
||||
import {FAVORITE_GIF_MAX_ENCODED_BYTES} from '@app/features/user/state/SyncedFieldBudget';
|
||||
import type {FavoriteGifMediaFormat as FavoriteGifMediaFormatProto} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
|
||||
import {FavoriteGifSettingsSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
@@ -52,8 +54,9 @@ class FavoriteGif {
|
||||
field: 'favoriteGifs',
|
||||
schema: FavoriteGifSettingsSchema,
|
||||
persist: ['favoriteGifs', 'saveGifFavoritesAsSavedMedia', 'hasSeenFavoriteGifFirstTimePrompt'],
|
||||
maxEncodedBytes: FAVORITE_GIF_MAX_ENCODED_BYTES,
|
||||
toMessage: (s) => ({
|
||||
entries: s.favoriteGifs.map((entry) => ({
|
||||
entries: s.favoriteGifs.map(slimFavoriteGifEntry).map((entry) => ({
|
||||
url: entry.url,
|
||||
proxyUrl: entry.proxy_url,
|
||||
width: entry.width,
|
||||
@@ -66,15 +69,17 @@ class FavoriteGif {
|
||||
seenFirstTimePrompt: s.hasSeenFavoriteGifFirstTimePrompt,
|
||||
}),
|
||||
applyMessage: (s, m) => {
|
||||
s.favoriteGifs = m.entries.map((entry) => ({
|
||||
url: entry.url,
|
||||
proxy_url: entry.proxyUrl,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
media: mediaFromProto(entry.media),
|
||||
content_type: entry.contentType,
|
||||
placeholder: entry.placeholder ? entry.placeholder : null,
|
||||
}));
|
||||
s.favoriteGifs = m.entries.map((entry) =>
|
||||
slimFavoriteGifEntry({
|
||||
url: entry.url,
|
||||
proxy_url: entry.proxyUrl,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
media: mediaFromProto(entry.media),
|
||||
content_type: entry.contentType,
|
||||
placeholder: entry.placeholder ? entry.placeholder : null,
|
||||
}),
|
||||
);
|
||||
s.saveGifFavoritesAsSavedMedia = m.saveAsSavedMedia;
|
||||
s.hasSeenFavoriteGifFirstTimePrompt = m.seenFirstTimePrompt;
|
||||
},
|
||||
@@ -110,10 +115,6 @@ class FavoriteGif {
|
||||
setSaveGifFavoritesAsSavedMedia(value: boolean): void {
|
||||
this.saveGifFavoritesAsSavedMedia = value;
|
||||
}
|
||||
|
||||
markFirstTimePromptSeen(): void {
|
||||
this.hasSeenFavoriteGifFirstTimePrompt = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new FavoriteGif();
|
||||
|
||||
@@ -143,6 +143,21 @@ const VERIFIED_GUILD_DESCRIPTOR = msg({
|
||||
message: 'Verified community',
|
||||
comment: 'Audit log community feature label for verified communities.',
|
||||
});
|
||||
const AUDIO_BITRATE_128_KBPS_DESCRIPTOR = msg({
|
||||
message: '128 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const AUDIO_BITRATE_256_KBPS_DESCRIPTOR = msg({
|
||||
message: '256 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const AUDIO_BITRATE_384_KBPS_DESCRIPTOR = msg({
|
||||
message: '384 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const VIP_VOICE_DESCRIPTOR = msg({
|
||||
message: 'VIP voice',
|
||||
comment: 'Audit log community feature label for elevated voice capacity/quality.',
|
||||
@@ -404,6 +419,9 @@ export function getSplashAlignmentLabel(value: unknown, i18n: I18n): string | nu
|
||||
const featureLabelMap: Record<string, MessageDescriptor> = {
|
||||
[GuildFeatures.ANIMATED_ICON]: ANIMATED_ICON_DESCRIPTOR,
|
||||
[GuildFeatures.ANIMATED_BANNER]: ANIMATED_BANNER_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_128_KBPS]: AUDIO_BITRATE_128_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_256_KBPS]: AUDIO_BITRATE_256_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_384_KBPS]: AUDIO_BITRATE_384_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.BANNER]: BANNER_DESCRIPTOR,
|
||||
[GuildFeatures.CLONE_EMOJI_DISABLED]: EMOJI_CLONING_DISABLED_DESCRIPTOR,
|
||||
[GuildFeatures.CLONE_STICKER_DISABLED]: STICKER_CLONING_DISABLED_DESCRIPTOR,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user