mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
258fe6f742 | ||
|
|
cfa20b7093 | ||
|
|
569146c5bc |
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
@@ -103,16 +110,8 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -265,6 +264,45 @@ 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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,90 @@ describe('VoiceAdminController', () => {
|
||||
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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}],
|
||||
|
||||
+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
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -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,6 +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 {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';
|
||||
@@ -52,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,
|
||||
@@ -126,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;
|
||||
@@ -133,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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
|
||||
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {getVoiceContextEntranceSoundScope} from '@app/features/notification/utils/EntranceSoundScopes';
|
||||
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
||||
@@ -117,6 +118,7 @@ import {
|
||||
getActiveVoiceProcessingMode,
|
||||
resolveVoiceProcessingFromStateForDeviceLabel,
|
||||
} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
import {resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {
|
||||
VoiceEngineV2AudioControls,
|
||||
VoiceEngineV2AudioMode,
|
||||
@@ -606,7 +608,9 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
||||
}
|
||||
|
||||
private getMicrophonePublishOptions(channelId: string | null): TrackPublishOptions | undefined {
|
||||
const channelBitrate = channelId ? Channels.getChannel(channelId)?.bitrate : null;
|
||||
const channel = channelId ? Channels.getChannel(channelId) : null;
|
||||
const guild = channel?.guildId ? Guilds.getGuild(channel.guildId) : null;
|
||||
const channelBitrate = resolveVoiceChannelBitrate(channel?.bitrate, guild?.features);
|
||||
const profile = resolveVoiceProcessingFromStateForDeviceLabel(VoiceSettings, this.resolveActiveInputDeviceLabel());
|
||||
return buildMicrophonePublishOptions(channelBitrate, profile.mode);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
buildMicrophonePublishOptions,
|
||||
@@ -51,6 +52,15 @@ describe('buildMicrophonePublishOptions', () => {
|
||||
red: true,
|
||||
});
|
||||
});
|
||||
it('pins a call outside a guild to 64 kbps', () => {
|
||||
expect(buildMicrophonePublishOptions(resolveVoiceChannelBitrate(null, null), 'voice')).toEqual({
|
||||
audioPreset: {
|
||||
maxBitrate: 64000,
|
||||
priority: 'high',
|
||||
},
|
||||
red: true,
|
||||
});
|
||||
});
|
||||
it('does not force stereo below the stereo voice threshold', () => {
|
||||
expect(buildMicrophonePublishOptions(96000, 'studio')).toEqual({
|
||||
audioPreset: {
|
||||
|
||||
@@ -370,7 +370,7 @@ Every field is optional, and an omitted field preserves its current value. The f
|
||||
| topic?<sup>2</sup> | ?string | The topic of the channel (1-1,024 characters), or null to clear it |
|
||||
| url?<sup>3</sup> | ?string | The destination of a guild link channel (1-2,048 characters, `http` or `https`), or null to clear it |
|
||||
| parent_id?<sup>4</sup> | ?snowflake | The ID of the parent category, where null or the snowflake `0` moves the channel to the top level |
|
||||
| bitrate?<sup>5</sup> | ?integer | The voice bitrate in bits per second (8,000-320,000) |
|
||||
| bitrate?<sup>5</sup><sup>13</sup> | ?integer | The voice bitrate in bits per second (8,000-384,000) |
|
||||
| user_limit?<sup>5</sup> | ?integer | The voice occupancy limit (0-99, where `0` configures no limit) |
|
||||
| voice_connection_limit?<sup>5</sup> | ?integer | The number of simultaneous voice connections one user may hold (1-100) |
|
||||
| permission_overwrites?<sup>6</sup> | array[[permission overwrite](#permission-overwrite-object) object] | The complete overwrite collection that replaces the stored one |
|
||||
@@ -405,6 +405,8 @@ Every field is optional, and an omitted field preserves its current value. The f
|
||||
|
||||
<sup>12</sup> Applied only to a guild voice channel. Supplying the field at all, including as null, requires [UPDATE_RTC_REGION](/http-api/permissions/)
|
||||
|
||||
<sup>13</sup> The stored value is capped at 96,000 unless the guild holds an [audio bitrate feature](/http-api/guilds/#guild-features). A higher value is stored at the cap rather than rejected
|
||||
|
||||
A `url` that is not an absolute `http` or `https` URL with a host returns 400 `INVALID_FORM_BODY` with the code `INVALID_URL_FORMAT` on the path `url`.
|
||||
|
||||
A parent that does not exist in the same guild returns 400 `INVALID_FORM_BODY` with the code `INVALID_PARENT_CHANNEL` on the path `parent_id`. A parent that is not a category returns `PARENT_MUST_BE_CATEGORY` the same way.
|
||||
|
||||
@@ -157,7 +157,7 @@ Creates a guild channel and returns its [channel object](/http-api/channels/#cha
|
||||
| topic? | ?string | Channel topic (1-1024 characters) |
|
||||
| url?<sup>3</sup> | ?string | Destination URL for a link channel |
|
||||
| parent_id?<sup>4</sup> | ?snowflake | Parent category |
|
||||
| bitrate?<sup>5</sup> | ?integer | Voice channel bitrate in bits per second (8000-320000, default 64000) |
|
||||
| bitrate?<sup>5</sup><sup>11</sup> | ?integer | Voice channel bitrate in bits per second (8000-384000, default 64000) |
|
||||
| user_limit?<sup>5</sup><sup>6</sup> | ?integer | Voice channel occupancy limit (0-99, default 0) |
|
||||
| voice_connection_limit?<sup>5</sup> | ?integer | Simultaneous voice connections permitted for one user in a voice channel (1-100, default 5) |
|
||||
| rate_limit_per_user? | ?integer | Slowmode interval in seconds (0-21600, default 0) |
|
||||
@@ -187,6 +187,8 @@ Creates a guild channel and returns its [channel object](/http-api/channels/#cha
|
||||
|
||||
<sup>10</sup> The value is trimmed, and an empty result is stored as null
|
||||
|
||||
<sup>11</sup> The stored value is capped at 96000 unless the guild holds an [audio bitrate feature](/http-api/guilds/#guild-features). A higher value is stored at the cap rather than rejected
|
||||
|
||||
A value longer than 10000 characters is rejected with `STRING_LENGTH_INVALID` before normalisation runs. A parent that does not exist in this guild returns `INVALID_PARENT_CHANNEL`, and one that is not a category returns `PARENT_MUST_BE_CATEGORY`.
|
||||
|
||||
The guild holds at most the instance-configured `max_guild_channels` [limit](/http-api/instance/#limit-keys), defaulting to 500, and a parent category holds at most `max_channels_per_category`, defaulting to 50. Reaching the guild limit returns 400 `MAX_GUILD_CHANNELS` and reaching the category limit returns 400 `MAX_CATEGORY_CHANNELS`. Each message has the resolved limit. The new channel is created with no RTC region.
|
||||
|
||||
@@ -242,7 +242,7 @@ A creation template describes the roles and channels that [Create guild](#create
|
||||
| topic? | ?string | Channel topic |
|
||||
| position | integer | The sort position of the channel |
|
||||
| parent_id?<sup>3</sup> | ?decimal string \| integer | Template-local parent category ID |
|
||||
| bitrate?<sup>4</sup> | ?integer | Voice bitrate, defaulting to 64000 for a voice channel |
|
||||
| bitrate?<sup>4</sup> | ?integer | Voice bitrate, defaulting to 64000 for a voice channel and capped at 96000 |
|
||||
| user_limit?<sup>4</sup> | ?integer | Voice occupancy limit, defaulting to 0 for a voice channel |
|
||||
| voice_connection_limit?<sup>4</sup> | ?integer | Per-user voice connection limit, defaulting to 5 for a voice channel |
|
||||
| nsfw? | boolean | Whether the channel is age restricted (default false) |
|
||||
@@ -378,6 +378,9 @@ Each value in the guild's `features` array is a capability or availability flag.
|
||||
| --- | --- |
|
||||
| ANIMATED_ICON | Guild can use an animated icon |
|
||||
| ANIMATED_BANNER | Guild can use an animated banner |
|
||||
| AUDIO_BITRATE_128_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 128000 |
|
||||
| AUDIO_BITRATE_256_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 256000 |
|
||||
| AUDIO_BITRATE_384_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 384000 |
|
||||
| BANNER | Guild can use a banner |
|
||||
| CLONE_EMOJI_DISABLED<sup>1</sup> | The one-click emoji clone shortcut is disabled for non-members |
|
||||
| CLONE_STICKER_DISABLED<sup>1</sup> | The one-click sticker clone shortcut is disabled for non-members |
|
||||
@@ -415,6 +418,8 @@ Each value in the guild's `features` array is a capability or availability flag.
|
||||
|
||||
<sup>5</sup> The feature raises the default `max_guild_members` limit from 1000000 to 10000000 before the ordered [limit configuration](/http-api/instance/#limit-keys) is checked, so the raised ceiling applies even when no configured rule names the feature
|
||||
|
||||
<sup>6</sup> A guild holding none of the three caps a voice channel `bitrate` at 96000. The highest feature the guild holds wins, and a write above the cap is stored at the cap rather than rejected. A guild that loses the feature keeps the stored value, and the next write to `bitrate` lowers it to the cap
|
||||
|
||||
## Custom invite URL object
|
||||
|
||||
A custom invite URL object reports the guild's current custom invite code and how many times the invite behind it has been used. [Get guild custom invite URL](#get-guild-custom-invite-url) returns it.
|
||||
|
||||
@@ -79,6 +79,8 @@ A call never acks. A refused placement into a direct message or group direct mes
|
||||
|
||||
A guild voice channel stores its `bitrate`, `user_limit`, `voice_connection_limit`, and `rtc_region` on the [channel object](/http-api/channels/#channel-object). It also has ordinary messages, pins, and slowmode, so its text history is read and written through the [Messages resource](/http-api/messages/).
|
||||
|
||||
A new voice channel stores a `bitrate` of 64000. The ceiling is 96000, and the `AUDIO_BITRATE_128_KBPS`, `AUDIO_BITRATE_256_KBPS`, and `AUDIO_BITRATE_384_KBPS` [guild features](/http-api/guilds/#guild-features) raise it to 128000, 256000, and 384000. A direct message and a group direct message call carry no `bitrate` and always run at 64000.
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Effect on voice |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
clampVoiceChannelBitrate,
|
||||
GuildFeatures,
|
||||
getMaxVoiceChannelBitrate,
|
||||
resolveVoiceChannelBitrate,
|
||||
} from './GuildConstants';
|
||||
|
||||
describe('getMaxVoiceChannelBitrate', () => {
|
||||
it('caps a guild holding no audio bitrate feature at 96 kbps', () => {
|
||||
expect(getMaxVoiceChannelBitrate([])).toBe(96000);
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.VIP_VOICE])).toBe(96000);
|
||||
});
|
||||
it('caps a call outside a guild at 96 kbps', () => {
|
||||
expect(getMaxVoiceChannelBitrate(null)).toBe(96000);
|
||||
expect(getMaxVoiceChannelBitrate(undefined)).toBe(96000);
|
||||
});
|
||||
it('raises the cap to the feature the guild holds', () => {
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_128_KBPS])).toBe(128000);
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000);
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_384_KBPS])).toBe(384000);
|
||||
});
|
||||
it('takes the highest feature when a guild holds several', () => {
|
||||
expect(
|
||||
getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_384_KBPS, GuildFeatures.AUDIO_BITRATE_128_KBPS]),
|
||||
).toBe(384000);
|
||||
expect(
|
||||
getMaxVoiceChannelBitrate(new Set([GuildFeatures.AUDIO_BITRATE_128_KBPS, GuildFeatures.AUDIO_BITRATE_256_KBPS])),
|
||||
).toBe(256000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampVoiceChannelBitrate', () => {
|
||||
it('clamps above the cap the guild holds', () => {
|
||||
expect(clampVoiceChannelBitrate(384000, [])).toBe(96000);
|
||||
expect(clampVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_128_KBPS])).toBe(128000);
|
||||
expect(clampVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_384_KBPS])).toBe(384000);
|
||||
});
|
||||
it('leaves a value under the cap alone', () => {
|
||||
expect(clampVoiceChannelBitrate(64000, [])).toBe(64000);
|
||||
expect(clampVoiceChannelBitrate(8000, [])).toBe(8000);
|
||||
});
|
||||
it('raises a value under the minimum', () => {
|
||||
expect(clampVoiceChannelBitrate(1000, [])).toBe(8000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVoiceChannelBitrate', () => {
|
||||
it('falls back to 64 kbps when the channel stores no bitrate', () => {
|
||||
expect(resolveVoiceChannelBitrate(null, null)).toBe(64000);
|
||||
expect(resolveVoiceChannelBitrate(undefined, [])).toBe(64000);
|
||||
expect(resolveVoiceChannelBitrate(0, [])).toBe(64000);
|
||||
expect(resolveVoiceChannelBitrate(Number.NaN, [])).toBe(64000);
|
||||
});
|
||||
it('clamps a stored bitrate to what the guild currently holds', () => {
|
||||
expect(resolveVoiceChannelBitrate(384000, [])).toBe(96000);
|
||||
expect(resolveVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_BITRATE_MAX_128_KBPS,
|
||||
VOICE_CHANNEL_BITRATE_MAX_256_KBPS,
|
||||
VOICE_CHANNEL_BITRATE_MAX_384_KBPS,
|
||||
VOICE_CHANNEL_BITRATE_MAX_STANDARD,
|
||||
VOICE_CHANNEL_BITRATE_MIN,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
|
||||
export const GuildVerificationLevel = {
|
||||
@@ -97,6 +105,9 @@ export const CONTENT_WARNING_TEXT_MAX_LENGTH = 200;
|
||||
export const GuildFeatures = {
|
||||
ANIMATED_ICON: 'ANIMATED_ICON',
|
||||
ANIMATED_BANNER: 'ANIMATED_BANNER',
|
||||
AUDIO_BITRATE_128_KBPS: 'AUDIO_BITRATE_128_KBPS',
|
||||
AUDIO_BITRATE_256_KBPS: 'AUDIO_BITRATE_256_KBPS',
|
||||
AUDIO_BITRATE_384_KBPS: 'AUDIO_BITRATE_384_KBPS',
|
||||
BANNER: 'BANNER',
|
||||
CLONE_EMOJI_DISABLED: 'CLONE_EMOJI_DISABLED',
|
||||
CLONE_STICKER_DISABLED: 'CLONE_STICKER_DISABLED',
|
||||
@@ -127,6 +138,37 @@ export const GuildFeatures = {
|
||||
|
||||
export type GuildFeature = ValueOf<typeof GuildFeatures>;
|
||||
|
||||
export function getMaxVoiceChannelBitrate(features: Iterable<string> | null | undefined): number {
|
||||
if (features == null) {
|
||||
return VOICE_CHANNEL_BITRATE_MAX_STANDARD;
|
||||
}
|
||||
let maximum = VOICE_CHANNEL_BITRATE_MAX_STANDARD;
|
||||
for (const feature of features) {
|
||||
if (feature === GuildFeatures.AUDIO_BITRATE_384_KBPS) {
|
||||
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_384_KBPS);
|
||||
} else if (feature === GuildFeatures.AUDIO_BITRATE_256_KBPS) {
|
||||
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_256_KBPS);
|
||||
} else if (feature === GuildFeatures.AUDIO_BITRATE_128_KBPS) {
|
||||
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_128_KBPS);
|
||||
}
|
||||
}
|
||||
return maximum;
|
||||
}
|
||||
|
||||
export function clampVoiceChannelBitrate(bitrate: number, features: Iterable<string> | null | undefined): number {
|
||||
return Math.min(Math.max(bitrate, VOICE_CHANNEL_BITRATE_MIN), getMaxVoiceChannelBitrate(features));
|
||||
}
|
||||
|
||||
export function resolveVoiceChannelBitrate(
|
||||
bitrate: number | null | undefined,
|
||||
features: Iterable<string> | null | undefined,
|
||||
): number {
|
||||
if (typeof bitrate !== 'number' || !Number.isFinite(bitrate) || bitrate <= 0) {
|
||||
return VOICE_CHANNEL_BITRATE_DEFAULT;
|
||||
}
|
||||
return clampVoiceChannelBitrate(Math.round(bitrate), features);
|
||||
}
|
||||
|
||||
export const JoinSourceTypes = {
|
||||
CREATOR: 0,
|
||||
INSTANT_INVITE: 1,
|
||||
|
||||
@@ -4,7 +4,12 @@ export const MAX_GUILDS_PREMIUM = 200;
|
||||
export const MAX_GUILD_CHANNELS = 500;
|
||||
export const MAX_CHANNELS_PER_CATEGORY = 50;
|
||||
export const VOICE_CHANNEL_BITRATE_MIN = 8000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX = 320000;
|
||||
export const VOICE_CHANNEL_BITRATE_DEFAULT = 64000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_STANDARD = 96000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_128_KBPS = 128000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_256_KBPS = 256000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_384_KBPS = 384000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX = VOICE_CHANNEL_BITRATE_MAX_384_KBPS;
|
||||
export const VOICE_CHANNEL_USER_LIMIT_MIN = 0;
|
||||
export const VOICE_CHANNEL_USER_LIMIT_MAX = 99;
|
||||
export const VOICE_CHANNEL_CAMERA_USER_LIMIT = 25;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RTC_REGION_ID_MAX_LENGTH,
|
||||
RTC_REGION_ID_MIN_LENGTH,
|
||||
VOICE_CHANNEL_BITRATE_MAX,
|
||||
VOICE_CHANNEL_BITRATE_MAX_STANDARD,
|
||||
VOICE_CHANNEL_BITRATE_MIN,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_MAX,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_MIN,
|
||||
@@ -59,7 +60,9 @@ const ChannelCommonBase = z.object({
|
||||
.min(VOICE_CHANNEL_BITRATE_MIN)
|
||||
.max(VOICE_CHANNEL_BITRATE_MAX)
|
||||
.nullish()
|
||||
.describe(`Voice channel bitrate in bits per second (${VOICE_CHANNEL_BITRATE_MIN}-${VOICE_CHANNEL_BITRATE_MAX})`),
|
||||
.describe(
|
||||
`Voice channel bitrate in bits per second (${VOICE_CHANNEL_BITRATE_MIN}-${VOICE_CHANNEL_BITRATE_MAX}), clamped to ${VOICE_CHANNEL_BITRATE_MAX_STANDARD} unless the guild holds an AUDIO_BITRATE feature`,
|
||||
),
|
||||
user_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -41,6 +41,21 @@ export const GuildFeatureSchema = withOpenApiType(
|
||||
[
|
||||
[GuildFeatures.ANIMATED_ICON, 'ANIMATED_ICON', 'Guild can have an animated icon'],
|
||||
[GuildFeatures.ANIMATED_BANNER, 'ANIMATED_BANNER', 'Guild can have an animated banner'],
|
||||
[
|
||||
GuildFeatures.AUDIO_BITRATE_128_KBPS,
|
||||
'AUDIO_BITRATE_128_KBPS',
|
||||
'Guild can set a voice channel bitrate of up to 128 kbps',
|
||||
],
|
||||
[
|
||||
GuildFeatures.AUDIO_BITRATE_256_KBPS,
|
||||
'AUDIO_BITRATE_256_KBPS',
|
||||
'Guild can set a voice channel bitrate of up to 256 kbps',
|
||||
],
|
||||
[
|
||||
GuildFeatures.AUDIO_BITRATE_384_KBPS,
|
||||
'AUDIO_BITRATE_384_KBPS',
|
||||
'Guild can set a voice channel bitrate of up to 384 kbps',
|
||||
],
|
||||
[GuildFeatures.BANNER, 'BANNER', 'Guild can have a banner'],
|
||||
[
|
||||
GuildFeatures.CLONE_EMOJI_DISABLED,
|
||||
|
||||
Reference in New Issue
Block a user