Compare commits

...
34 changed files with 1035 additions and 139 deletions
+1 -1
View File
@@ -9801,7 +9801,7 @@
},
"GuildFeatureSchema": {
"type": "string",
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
},
"AddGuildMembersAdminBulkJobCreateRequest": {
"type": "object",
+24 -18
View File
@@ -55,15 +55,13 @@ impl AdminApiClient {
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
let region_id = required_field(params, "id")?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_region(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
validate_against::<generated_types::UpdateVoiceRegionRequest>(params)?;
self.patch_with_reason(
&format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
Some(params),
None,
)
.await
}
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
@@ -121,15 +119,17 @@ impl AdminApiClient {
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_server(&region_id, &server_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
validate_against::<generated_types::UpdateVoiceServerRequest>(params)?;
self.patch_with_reason(
&format!(
"/admin/voice/regions/{}/servers/{}",
urlencoding::encode(&region_id),
urlencoding::encode(&server_id)
),
Some(params),
None,
)
.await
}
pub async fn delete_voice_server(
@@ -150,6 +150,12 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
serde_json::from_value::<T>(params.clone())
.map(drop)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
if has_coordinate("latitude") == has_coordinate("longitude") {
+58 -20
View File
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
}
body.insert("is_default".into(), form.bool_value("is_default").into());
body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert(
"required_guild_features".into(),
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
insert_submitted_list(&mut body, form, "required_guild_features");
insert_submitted_list(&mut body, form, "allowed_guild_ids");
serde_json::Value::Object(body)
}
fn insert_submitted_list(
body: &mut serde_json::Map<String, serde_json::Value>,
form: &MultiValueForm,
field: &str,
) {
let repeated = format!("{field}[]");
if !form.contains_key(&repeated) && !form.contains_key(field) {
return;
}
body.insert(
field.to_owned(),
form.list_values_any(&[repeated.as_str(), field]).into(),
);
}
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
let mut body = serde_json::Map::new();
if let Some(v) = form.clean("region_id") {
@@ -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}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=true\
&required_guild_features=&allowed_guild_ids=&soft_connection_limit="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
assert_eq!(body["soft_connection_limit"], Value::Null);
assert_eq!(body["vip_only"], json!(false));
}
#[tokio::test]
async fn activating_a_server_leaves_the_restriction_fields_untouched() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-servers?action=update",
&format!(
"_csrf={csrf_token}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=false&vip_only=true"
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
let object = body.as_object().expect("object body");
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], json!(false));
assert_eq!(body["vip_only"], json!(true));
}
#[tokio::test]
async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-regions?action=update",
&format!(
"_csrf={csrf_token}&id={REGION_ID}&name=Northern%20Europe&emoji=%F0%9F%87%B8%F0%9F%87%AA\
&latitude=59.33&longitude=18.06&required_guild_features=&allowed_guild_ids="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(&app, "PATCH /admin/voice/regions/europe-north");
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
}
struct TestApp {
router: Router,
session_cookie: String,
captured: CapturedBodies,
}
async fn setup() -> TestApp {
let captured: CapturedBodies = Arc::new(Mutex::new(Vec::new()));
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
let router = build_router(test_config(api_endpoint));
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
captured,
}
}
fn captured_body(app: &TestApp, route: &str) -> Value {
let captured = app.captured.lock().expect("captured bodies");
captured
.iter()
.find(|(seen, _)| seen == route)
.map(|(_, body)| body.clone())
.unwrap_or_else(|| {
panic!(
"no request captured for {route}, saw {:?}",
captured.iter().map(|(seen, _)| seen).collect::<Vec<_>>()
)
})
}
async fn csrf_token(app: &TestApp) -> String {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/voice-regions")
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
let pair = value.split(';').next()?;
let token = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="))?;
(!token.is_empty()).then(|| token.to_owned())
})
.expect("csrf_token cookie")
}
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(
header::COOKIE,
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
)
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
response.status()
}
async fn spawn_mock_api(captured: CapturedBodies) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(
listener,
Router::new().fallback(mock_api).with_state(captured),
)
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(
State(captured): State<CapturedBodies>,
method: Method,
uri: Uri,
request: Request<Body>,
) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH {
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
captured
.lock()
.expect("captured bodies")
.push((format!("PATCH {path}"), body));
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::PATCH, "/admin/voice/regions/europe-north") => {
Json(json!({ "region": region() })).into_response()
}
(Method::PATCH, "/admin/voice/regions/europe-north/servers/europe-north-server-1") => {
Json(json!({ "server": server() })).into_response()
}
(Method::GET, "/admin/voice/regions") => {
Json(json!({ "regions": [region()] })).into_response()
}
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn region() -> Value {
json!({
"id": REGION_ID,
"name": "Northern Europe",
"emoji": "flag",
"latitude": 59.33,
"longitude": 18.06,
"is_default": true,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn server() -> Value {
json!({
"region_id": REGION_ID,
"server_id": SERVER_ID,
"endpoint": "wss://voice.example.com",
"latitude": null,
"longitude": null,
"is_active": true,
"soft_connection_limit": null,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn admin_user() -> Value {
json!({
"id": "1500000000000000000",
"username": "AdminUser",
"discriminator": 1,
"avatar": null,
"banner": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"global_name": "AdminUser",
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-US",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": false,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
@@ -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,
+23 -17
View File
@@ -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"}],
@@ -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
@@ -3,10 +3,10 @@
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
import {SettingsControlRow} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SettingsControlRow';
import {
BITRATE_KBPS_MARKERS,
BITRATE_KBPS_MAX,
BITRATE_KBPS_DEFAULT,
BITRATE_KBPS_MIN,
type FormInputs,
getBitrateKbpsMarkers,
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
import {
@@ -50,6 +50,7 @@ const CONNECTION_LIMIT_VALUE_DESCRIPTOR = msg({
interface VoiceSettingsProps {
form: UseFormReturn<FormInputs>;
maxBitrateKbps: number;
}
const formatIntegerValue = (value: number): string => String(Math.round(value));
@@ -66,15 +67,14 @@ const formatConnectionLimitMarker = (value: number): string | null => {
const VoiceBitrateSlider: React.FC<{
value: number | undefined;
maxBitrateKbps: number;
onChange: (value: number) => void;
}> = ({value, onChange}) => {
}> = ({value, maxBitrateKbps, onChange}) => {
const {i18n} = useLingui();
const voiceQualityLabel = i18n._(VOICE_QUALITY_DESCRIPTOR);
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
let currentValue = value;
if (typeof currentValue !== 'number') {
currentValue = 64;
}
const factoryDefaultValue = Math.min(BITRATE_KBPS_DEFAULT, maxBitrateKbps);
const currentValue = Math.min(typeof value === 'number' ? value : BITRATE_KBPS_DEFAULT, maxBitrateKbps);
return (
<SettingsControlRow
label={voiceQualityLabel}
@@ -88,18 +88,18 @@ const VoiceBitrateSlider: React.FC<{
<Slider
value={currentValue}
defaultValue={currentValue}
factoryDefaultValue={64}
factoryDefaultValue={factoryDefaultValue}
minValue={BITRATE_KBPS_MIN}
maxValue={BITRATE_KBPS_MAX}
maxValue={maxBitrateKbps}
step={1}
markers={[...BITRATE_KBPS_MARKERS]}
markers={getBitrateKbpsMarkers(maxBitrateKbps)}
ariaLabel={voiceQualityLabel}
ariaValueText={i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(currentValue)})}
onMarkerRender={formatIntegerValue}
onValueRender={(kilobits) => i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(kilobits)})}
onValueChange={(kilobits) => onChange(Math.round(kilobits))}
showResetButton={true}
onReset={() => onChange(64)}
onReset={() => onChange(factoryDefaultValue)}
resetTooltip={resetSliderLabel}
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.slider"
/>
@@ -108,7 +108,7 @@ const VoiceBitrateSlider: React.FC<{
);
};
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form, maxBitrateKbps}) => {
const {i18n} = useLingui();
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
const participantLimitLabel = i18n._(PARTICIPANT_LIMIT_LABEL_DESCRIPTOR);
@@ -121,6 +121,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
render={({field}) => (
<VoiceBitrateSlider
value={field.value}
maxBitrateKbps={maxBitrateKbps}
onChange={field.onChange}
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.change"
/>
@@ -172,7 +173,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
);
};
export const VoiceConnectionLimitControl: React.FC<VoiceSettingsProps> = ({form}) => {
export const VoiceConnectionLimitControl: React.FC<{form: UseFormReturn<FormInputs>}> = ({form}) => {
const {i18n} = useLingui();
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
const connectionLimitLabel = i18n._(CONNECTION_LIMIT_LABEL_DESCRIPTOR);
@@ -2,7 +2,8 @@
import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {VOICE_CHANNEL_BITRATE_MAX, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
import {getMaxVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
import {VOICE_CHANNEL_BITRATE_DEFAULT, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
export interface FormInputs {
name: string;
@@ -20,8 +21,17 @@ export interface FormInputs {
export const CHANNEL_OVERVIEW_TAB_ID = 'overview';
export const BITRATE_KBPS_MIN = VOICE_CHANNEL_BITRATE_MIN / 1000;
export const BITRATE_KBPS_MAX = VOICE_CHANNEL_BITRATE_MAX / 1000;
export const BITRATE_KBPS_MARKERS: ReadonlyArray<number> = [BITRATE_KBPS_MIN, 64, 128, 256, BITRATE_KBPS_MAX];
export const BITRATE_KBPS_DEFAULT = VOICE_CHANNEL_BITRATE_DEFAULT / 1000;
export function getMaxBitrateKbps(guildFeatures: Iterable<string> | null | undefined): number {
return getMaxVoiceChannelBitrate(guildFeatures) / 1000;
}
export function getBitrateKbpsMarkers(maxKbps: number): Array<number> {
const markers = [BITRATE_KBPS_MIN, BITRATE_KBPS_DEFAULT, 128, 256].filter((marker) => marker < maxKbps);
markers.push(maxKbps);
return markers;
}
export const MAX_TOPIC_LENGTH = 1024;
export interface RtcRegionOption extends ComboboxOption<string | null> {
@@ -1,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);
});
});
+42
View File
@@ -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,
+6 -1
View File
@@ -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,