Compare commits

..
Author SHA1 Message Date
HampusandGitHub 61b4511ae4 fix(schema): group category channels text before voice when ordering (#1823) 2026-08-23 17:49:26 +02:00
HampusandGitHub 137edc7cfb fix(app): share stream audio by default and reset the opt-out per stream (#1819) 2026-08-22 10:14:45 +02:00
HampusandGitHub 14e772a751 fix(api): serialise elapsed temp bans as null for the admin panel (#1817) 2026-08-21 20:34:43 +02:00
HampusandGitHub 32afbf12d6 fix(api): stop treating accounts pending deletion as already deleted (#1816) 2026-08-21 20:31:32 +02:00
HampusandGitHub ffaf5119d8 fix(app): only warn about software encoding when no layer is accelerated (#1815) 2026-08-21 18:49:51 +02:00
HampusandGitHub 10bc8c1efa fix(desktop): stop the windows audio probe timing out against its own budget (#1814) 2026-08-21 17:29:12 +02:00
HampusandGitHub 85a03a9e39 fix(app): apply the screen share audio toggle to the surface being shared (#1813) 2026-08-21 17:04:22 +02:00
51ee6567b4 chore(i18n): update public marketing catalogs (#1812)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:31:01 +02:00
090220a29d chore(marketing): advance pointer f39eced → 02e3a2c (#1811)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:30:54 +02:00
HampusandGitHub e7973b8be0 fix(api): derive voice reconciliation candidate ttl from real sweep spacing (#1810) 2026-08-21 15:49:50 +02:00
HampusandGitHub b5324c9223 perf(gateway): stop materializing all members on the guild connect path (#1809) 2026-08-21 14:00:19 +02:00
HampusandGitHub edb8d80077 ci: source the s3 provider for downloads and static from repo variables (#1803) 2026-08-20 21:46:26 +02:00
HampusandGitHub ddee116339 feat(api): route downloads through the configured downloads provider (#1802) 2026-08-20 21:44:53 +02:00
HampusandGitHub bdacaea4a8 feat(config): add an optional separate s3 provider for downloads (#1801) 2026-08-20 21:34:19 +02:00
HampusandGitHub 9e28e02b5d ci(rust): pin the floating toolchains to the version images build with (#1800) 2026-08-20 21:27:12 +02:00
HampusandGitHub 3527dc95a2 fix(rust): silence result_large_err on axum response error paths (#1799) 2026-08-20 21:18:18 +02:00
HampusandGitHub 27c7b2722d chore(admin): regenerate openapi schemas for the admin acl cap (#1798) 2026-08-20 21:17:04 +02:00
HampusandGitHub ba96f52ed6 fix(schema): allow assigning every admin ACL to a user (#1797) 2026-08-20 21:09:56 +02:00
HampusandGitHub 2c8b3ff45c fix(build): build the messages and users images with scylla support (#1796) 2026-08-20 20:30:16 +02:00
HampusandGitHub 631bc2307a fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795) 2026-08-20 19:56:02 +02:00
HampusandGitHub 8f4f9a8601 refactor(media): share one external proxy url codec across every service (#1794) 2026-08-20 19:55:40 +02:00
HampusandGitHub 1c920f966e feat(media): emit external proxy urls with a readable path and extension (#1793) 2026-08-20 18:44:45 +02:00
HampusandGitHub 2b1de38949 chore(i18n): regenerate catalogs after the voice engine removal (#1791) 2026-08-20 17:45:49 +02:00
HampusandGitHub d5daf61dbd fix(voice): recalibrate stored participant and stream volumes too (#1790) 2026-08-20 17:45:17 +02:00
HampusandGitHub 06c42ce02f refactor(desktop): delete the unused rust voice module (#1789) 2026-08-20 17:44:55 +02:00
HampusandGitHub 4f21430880 refactor(voice): drop the native only surface from voice_engine_v2 and narrow the desktop bridge (#1788) 2026-08-20 17:44:37 +02:00
HampusandGitHub 9731bac40f refactor(voice): remove the native voice engine from the renderer (#1787) 2026-08-20 17:43:38 +02:00
HampusandGitHub b144e650f2 fix(voice): make deafen reach watched screen share audio (#1786) 2026-08-20 17:43:19 +02:00
HampusandGitHub ef6536644c fix(voice): stop the in call speaker slider clamping a boosted output volume (#1785) 2026-08-20 17:42:59 +02:00
HampusandGitHub 17eab43245 fix(voice): retune the remote playback leveller and measure it in float (#1784) 2026-08-20 17:42:40 +02:00
HampusandGitHub fbd7f1e3b8 fix(voice): compose participant gain in linear space and split the ceilings (#1783) 2026-08-20 17:42:21 +02:00
HampusandGitHub 25af7516a4 fix(voice): widen the volume boost leg and add a master soft clip limiter (#1782) 2026-08-20 17:42:02 +02:00
HampusandGitHub c020eded31 fix(voice): stop forcing automatic gain control off on every capture profile (#1781) 2026-08-20 17:41:44 +02:00
HampusandGitHub 5331c0216a fix(voice): keep a remote track pinned at zero volume across re-attach (#1780) 2026-08-20 17:40:53 +02:00
HampusandGitHub 528777926c fix(api): stop one unreachable community from breaking bookmarks (#1779) 2026-08-20 15:50:51 +02:00
HampusandGitHub 47b5c3d4f0 fix(app): expose the guild id on guild list items again (#1778) 2026-08-20 14:39:24 +02:00
HampusandGitHub d9bfca66d6 fix(app): keep search results in server order instead of per channel (#1777) 2026-08-20 13:10:47 +02:00
325 changed files with 8082 additions and 27023 deletions
+4 -4
View File
@@ -106,10 +106,10 @@ jobs:
- name: upload assets to S3 static bucket
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
STATIC_BUCKET: fluxer-static
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.STATIC_S3_ENDPOINT }}
STATIC_BUCKET: ${{ vars.STATIC_S3_BUCKET }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
--step upload_assets
+8 -8
View File
@@ -139,10 +139,10 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
@@ -524,11 +524,11 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+2 -2
View File
@@ -89,7 +89,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: clippy, rustfmt
- name: Install pnpm
@@ -286,7 +286,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: rustfmt
- name: Sync ci helper dependencies
Generated
+9
View File
@@ -1777,6 +1777,7 @@ dependencies = [
"axum",
"base64",
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper-util",
@@ -1800,6 +1801,7 @@ dependencies = [
"anyhow",
"base64",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"moka",
"reqwest",
@@ -1836,6 +1838,7 @@ dependencies = [
"cc",
"clap",
"criterion",
"fluxer_common",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1875,6 +1878,7 @@ dependencies = [
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
"futures",
"hmac 0.13.0",
@@ -1943,6 +1947,7 @@ dependencies = [
"encoding_rs",
"entities",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"infer",
"moka",
@@ -2038,10 +2043,14 @@ dependencies = [
"aws-credential-types",
"aws-sigv4",
"axum",
"base64",
"hmac 0.13.0",
"maxminddb",
"moka",
"reqwest",
"serde_json",
"sha2 0.11.0",
"thiserror",
"time",
"tracing",
"urlencoding",
+2 -2
View File
@@ -14913,7 +14913,7 @@
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"deletion_public_reason": {"nullable": true, "type": "string"},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 115},
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"has_totp": {"type": "boolean"},
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
@@ -15481,7 +15481,7 @@
"acls": {
"type": "array",
"items": {"type": "string"},
"maxItems": 100,
"maxItems": 115,
"description": "List of access control permissions to assign"
}
},
+1
View File
@@ -112,6 +112,7 @@ fn is_urlencoded_form(request: &Request) -> bool {
})
}
#[allow(clippy::result_large_err)]
async fn extract_csrf_from_form_body(
request: Request,
) -> Result<(Request, Option<String>), Response> {
@@ -29,12 +29,16 @@
"main": "./src/MediaProxyUtils.ts",
"types": "./src/MediaProxyUtils.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@types/node": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:"
}
}
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildExternalMediaProxyPath,
buildV2ExternalMediaProxyPath,
reconstructOriginalUrl,
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
import {describe, expect, it} from 'vitest';
const ROUND_TRIP_URLS = [
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
'https://example.com:8443/a.png',
'https://avatars.githubusercontent.com/u/241303489?v=4',
'http://example.com/plain.gif',
'https://example.com/deep/nested/path/to/file.jpeg',
'https://example.com/file.png?a=1&b=2&c=3',
'https://example.com/spaced%20name.png',
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
'https://sub.domain.example.co.uk/a/b.webp',
];
describe('buildExternalMediaProxyPath', () => {
it('emits the plain path shape with the extension last', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
);
});
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
);
});
it('keeps a non default port on the host segment', () => {
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
});
it('preserves the http scheme', () => {
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
});
it('handles a root url with no path', () => {
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
});
it('never emits the v2 prefix any more', () => {
for (const url of ROUND_TRIP_URLS) {
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
}
});
it('ends in the source file extension so extension based cdn caching applies', () => {
for (const [url, ext] of [
['https://example.com/a.webp', '.webp'],
['https://example.com/a.png?x=1', '.png'],
['https://example.com/a/b/c.jpeg', '.jpeg'],
] as const) {
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
}
});
it('rejects a url it cannot parse', () => {
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
});
});
describe('reconstructOriginalUrl', () => {
it('round trips every supported shape', () => {
for (const url of ROUND_TRIP_URLS) {
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
}
});
it('decodes an externally produced path verbatim', () => {
expect(
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
});
it('does not double the question mark when the query segment carries one', () => {
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
expect(decoded).toBe('https://example.com/x.png?a=1');
expect(decoded).not.toContain('??');
});
it('still accepts a query segment without a leading question mark', () => {
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
});
it('still decodes v2 paths so links already sent keep working', () => {
expect(reconstructOriginalUrl(buildV2ExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
'https://example.com/a.png?x=1',
);
});
it('rejects a path that has no host after the protocol', () => {
expect(() => reconstructOriginalUrl('https')).toThrow();
});
it('rejects an empty v2 payload', () => {
expect(() => reconstructOriginalUrl('v2/')).toThrow();
});
});
@@ -74,7 +74,8 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
const path = decodeLegacyComponent(encodedPath);
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
}
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
@@ -85,11 +86,28 @@ function reconstructV2OriginalUrl(proxyUrlPath: string): string {
return decodeV2PathComponent(encodedOriginalUrl);
}
export function buildExternalMediaProxyPath(inputUrl: string): string {
export function buildV2ExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
}
export function buildExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
const protocol = parsedUrl.protocol.replace(/:$/u, '');
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
const path = parsedUrl.pathname
.replace(/^\//u, '')
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
segments.push(protocol, host);
if (path) {
segments.push(path);
}
return segments.join('/');
}
export function reconstructOriginalUrl(proxyUrlPath: string): string {
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
? reconstructV2OriginalUrl(proxyUrlPath)
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
}
},
"include": ["src/**/*"]
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+10
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -461,6 +463,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
voiceReconciliation: {
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
},
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
+2 -1
View File
@@ -84,7 +84,8 @@ export async function mapUserToAdminResponse(
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
suspicious_activity_flags: user.suspiciousActivityFlags,
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
temp_banned_until:
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
deletion_reason_code: user.deletionReasonCode,
@@ -929,9 +929,8 @@ export class MessageSendService {
algorithm: 'leaky_bucket',
});
if (!slowmodeResult.allowed) {
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
throw new SlowmodeRateLimitError({
retryAfter,
retryAfter: slowmodeResult.retryAfter,
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
});
}
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.id).toBe(firstMessage.id);
});
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
const rateLimitPerUser = 5;
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await ensureSessionStarted(harness, member.token);
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
await updateChannel(harness, owner.token, channel.id, {
rate_limit_per_user: rateLimitPerUser,
});
await sendChannelMessage(harness, member.token, channel.id, 'first message');
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'second message'})
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
.executeWithResponse();
const headerRetryAfter = Number(response.headers.get('Retry-After'));
expect(Number.isInteger(headerRetryAfter)).toBe(true);
expect(headerRetryAfter).toBeGreaterThan(0);
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
expect(json.retry_after).toBeGreaterThan(0);
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
});
});
+10
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
@@ -150,6 +151,7 @@ export interface APIConfig {
static: string;
};
};
s3Downloads: ResolvedDownloadsProvider;
email: {
enabled: boolean;
provider: 'smtp' | 'none';
@@ -359,6 +361,14 @@ export interface APIConfig {
taskName?: WorkerTaskName;
enableCronScheduler?: boolean;
enableVoiceReconciliation: boolean;
voiceReconciliation: {
intervalMs: number | undefined;
staggerDelayMs: number | undefined;
lockTtlSeconds: number | undefined;
cadenceTtlSeconds: number | undefined;
gatewayOnlyGraceMs: number | undefined;
liveKitOnlyGraceMs: number | undefined;
};
laneConcurrencyOverrides: {
realtime?: number;
unfurl?: number;
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
]);
});
});
describe('provider selection', () => {
interface ClientProbe {
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
}
it('defaults to the shared S3 configuration', async () => {
const service = new StorageService() as unknown as ClientProbe;
expect(await service.client.config.region()).toBe(Config.s3.region);
});
it('uses an explicitly supplied provider instead of the shared one', async () => {
const service = new StorageService({
endpoint: 'https://downloads.example.net',
forcePathStyle: false,
region: 'eu-central-9',
accessKeyId: 'DL_KEY',
secretAccessKey: 'DL_SECRET',
}) as unknown as ClientProbe;
expect(await service.client.config.region()).toBe('eu-central-9');
expect(await service.client.config.region()).not.toBe(Config.s3.region);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import fs from 'node:fs';
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
export class StorageService implements IStorageService {
private readonly client: S3Client;
private readonly presignClient: S3Client;
private readonly provider: S3ProviderSettings;
constructor() {
this.client = buildPooledS3Client({
constructor(provider?: S3ProviderSettings) {
this.provider = provider ?? {
endpoint: Config.s3.endpoint,
presignedUrlBase: Config.s3.presignedUrlBase,
forcePathStyle: Config.s3.forcePathStyle,
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
};
this.client = buildPooledS3Client({
endpoint: this.provider.endpoint,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: true,
});
this.presignClient = buildPooledS3Client({
endpoint: this.resolvePresignEndpoint(),
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
forcePathStyle: Config.s3.forcePathStyle,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: this.provider.forcePathStyle,
});
}
private resolvePresignEndpoint(): string {
const fallbackEndpoint = Config.s3.endpoint;
const configuredEndpoint = Config.s3.presignedUrlBase;
const fallbackEndpoint = this.provider.endpoint;
const configuredEndpoint = this.provider.presignedUrlBase;
if (!configuredEndpoint) {
return fallbackEndpoint;
}
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {Config} from '../Config';
import {createDownloadsStorageService} from './StorageServiceFactory';
describe('createDownloadsStorageService', () => {
it('returns null when no downloads override is configured', () => {
expect(Config.s3Downloads.isOverridden).toBe(false);
expect(createDownloadsStorageService()).toBeNull();
});
it('resolves the downloads provider to the shared provider by default', () => {
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
});
});
@@ -1,8 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import type {IStorageService} from './IStorageService';
import {StorageService} from './StorageService';
export function createStorageService(): IStorageService {
return new StorageService();
}
export function createDownloadsStorageService(): IStorageService | null {
if (!Config.s3Downloads.isOverridden) {
return null;
}
return new StorageService(Config.s3Downloads.settings);
}
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
import {createStorageService} from '../infrastructure/StorageServiceFactory';
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
import {UserCacheService} from '../infrastructure/UserCacheService';
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
import {VirusScanService} from '../infrastructure/VirusScanService';
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
const fallback = singleton(() => createStorageService());
return () => _injectedStorageService ?? fallback();
})();
const getDownloadsStorageService: () => IStorageService = (() => {
const override = singleton(() => createDownloadsStorageService());
return () => override() ?? getStorageService();
})();
export const getErrorI18nService = singleton(() => new ErrorI18nService());
export const getLimitConfigService = singleton(
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
return accountDeletionQueue;
}
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
const getNcmecRepository = singleton(() => new NcmecRepository());
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {describe, expect, it} from 'vitest';
import {UserContentServiceTestHooks} from './UserContentService';
const {isUnreachableEntityError} = UserContentServiceTestHooks;
describe('isUnreachableEntityError', () => {
it('treats a deleted or left community as unreachable rather than fatal', () => {
expect(isUnreachableEntityError(new UnknownGuildError())).toBe(true);
});
it('treats a gone channel and a lost permission as unreachable', () => {
expect(isUnreachableEntityError(new UnknownChannelError())).toBe(true);
expect(isUnreachableEntityError(new MissingPermissionsError())).toBe(true);
});
it('leaves a deleted message to the delete path instead of marking it unavailable', () => {
expect(isUnreachableEntityError(new UnknownMessageError())).toBe(false);
});
it('still lets unexpected failures surface', () => {
expect(isUnreachableEntityError(new Error('database is on fire'))).toBe(false);
expect(isUnreachableEntityError(null)).toBe(false);
});
});
@@ -6,6 +6,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
@@ -94,6 +95,13 @@ function normalizeProviderEnvironment(
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
}
const isUnreachableEntityError = (error: unknown): boolean =>
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError ||
error instanceof UnknownGuildError;
export const UserContentServiceTestHooks = {isUnreachableEntityError};
export class UserContentService {
private readonly updatePropagator: BaseUserUpdatePropagator;
private readonly userRepository: UserContentRepository;
@@ -138,11 +146,7 @@ export class UserContentService {
messageId: mention.messageId,
});
} catch (error) {
if (
error instanceof UnknownMessageError ||
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError
) {
if (error instanceof UnknownMessageError || isUnreachableEntityError(error)) {
return null;
}
throw error;
@@ -188,7 +192,7 @@ export class UserContentService {
await this.userRepository.deleteSavedMessage(userId, savedMessage.messageId);
return null;
}
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
if (isUnreachableEntityError(error)) {
status = 'missing_permissions';
} else {
throw error;
@@ -808,6 +808,9 @@ export class UserRelationshipService {
if (!user) {
return false;
}
if (user.pendingDeletionAt !== null) {
return false;
}
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
}
}
@@ -19,6 +19,16 @@ import {
} from './RelationshipTestUtils';
import {fetchUserMe} from './UserTestUtils';
async function markUserScheduledForDeletion(harness: ApiTestHarness, userId: string): Promise<void> {
const pendingDeletionAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
await createBuilder(harness, '')
.post(`/test/users/${userId}/set-pending-deletion`)
.body({pending_deletion_at: pendingDeletionAt, set_self_deleted_flag: false})
.expect(HTTP_STATUS.OK)
.execute();
await markUserDeleted(harness, userId);
}
async function markUserDeleted(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilder(harness, '')
.patch(`/test/users/${userId}/flags`)
@@ -221,6 +231,16 @@ describe('UserRelationshipStateTransitions', () => {
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
.execute();
});
test('can accept a friend request from a user scheduled for deletion', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
await sendFriendRequest(harness, bob.token, alice.userId);
await markUserScheduledForDeletion(harness, bob.userId);
const {json: friendship} = await acceptFriendRequest(harness, alice.token, bob.userId);
assertRelationshipType(friendship, RelationshipTypes.FRIEND);
const {json: aliceAfter} = await listRelationships(harness, alice.token);
assertRelationshipType(findRelationship(aliceAfter, bob.userId)!, RelationshipTypes.FRIEND);
});
test('cannot send friend request to user who blocked you', async () => {
const alice = await createTestAccount(harness);
const bob = await createTestAccount(harness);
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {candidateTtlSecondsFor} from './VoiceReconciliationWorker';
const INTERVAL_MS = 15000;
const GATEWAY_ONLY_GRACE_MS = 10000;
function ttlFor(observedSweepSpacingMs: number): number {
return candidateTtlSecondsFor({
intervalMs: INTERVAL_MS,
observedSweepSpacingMs,
graceMs: GATEWAY_ONLY_GRACE_MS,
});
}
describe('candidateTtlSecondsFor', () => {
it('outlives the gap between two consecutive observations of the same key', () => {
for (const observedSweepSpacingMs of [0, 45_000, 136_000, 300_000, 596_000, 900_000]) {
expect(ttlFor(observedSweepSpacingMs) * 1000).toBeGreaterThan(observedSweepSpacingMs);
}
});
it('outlives a sweep gap far longer than the tick interval', () => {
expect(ttlFor(596_000) * 1000).toBeGreaterThan(596_000);
});
it('grows with the observed sweep spacing rather than the tick interval', () => {
expect(ttlFor(596_000)).toBeGreaterThan(ttlFor(136_000));
expect(ttlFor(136_000)).toBeGreaterThan(ttlFor(0));
});
it('keeps a floor that survives a single long sweep before any spacing is observed', () => {
expect(ttlFor(0)).toBeGreaterThanOrEqual(300);
});
it('stays bounded so a stale candidate cannot outlive its connection indefinitely', () => {
expect(ttlFor(Number.MAX_SAFE_INTEGER)).toBeLessThanOrEqual(3600);
});
});
@@ -96,9 +96,24 @@ const DEFAULT_STAGGER_DELAY_MS = 25;
const DEFAULT_LOCK_TTL_SECONDS = 180;
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
const MIN_CANDIDATE_TTL_SECONDS = 300;
const MAX_CANDIDATE_TTL_SECONDS = 3600;
const CANDIDATE_TTL_SWEEP_MULTIPLIER = 3;
const LAST_SWEEP_KEY_TTL_SECONDS = 86400;
const ROOM_KEY_PREFIX = 'voice:room:server:';
export function candidateTtlSecondsFor(input: {
intervalMs: number;
observedSweepSpacingMs: number;
graceMs: number;
}): number {
const spacingMs = Math.max(input.intervalMs, input.observedSweepSpacingMs);
const ttlSeconds = Math.ceil((spacingMs * CANDIDATE_TTL_SWEEP_MULTIPLIER + input.graceMs * 2) / 1000);
return Math.min(MAX_CANDIDATE_TTL_SECONDS, Math.max(MIN_CANDIDATE_TTL_SECONDS, ttlSeconds));
}
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
const VOICE_RECONCILIATION_LAST_SWEEP_KEY = 'voice:reconcile:last-sweep-at';
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
@@ -115,8 +130,7 @@ export class VoiceReconciliationWorker {
private readonly cadenceTtlSeconds: number;
private readonly gatewayOnlyGraceMs: number;
private readonly liveKitOnlyGraceMs: number;
private readonly gatewayOnlyCandidateTtlSeconds: number;
private readonly liveKitOnlyCandidateTtlSeconds: number;
private observedSweepSpacingMs = 0;
private intervalHandle: NodeJS.Timeout | null = null;
private reconciling = false;
private reconciliationLockLost = false;
@@ -136,14 +150,6 @@ export class VoiceReconciliationWorker {
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
this.gatewayOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.gatewayOnlyGraceMs * 4) / 1000),
);
this.liveKitOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.liveKitOnlyGraceMs * 4) / 1000),
);
}
start(): void {
@@ -156,6 +162,7 @@ export class VoiceReconciliationWorker {
intervalMs: this.intervalMs,
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
},
'Starting VoiceReconciliationWorker',
);
@@ -175,6 +182,7 @@ export class VoiceReconciliationWorker {
async reconcile(): Promise<void> {
const startTime = Date.now();
await this.recordSweepSpacing(startTime);
const discovery = await this.discoverActiveRooms();
this.logger.info(
{
@@ -233,6 +241,8 @@ export class VoiceReconciliationWorker {
const durationMs = Date.now() - startTime;
this.logger.info(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
roomsChecked,
totalConfirmed,
totalRepaired,
@@ -300,6 +310,37 @@ export class VoiceReconciliationWorker {
}
}
private candidateTtlSeconds(graceMs: number): number {
return candidateTtlSecondsFor({
intervalMs: this.intervalMs,
observedSweepSpacingMs: this.observedSweepSpacingMs,
graceMs,
});
}
private async recordSweepSpacing(startedAt: number): Promise<void> {
try {
const previous = await this.kvClient.get(VOICE_RECONCILIATION_LAST_SWEEP_KEY);
const previousAt = previous === null ? Number.NaN : Number(previous);
if (Number.isFinite(previousAt) && startedAt > previousAt) {
this.observedSweepSpacingMs = Math.max(this.observedSweepSpacingMs, startedAt - previousAt);
const gatewayOnlyCandidateTtlSeconds = this.candidateTtlSeconds(this.gatewayOnlyGraceMs);
if (this.observedSweepSpacingMs >= gatewayOnlyCandidateTtlSeconds * 1000) {
this.logger.warn(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds,
},
'Reconciliation sweeps are further apart than the candidate TTL; divergent voice states will be deferred forever',
);
}
}
await this.kvClient.setex(VOICE_RECONCILIATION_LAST_SWEEP_KEY, LAST_SWEEP_KEY_TTL_SECONDS, String(startedAt));
} catch (error) {
this.logger.warn({error}, 'Failed to record reconciliation sweep spacing');
}
}
private async acquireCadenceLease(): Promise<boolean> {
try {
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
@@ -993,7 +1034,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.gatewayOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -1024,7 +1065,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.liveKitOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.liveKitOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -243,6 +243,12 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
voiceRoomStore,
kvClient,
logger: Logger,
intervalMs: Config.worker.voiceReconciliation.intervalMs,
staggerDelayMs: Config.worker.voiceReconciliation.staggerDelayMs,
lockTtlSeconds: Config.worker.voiceReconciliation.lockTtlSeconds,
cadenceTtlSeconds: Config.worker.voiceReconciliation.cadenceTtlSeconds,
gatewayOnlyGraceMs: Config.worker.voiceReconciliation.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: Config.worker.voiceReconciliation.liveKitOnlyGraceMs,
})
: null;
if (Config.voice.enabled && voiceTopology !== null) {
@@ -52,6 +52,15 @@ source edits in this package.
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
510 kbps maximum average bitrate in local offers and remote answers.
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
`elementVolume` with a truthiness check, so a track deliberately held at `0`
came back at full volume whenever it was re-attached or its Web Audio graph
was rebuilt. All three guards now test `!== undefined`. Note that remote
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
`webAudioMix` must stay unconditional.
## Updating from upstream
1. Check the upstream changelog for the target version.
@@ -64,7 +64,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
}
getVolume(): number {
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
return this.elementVolume;
}
if (isReactNative()) {
@@ -113,7 +113,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
element.muted = true;
}
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
this.setVolume(this.elementVolume);
}
@@ -169,7 +169,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
lastNode.connect(this.gainNode);
this.gainNode.connect(context.destination);
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
this.gainNode.gain.setTargetAtTime(this.elementVolume, 0, 0.1);
}
@@ -332,6 +332,7 @@ export const GuildListItemPresentation = forwardRef<
role="button"
tabIndex={0}
data-guild-list-focus-item="true"
data-guild-id={guild.id}
onLongPress={onLongPress}
disabled={false}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-list-item.click"
@@ -28,6 +28,10 @@ import {
SearchIndexingState,
} from '@app/features/channel/components/channel_search_results/SearchResultsStateViews';
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
} from '@app/features/channel/components/SearchResultGrouping';
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
import {areSegmentsEqual} from '@app/features/channel/components/SearchResultsUtils';
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from '@app/features/channel/components/SearchScopeOptions';
@@ -134,35 +138,27 @@ export const ChannelSearchResults = observer(
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
[successChannels],
);
const messagesByChannel = useMemo(() => {
const grouped = new Map<string, Array<Message>>();
for (const message of successResults) {
if (!grouped.has(message.channelId)) {
grouped.set(message.channelId, []);
}
grouped.get(message.channelId)!.push(message);
}
return grouped;
}, [successResults]);
const resultGroups = useMemo(() => buildSearchResultGroups(successResults), [successResults]);
const resultGroupsByMessageId = useMemo(() => buildSearchResultGroupsByMessageId(resultGroups), [resultGroups]);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(successResults);
const spammerOverrideVersion = LocalUserSpamOverride.version;
const collapsedMessageVisibility = useMemo(
() => ({
isMessageRevealed: (message: Message) => {
const channelMessages = messagesByChannel.get(message.channelId);
if (!channelMessages) return false;
const resultGroup = resultGroupsByMessageId.get(message.id);
if (!resultGroup) return false;
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
if (!messageChannel) return false;
const groupKey = getCollapsedMessageGroupKey({
channel: messageChannel,
messages: channelMessages,
messages: resultGroup.messages,
messageId: message.id,
treatSpam: true,
});
return groupKey != null && revealedGroupKeys.has(groupKey);
},
}),
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
);
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
setRevealedGroupKeys((current) => {
@@ -606,9 +602,9 @@ export const ChannelSearchResults = observer(
data-message-selection-root="true"
data-flx="channel.channel-search-results.render-content.results-scroller"
>
{Array.from(messagesByChannel.entries()).map(([resultChannelId, messages]) => {
{resultGroups.map((resultGroup) => {
const renderData = getSearchResultChannelRenderData(
resultChannelId,
resultGroup.channelId,
searchChannelsById,
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
);
@@ -649,7 +645,7 @@ export const ChannelSearchResults = observer(
</div>
);
return (
<React.Fragment key={resultChannelId}>
<React.Fragment key={resultGroup.key}>
<MessageContextPrefix
channel={messageChannel}
showGuildMeta={showGuildMeta}
@@ -660,7 +656,7 @@ export const ChannelSearchResults = observer(
/>
<SearchResultMessageList
channel={messageChannel}
messages={messages}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
@@ -725,7 +721,7 @@ export const ChannelSearchResults = observer(
handlePaginationJump,
onCopySelectedMessages,
collapsedMessageVisibility,
messagesByChannel,
resultGroups,
searchChannelsById,
revealedGroupKeys,
handleCollapsedGroupRevealChange,
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {describe, expect, it} from 'vitest';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from './SearchResultGrouping';
const message = (id: string, channelId: string): Message => ({id, channelId}) as Message;
describe('SearchResultGrouping', () => {
it('keeps the order the server returned instead of bucketing per channel', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(groups.map((group) => group.messages.map((result) => result.id))).toEqual([['500'], ['400'], ['300']]);
expect(groups.map((group) => group.channelId)).toEqual(['general', 'random', 'general']);
});
it('merges consecutive results from the same channel into one group', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('490', 'general'),
message('400', 'random'),
]);
expect(groups).toHaveLength(2);
expect(groups[0].messages.map((result) => result.id)).toEqual(['500', '490']);
expect(groups[1].messages.map((result) => result.id)).toEqual(['400']);
});
it('gives every group a unique key even when a channel appears more than once', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(new Set(groups.map((group) => group.key)).size).toBe(3);
});
it('indexes each message to the group it renders in', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
const groupsByMessageId = buildSearchResultGroupsByMessageId(groups);
expect(groupsByMessageId.get('500')).toBe(groups[0]);
expect(groupsByMessageId.get('400')).toBe(groups[1]);
expect(groupsByMessageId.get('300')).toBe(groups[2]);
});
it('counts distinct channels rather than groups', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(countSearchResultChannels(groups)).toBe(2);
expect(countSearchResultChannels(buildSearchResultGroups([]))).toBe(0);
});
});
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
export interface SearchResultGroup {
key: string;
channelId: string;
messages: Array<Message>;
}
export const buildSearchResultGroups = (messages: Array<Message>): Array<SearchResultGroup> => {
const groups: Array<SearchResultGroup> = [];
let currentGroup: SearchResultGroup | null = null;
for (const message of messages) {
if (!currentGroup || currentGroup.channelId !== message.channelId) {
currentGroup = {key: `${message.channelId}-${message.id}`, channelId: message.channelId, messages: []};
groups.push(currentGroup);
}
currentGroup.messages.push(message);
}
return groups;
};
export const buildSearchResultGroupsByMessageId = (
groups: Array<SearchResultGroup>,
): Map<string, SearchResultGroup> => {
const groupsByMessageId = new Map<string, SearchResultGroup>();
for (const group of groups) {
for (const message of group.messages) {
groupsByMessageId.set(message.id, group);
}
}
return groupsByMessageId;
};
export const countSearchResultChannels = (groups: Array<SearchResultGroup>): number => {
return new Set(groups.map((group) => group.channelId)).size;
};
@@ -8,6 +8,11 @@ import {Message as MessageComponent} from '@app/features/channel/components/Chan
import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from '@app/features/channel/components/SearchResultGrouping';
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
import {type ChannelSearchFilters, useChannelSearch} from '@app/features/channel/hooks/useChannelSearch';
import type {Channel} from '@app/features/channel/models/Channel';
@@ -216,22 +221,17 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
[successChannels],
);
const messagesByChannel = React.useMemo(() => {
const grouped = new Map<string, Array<Message>>();
for (const message of successResults) {
if (!grouped.has(message.channelId)) {
grouped.set(message.channelId, []);
}
grouped.get(message.channelId)!.push(message);
}
return grouped;
}, [successResults]);
const resultGroups = React.useMemo(() => buildSearchResultGroups(successResults), [successResults]);
const resultGroupsByMessageId = React.useMemo(
() => buildSearchResultGroupsByMessageId(resultGroups),
[resultGroups],
);
const spammerOverrideVersion = LocalUserSpamOverride.version;
const collapsedMessageVisibility = React.useMemo(
() => ({
isMessageRevealed: (message: Message) => {
const channelMessages = messagesByChannel.get(message.channelId);
if (!channelMessages) {
const resultGroup = resultGroupsByMessageId.get(message.id);
if (!resultGroup) {
return false;
}
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
@@ -240,14 +240,14 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
}
const groupKey = getCollapsedMessageGroupKey({
channel: messageChannel,
messages: channelMessages,
messages: resultGroup.messages,
messageId: message.id,
treatSpam: true,
});
return groupKey != null && revealedGroupKeys.has(groupKey);
},
}),
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
);
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
setRevealedGroupKeys((current) => {
@@ -598,7 +598,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
);
}
const totalPages = Math.max(1, Math.ceil(total / hitsPerPage));
const hasMultipleChannels = messagesByChannel.size > 1;
const hasMultipleChannels = countSearchResultChannels(resultGroups) > 1;
return (
<>
<CollapsedMessageVisibilityProvider
@@ -613,8 +613,9 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
data-message-selection-root="true"
data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller"
>
{Array.from(messagesByChannel.entries()).map(([channelId, messages]) => {
const messageChannel = searchChannelsById.get(channelId) ?? Channels.getChannel(channelId);
{resultGroups.map((resultGroup) => {
const messageChannel =
searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId);
if (!messageChannel) {
return null;
}
@@ -648,7 +649,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
</LongPressable>
);
return (
<React.Fragment key={channelId}>
<React.Fragment key={resultGroup.key}>
{hasMultipleChannels && (
<div
className={styles.channelSection}
@@ -667,7 +668,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
)}
<SearchResultMessageList
channel={messageChannel}
messages={messages}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
@@ -44,7 +44,6 @@ import {useVoiceCallFullscreenViewState} from '@app/features/voice/components/us
import {VoiceCallView} from '@app/features/voice/components/VoiceCallView';
import {VoiceE2EEIndicator} from '@app/features/voice/components/VoiceE2EEIndicator';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
import {useCompactCallExpansionState} from '@app/features/voice/hooks/useCompactCallExpansionState';
import {usePendingVoiceConnection} from '@app/features/voice/hooks/usePendingVoiceConnection';
import {getGuildVoiceCallExpansionKey} from '@app/features/voice/state/CompactVoiceCallHeight';
@@ -182,7 +181,7 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView
isVoiceChannel &&
connectedChannelId === channelId &&
(connectedGuildId ?? null) === (channel?.guildId ?? null) &&
(room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
room,
);
const matureContentGateReason = GuildMatureContentAgree.getGateReason({channelId, guildId});
const matureContentResolved = GuildMatureContentAgree.getResolvedContext({channelId, guildId});
@@ -3,7 +3,6 @@
import Authentication from '@app/features/auth/state/Authentication';
import type {Channel} from '@app/features/channel/models/Channel';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
import CallInitiator from '@app/features/voice/state/CallInitiator';
import CallState, {type Call} from '@app/features/voice/state/CallState';
@@ -40,9 +39,7 @@ export function useCallHeaderState(channel?: Channel | null): CallHeaderState {
const matchesConnectionContext = Boolean(
channelId && MediaEngine.channelId === channelId && (MediaEngine.guildId ?? null) === normalizedGuildId,
);
const isDeviceInRoomForChannelCall = Boolean(
matchesConnectionContext && (MediaEngine.room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
);
const isDeviceInRoomForChannelCall = Boolean(matchesConnectionContext && MediaEngine.room);
const isDeviceConnectingToChannelCall =
matchesConnectionContext && (MediaEngine.connecting || (MediaEngine.connected && !isDeviceInRoomForChannelCall));
const controlsVariant: CallHeaderControlsVariant = !callExistsAndOngoing
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -834,7 +834,7 @@ export async function forward(
logger.warn(`Forward send failed in channel ${channelId}`);
return false;
}
SlowmodeCommands.recordMessageSend(channelId);
SlowmodeCommands.confirmMessageSend(channelId, forwardedMessage.timestamp);
if (optionalMessage) {
const commentNonce = SnowflakeUtils.fromTimestamp(Date.now() + 1);
const commentMessage = await send(channelId, {
@@ -845,7 +845,7 @@ export async function forward(
logger.warn(`Forward comment send failed in channel ${channelId}`);
return false;
}
SlowmodeCommands.recordMessageSend(channelId);
SlowmodeCommands.confirmMessageSend(channelId, commentMessage.timestamp);
}
}
logger.debug('Successfully forwarded message to all channels');
@@ -24,6 +24,7 @@ import {
normalizeMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
@@ -66,9 +67,7 @@ type ScheduledMessageRequest = MessageCreateRequest & {
interface ApiErrorBody {
code?: number | string;
retry_after?: number;
message?: string;
details?: unknown;
}
export interface ScheduleMessageParams {
@@ -372,37 +371,6 @@ const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
return typeof error.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
};
function parseNestedRetryAfterSeconds(body: ApiErrorBody | undefined): number | undefined {
if (body === undefined || typeof body.details !== 'object' || body.details === null || Array.isArray(body.details)) {
return undefined;
}
const details = body.details as Record<string, unknown>;
const retry = details.retry;
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) return undefined;
const afterSeconds = (retry as Record<string, unknown>).after_seconds;
if (typeof afterSeconds !== 'number' || !Number.isFinite(afterSeconds) || afterSeconds <= 0) return undefined;
return afterSeconds;
}
function resolveRetryAfterSeconds(error: HttpError): number | undefined {
const body = getApiErrorBody(error);
const nestedRetryAfter = parseNestedRetryAfterSeconds(body);
if (nestedRetryAfter !== undefined) return nestedRetryAfter;
const bodyRetryAfter = body === undefined ? undefined : body.retry_after;
if (typeof bodyRetryAfter === 'number' && Number.isFinite(bodyRetryAfter) && bodyRetryAfter > 0) {
return bodyRetryAfter;
}
const responseHeaders: Record<string, string> | undefined = error.responseHeaders;
const header = responseHeaders === undefined ? undefined : responseHeaders['retry-after'];
if (header === undefined || header.trim() === '') return undefined;
const numeric = Number(header);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const deadline = Date.parse(header);
if (!Number.isFinite(deadline)) return undefined;
const remainingSeconds = (deadline - Date.now()) / 1000;
return remainingSeconds > 0 ? remainingSeconds : undefined;
}
function handleScheduleError(
i18n: I18n,
error: unknown,
@@ -430,8 +398,7 @@ function handleScheduleError(
return;
}
if (isSlowmodeError(error)) {
const retryAfterSeconds = resolveRetryAfterSeconds(error);
const retryAfterMs = SlowmodeCommands.retryAfterSecondsToMs(retryAfterSeconds);
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
@@ -490,8 +457,8 @@ function handleScheduleError(
}
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
const retryAfterSecondsValue = resolveRetryAfterSeconds(error);
const retryAfterSeconds = retryAfterSecondsValue === undefined ? undefined : Math.ceil(retryAfterSecondsValue);
const retryAfterMs = resolveRetryAfterMs(error);
const retryAfterSeconds = retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000);
ModalCommands.push(
modal(() => (
<MessageSendTooQuickModal
@@ -131,6 +131,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
referenced_message: referencedMessage?.toJSON(),
});
SlowmodeCommands.prepareMessageSend(channel.id);
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
void MessageCommands.send(channel.id, {
content: message.content,
nonce,
@@ -141,11 +142,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
stickers,
favoriteMemeId,
tts,
}).then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.recordMessageSend(channel.id);
}
});
})
.then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
return;
}
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
})
.catch(() => {
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
});
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
return true;
},
@@ -196,6 +203,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
});
SlowmodeCommands.prepareMessageSend(channel.id);
const allowedMentions: AllowedMentions = {replied_user: replyingMessage?.mentioning ?? true};
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
void MessageCommands.send(channel.id, {
content: messageData.content,
nonce,
@@ -207,11 +215,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
flags: 0,
stickers: messageData.stickers || [],
favoriteMemeId: sendOptions.favoriteMemeId,
}).then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.recordMessageSend(channel.id);
}
});
})
.then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
return;
}
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
})
.catch(() => {
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
});
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
},
[channel?.id, referencedMessage, replyingMessage],
@@ -38,6 +38,7 @@ import {
type MessageEditRequest,
normalizeMessageEditContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
@@ -141,9 +142,7 @@ export interface RetryError {
export interface ApiErrorBody {
code?: number | string;
retry_after?: number;
message?: string;
details?: unknown;
}
interface PresignedAttachmentUploadSinglepartResponse {
@@ -225,70 +224,25 @@ const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
};
interface MessageRateLimitRetry {
retryAfterSeconds: number | null;
retryAfterMs: number | null;
automaticRetryDelayMs: number | null;
}
function parsePositiveRetryAfterSeconds(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
function parseRetryAfterHeaderSeconds(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const deadline = Date.parse(value);
if (!Number.isFinite(deadline)) return null;
const remainingSeconds = (deadline - Date.now()) / 1000;
return remainingSeconds > 0 ? remainingSeconds : null;
}
function parseNestedRetryAfterSeconds(body: ApiErrorBody | undefined): number | null {
if (body === undefined || typeof body.details !== 'object' || body.details === null || Array.isArray(body.details)) {
return null;
}
const details = body.details as Record<string, unknown>;
const retry = details.retry;
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) return null;
const afterSeconds = (retry as Record<string, unknown>).after_seconds;
if (typeof afterSeconds !== 'number' || !Number.isFinite(afterSeconds) || afterSeconds <= 0) return null;
return afterSeconds;
}
function readRateLimitHeader(error: HttpError, name: string): string | undefined {
return error.responseHeaders[name.toLowerCase()];
}
function resolveMessageRateLimitRetry(error: HttpError): MessageRateLimitRetry {
const body = getApiErrorBody(error);
const candidates: Array<number> = [];
const nestedRetryAfter = parseNestedRetryAfterSeconds(body);
if (nestedRetryAfter !== null) candidates.push(nestedRetryAfter);
const bodyRetryAfter = body === undefined ? undefined : body.retry_after;
const parsedBodyRetryAfter = parsePositiveRetryAfterSeconds(bodyRetryAfter);
if (parsedBodyRetryAfter !== null) candidates.push(parsedBodyRetryAfter);
const parsedHeaderRetryAfter = parseRetryAfterHeaderSeconds(readRateLimitHeader(error, 'retry-after'));
if (parsedHeaderRetryAfter !== null) candidates.push(parsedHeaderRetryAfter);
const resetAfterHeader = readRateLimitHeader(error, 'x-ratelimit-reset-after');
if (resetAfterHeader !== undefined) {
const parsedResetAfter = parsePositiveRetryAfterSeconds(Number(resetAfterHeader));
if (parsedResetAfter !== null) candidates.push(parsedResetAfter);
}
if (candidates.length === 0) {
return {retryAfterSeconds: null, automaticRetryDelayMs: null};
}
const rawRetryAfter = Math.max(...candidates);
const retryAfterSeconds = Math.ceil(rawRetryAfter);
const retryAfterMs = Math.ceil(rawRetryAfter * 1000);
if (!Number.isSafeInteger(retryAfterSeconds) || !Number.isSafeInteger(retryAfterMs)) {
return {retryAfterSeconds: null, automaticRetryDelayMs: null};
const retryAfterMs = resolveRetryAfterMs(error);
if (retryAfterMs === null) {
return {retryAfterMs: null, automaticRetryDelayMs: null};
}
let automaticRetryDelayMs: number | null = null;
if (retryAfterMs <= MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_DELAY_MS) {
automaticRetryDelayMs = retryAfterMs;
}
return {retryAfterSeconds, automaticRetryDelayMs};
return {retryAfterMs, automaticRetryDelayMs};
}
function retryAfterMsToWholeSeconds(retryAfterMs: number | null): number | null {
if (retryAfterMs === null) return null;
return Math.ceil(retryAfterMs / 1000);
}
const isAbortError = (error: unknown): boolean => {
return error instanceof DOMException && error.name === 'AbortError';
@@ -1344,7 +1298,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
this.restoreFailedMessage(payload.channelId, payload.nonce);
}
completed(null, undefined, error);
this.handleRateLimitError(retry.retryAfterSeconds);
this.handleRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private handleSendError(
@@ -1425,9 +1379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
);
} else if (error instanceof HttpError && isSlowmodeError(error)) {
const retry = resolveMessageRateLimitRetry(error);
const retryAfterMs = SlowmodeCommands.retryAfterSecondsToMs(
retry.retryAfterSeconds === null ? undefined : retry.retryAfterSeconds,
);
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(retry.retryAfterMs);
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
@@ -1579,7 +1531,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
return;
}
completed(null, undefined, error);
this.handleEditRateLimitError(retry.retryAfterSeconds);
this.handleEditRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private showEditErrorModal(error: HttpError): void {
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {describe, expect, it} from 'vitest';
function slowmodeRejection(body: unknown, responseHeaders: Record<string, string>): HttpError {
return new HttpError({
method: 'POST',
path: '/channels/1234567890123456789/messages',
status: 400,
body,
responseHeaders,
});
}
describe('resolveRetryAfterMs', () => {
it('uses the decimal seconds from the body instead of the Retry-After header', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 4.44},
{'retry-after': '5'},
);
expect(resolveRetryAfterMs(error)).toBe(4440);
});
it('never multiplies a millisecond Retry-After header into a longer window than the body', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 4.7},
{'retry-after': '4700'},
);
expect(resolveRetryAfterMs(error)).toBe(4700);
});
it('prefers a nested retry window over every other source', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 30, details: {retry: {after_seconds: 2.5}}},
{'retry-after': '30'},
);
expect(resolveRetryAfterMs(error)).toBe(2500);
});
it('falls back to the Retry-After header when the body carries no window', () => {
const error = slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {'retry-after': '3'});
expect(resolveRetryAfterMs(error)).toBe(3000);
});
it('falls back to the reset-after header when nothing else is present', () => {
const error = slowmodeRejection({code: APIErrorCodes.RATE_LIMITED}, {'x-ratelimit-reset-after': '1.25'});
expect(resolveRetryAfterMs(error)).toBe(1250);
});
it('reads an HTTP date Retry-After header as a remaining duration', () => {
const deadline = new Date(Date.now() + 4000).toUTCString();
const error = slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {'retry-after': deadline});
const retryAfterMs = resolveRetryAfterMs(error);
expect(retryAfterMs).not.toBeNull();
expect(retryAfterMs!).toBeGreaterThan(0);
expect(retryAfterMs!).toBeLessThanOrEqual(4000);
});
it('returns null when no retry window is advertised', () => {
expect(resolveRetryAfterMs(slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {}))).toBeNull();
});
});
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {HttpError} from '@app/features/platform/types/EndpointError';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSeconds(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
function parseNestedSeconds(body: Record<string, unknown> | undefined): number | null {
if (body === undefined || !isRecord(body.details)) return null;
const retry = body.details.retry;
if (!isRecord(retry)) return null;
return parseSeconds(retry.after_seconds);
}
function parseHeaderSeconds(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const numeric = Number(value);
if (Number.isFinite(numeric)) return parseSeconds(numeric);
const deadline = Date.parse(value);
if (!Number.isFinite(deadline)) return null;
return parseSeconds((deadline - Date.now()) / 1000);
}
function resolveRetryAfterSeconds(error: HttpError): number | null {
const body = isRecord(error.body) ? error.body : undefined;
const nestedSeconds = parseNestedSeconds(body);
if (nestedSeconds !== null) return nestedSeconds;
const bodySeconds = body === undefined ? null : parseSeconds(body.retry_after);
if (bodySeconds !== null) return bodySeconds;
const headerSeconds = parseHeaderSeconds(error.responseHeaders['retry-after']);
if (headerSeconds !== null) return headerSeconds;
return parseHeaderSeconds(error.responseHeaders['x-ratelimit-reset-after']);
}
export function resolveRetryAfterMs(error: HttpError): number | null {
const retryAfterSeconds = resolveRetryAfterSeconds(error);
if (retryAfterSeconds === null) return null;
const retryAfterMs = Math.ceil(retryAfterSeconds * 1000);
if (!Number.isSafeInteger(retryAfterMs)) return null;
return retryAfterMs;
}
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {VoiceEngineV2BridgeApi} from '@fluxer/voice_engine_v2/bridge';
import type {VoiceEngineV2BridgeHardwareEncoderApi} from '@fluxer/voice_engine_v2/bridge';
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
@@ -440,7 +440,7 @@ export interface ElectronAPI {
selectDisplayMediaSource: (requestId: string, sourceId: string | null, withAudio: boolean) => void;
virtmic: VirtmicApi;
nativeAudio: NativeAudioApi;
voiceEngine?: VoiceEngineV2BridgeApi;
voiceEngine?: VoiceEngineV2BridgeHardwareEncoderApi;
}
export type VirtmicUnavailableReason =
@@ -6,32 +6,47 @@ import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConsta
const MAX_RETRY_AFTER_MS = CHANNEL_RATE_LIMIT_PER_USER_MAX * 1000;
function clearSendScopedSticker(channelId: string): void {
ChannelSticker.clearPendingStickerOnMessageSend(channelId);
export interface PendingMessageSend {
readonly previousSendTimestamp: number | null;
readonly pendingSendTimestamp: number;
}
function markSlowmodeSend(channelId: string): void {
Slowmode.recordMessageSend(channelId);
function clearSendScopedSticker(channelId: string): void {
ChannelSticker.clearPendingStickerOnMessageSend(channelId);
}
export function prepareMessageSend(channelId: string): void {
clearSendScopedSticker(channelId);
}
export function recordMessageSend(channelId: string): void {
markSlowmodeSend(channelId);
export function recordPendingMessageSend(channelId: string): PendingMessageSend {
const previousSendTimestamp = Slowmode.getLastSendTimestamp(channelId);
const pendingSendTimestamp = Slowmode.recordMessageSend(channelId);
return {previousSendTimestamp, pendingSendTimestamp};
}
export function confirmMessageSend(channelId: string, sentAt: string, pending?: PendingMessageSend): void {
const timestamp = Date.parse(sentAt);
if (!Number.isFinite(timestamp)) return;
const floor = pending?.pendingSendTimestamp;
const anchored = floor == null ? timestamp : Math.max(timestamp, floor);
Slowmode.updateSlowmodeTimestamp(channelId, anchored);
}
export function discardPendingMessageSend(channelId: string, pending: PendingMessageSend): void {
if (Slowmode.getLastSendTimestamp(channelId) !== pending.pendingSendTimestamp) return;
Slowmode.updateSlowmodeTimestamp(channelId, pending.previousSendTimestamp);
}
export function updateSlowmodeRemaining(channelId: string, retryAfterMs: number): void {
Slowmode.updateSlowmodeRemaining(channelId, retryAfterMs);
}
export function retryAfterSecondsToMs(retryAfterSeconds: number | undefined): number {
if (retryAfterSeconds == null || !Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0) {
export function clampSlowmodeRetryAfterMs(retryAfterMs: number | null): number {
if (retryAfterMs == null || !Number.isSafeInteger(retryAfterMs) || retryAfterMs <= 0) {
return 0;
}
const retryAfterMs = Math.ceil(retryAfterSeconds * 1000);
if (!Number.isSafeInteger(retryAfterMs) || retryAfterMs > MAX_RETRY_AFTER_MS) {
if (retryAfterMs > MAX_RETRY_AFTER_MS) {
return 0;
}
return retryAfterMs;
@@ -52,7 +52,7 @@ class Slowmode {
this.pruneExpired(Date.now());
}
recordMessageSend(channelId: string): void {
recordMessageSend(channelId: string): number {
const now = Date.now();
this.pruneExpired(now);
const current = this.getEntry(channelId);
@@ -60,17 +60,22 @@ class Slowmode {
explicitExpiresAt: current.explicitExpiresAt,
lastSendTimestamp: now,
});
return now;
}
updateSlowmodeTimestamp(channelId: string, timestamp: number): void {
updateSlowmodeTimestamp(channelId: string, timestamp: number | null): void {
const now = Date.now();
if (!isValidTimestamp(timestamp, now)) return;
let nextTimestamp = timestamp;
if (nextTimestamp !== null) {
if (!isValidTimestamp(nextTimestamp, now)) return;
nextTimestamp = Math.min(nextTimestamp, now);
}
this.pruneExpired(now);
const current = this.getEntry(channelId);
if (current.lastSendTimestamp === timestamp) return;
if (current.lastSendTimestamp === nextTimestamp) return;
this.setEntry(channelId, {
explicitExpiresAt: current.explicitExpiresAt,
lastSendTimestamp: timestamp,
lastSendTimestamp: nextTimestamp,
});
}
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {HttpError} from '@app/features/platform/types/EndpointError';
import * as SlowmodeCommands from '@app/features/slowmode/commands/SlowmodeCommands';
import Slowmode from '@app/features/slowmode/state/Slowmode';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const CHANNEL_ID = '1234567890123456789';
const RATE_LIMIT_PER_USER = 5;
const SLOWMODE_WINDOW_MS = RATE_LIMIT_PER_USER * 1000;
const T0 = Date.UTC(2026, 7, 20, 12, 0, 0);
function at(offsetMs: number): void {
vi.setSystemTime(T0 + offsetMs);
}
function shownRemainingMs(): number {
return Slowmode.getSlowmodeRemaining(CHANNEL_ID, RATE_LIMIT_PER_USER);
}
function shownCountdownSeconds(): number {
return Math.ceil(shownRemainingMs() / 1000);
}
function composerBlocksSend(): boolean {
return shownRemainingMs() > 0;
}
function serverTimestamp(offsetMs: number): string {
return new Date(T0 + offsetMs).toISOString();
}
function slowmodeRejection(retryAfterDecimalSeconds: number, retryAfterHeader: string): HttpError {
return new HttpError({
method: 'POST',
path: `/channels/${CHANNEL_ID}/messages`,
status: 400,
body: {code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: retryAfterDecimalSeconds},
responseHeaders: {'retry-after': retryAfterHeader},
});
}
function applySlowmodeRejection(error: HttpError): number {
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
SlowmodeCommands.updateSlowmodeRemaining(CHANNEL_ID, retryAfterMs);
return retryAfterMs;
}
describe('slowmode while an earlier send is still pending', () => {
beforeEach(() => {
vi.useFakeTimers();
at(0);
Slowmode.clearChannel(CHANNEL_ID);
});
afterEach(() => {
Slowmode.clearChannel(CHANNEL_ID);
vi.useRealTimers();
});
it('blocks the second message while the first one is still pending', () => {
at(0);
expect(composerBlocksSend()).toBe(false);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(120);
expect(composerBlocksSend()).toBe(true);
expect(shownRemainingMs()).toBe(SLOWMODE_WINDOW_MS - 120);
});
it('anchors the window to the timestamp the server assigned the message', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
expect(Slowmode.getLastSendTimestamp(CHANNEL_ID)).toBe(T0 + 200);
expect(shownRemainingMs()).toBe(SLOWMODE_WINDOW_MS - 250);
});
it('never shows more than the channel setting across the reported ordering', () => {
const countdown: Array<{atMs: number; shownMs: number; shownSeconds: number}> = [];
const sample = (offsetMs: number): void => {
at(offsetMs);
countdown.push({atMs: offsetMs, shownMs: shownRemainingMs(), shownSeconds: shownCountdownSeconds()});
};
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
sample(0);
sample(120);
sample(449);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
sample(450);
sample(2000);
sample(5199);
sample(5200);
sample(10200);
for (const entry of countdown) {
expect(entry.shownMs).toBeLessThanOrEqual(SLOWMODE_WINDOW_MS);
expect(entry.shownSeconds).toBeLessThanOrEqual(RATE_LIMIT_PER_USER);
}
for (let index = 1; index < countdown.length; index++) {
expect(countdown[index]!.shownSeconds).toBeLessThanOrEqual(countdown[index - 1]!.shownSeconds);
}
expect(countdown.at(-3)!.shownMs).toBeGreaterThan(0);
expect(countdown.at(-2)!.shownMs).toBe(0);
expect(countdown.at(-1)!.shownMs).toBe(0);
});
it('keeps a server slowmode rejection inside the channel setting', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
at(759);
const beforeRejection = shownRemainingMs();
at(760);
const storedMs = applySlowmodeRejection(slowmodeRejection(4.44, '5'));
expect(storedMs).toBe(4440);
expect(shownRemainingMs()).toBe(SLOWMODE_WINDOW_MS - 560);
expect(shownRemainingMs()).toBeLessThan(beforeRejection);
at(5200);
expect(shownRemainingMs()).toBe(0);
expect(composerBlocksSend()).toBe(false);
});
it('does not inflate the window when the rejection header carries milliseconds', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
at(760);
const storedMs = applySlowmodeRejection(slowmodeRejection(4.7, '4700'));
expect(storedMs).toBe(4700);
expect(shownRemainingMs()).toBeLessThanOrEqual(SLOWMODE_WINDOW_MS);
at(5460);
expect(shownRemainingMs()).toBe(0);
});
it('releases the window when the pending send never reaches the server', () => {
at(0);
const pendingSend = SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(120);
expect(composerBlocksSend()).toBe(true);
at(300);
SlowmodeCommands.discardPendingMessageSend(CHANNEL_ID, pendingSend);
expect(Slowmode.getLastSendTimestamp(CHANNEL_ID)).toBeNull();
expect(composerBlocksSend()).toBe(false);
});
it('keeps a rejection window when the rejected send releases its own guess', () => {
at(0);
const pendingSend = SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(760);
applySlowmodeRejection(slowmodeRejection(4.44, '5'));
SlowmodeCommands.discardPendingMessageSend(CHANNEL_ID, pendingSend);
expect(Slowmode.getLastSendTimestamp(CHANNEL_ID)).toBeNull();
expect(shownRemainingMs()).toBe(4440);
});
it('ignores an anchor from a clock that runs behind the server', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(30_000));
expect(shownRemainingMs()).toBeLessThanOrEqual(SLOWMODE_WINDOW_MS);
at(5450);
expect(shownRemainingMs()).toBe(0);
});
});
describe('clock skew on the send anchor', () => {
const CHANNEL_ID = '900000000000000001';
const RATE_LIMIT_SECONDS = 5;
beforeEach(() => {
vi.useFakeTimers();
Slowmode.clearChannel(CHANNEL_ID);
});
afterEach(() => {
Slowmode.clearChannel(CHANNEL_ID);
vi.useRealTimers();
});
const remainingAfterAck = (clientAheadMs: number): number => {
vi.setSystemTime(new Date(1_000_000));
const pending = SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
const serverAcceptedAt = new Date(1_000_000 - clientAheadMs).toISOString();
vi.setSystemTime(new Date(1_000_450));
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverAcceptedAt, pending);
return Slowmode.getSlowmodeRemaining(CHANNEL_ID, RATE_LIMIT_SECONDS);
};
it('does not shorten the window when the client clock runs ahead of the server', () => {
for (const aheadMs of [0, 250, 1_000, 2_500, 4_800, 30_000, 3_600_000]) {
expect(remainingAfterAck(aheadMs)).toBe(4_550);
}
});
it('never lets the local guard reach zero while the window is live', () => {
for (const aheadMs of [4_800, 30_000, 3_600_000]) {
expect(remainingAfterAck(aheadMs)).toBeGreaterThan(0);
}
});
});
@@ -53,9 +53,3 @@ export async function getBackgroundImageURL(id: string): Promise<string | null>
if (!media?.dataUrl) return null;
return dataUrlToObjectUrl(media.dataUrl);
}
export async function getNativeBackgroundMediaSource(id: string): Promise<NativeBackgroundMediaSource | null> {
const resolveVoiceBackgroundMedia = getElectronAPI()?.resolveVoiceBackgroundMedia;
if (!resolveVoiceBackgroundMedia) return null;
return resolveVoiceBackgroundMedia(id);
}
@@ -169,8 +169,8 @@ const resolvePushToTalkReleaseDelayInput = (
).value;
};
const AUTO_GAIN_DESCRIPTION_DESCRIPTOR = msg({
message: 'Evens out your mic volume. Off when enhanced suppression is on.',
comment: 'Description for the automatic gain control toggle in the custom voice processing settings.',
message: 'Evens out your mic volume so you are not too quiet.',
comment: 'Description for the automatic gain control toggle in the voice processing settings.',
});
const INPUT_AND_OUTPUT_DESCRIPTOR = msg({
message: 'Input and output',
@@ -293,7 +293,6 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
},
];
const noiseSuppressionMethod = resolveNoiseSuppressionMethod(deepFilterNoiseSuppression, noiseSuppression);
const effectiveAutoGainControl = !deepFilterNoiseSuppression && autoGainControl;
const noiseSuppressionOptions: Array<ComboboxOption<NoiseSuppressionMethod>> = [
{value: 'enhanced', label: i18n._(NOISE_SUPPRESSION_ENHANCED_DESCRIPTOR)},
{value: 'standard', label: i18n._(NOISE_SUPPRESSION_STANDARD_DESCRIPTOR)},
@@ -449,6 +448,16 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
)}
</>
);
const renderAutoGainControlSwitch = () => (
<Switch
label={i18n._(VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR)}
description={i18n._(AUTO_GAIN_DESCRIPTION_DESCRIPTOR)}
value={autoGainControl}
onChange={(value) => VoiceSettingsCommands.update({autoGainControl: value})}
ariaLabel={i18n._(VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR)}
data-flx="user.voice-tab.render-auto-gain-control-switch.switch.update-auto-gain-control"
/>
);
const renderCustomProfile = () => (
<div className={styles.profileSubSection} data-flx="user.voice-tab.render-custom-profile.profile-sub-section">
{renderPttControls()}
@@ -507,15 +516,7 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
ariaLabel={i18n._(VOICE_ECHO_CANCELLATION_DESCRIPTOR)}
data-flx="user.voice-tab.render-custom-profile.switch.update--2"
/>
<Switch
label={i18n._(VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR)}
description={i18n._(AUTO_GAIN_DESCRIPTION_DESCRIPTOR)}
value={effectiveAutoGainControl}
disabled={deepFilterNoiseSuppression}
onChange={(value) => VoiceSettingsCommands.update({autoGainControl: value})}
ariaLabel={i18n._(VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR)}
data-flx="user.voice-tab.render-custom-profile.switch.update-auto-gain-control"
/>
{renderAutoGainControlSwitch()}
</div>
);
return (
@@ -662,6 +663,7 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
{voiceProcessingMode === 'voice' && (
<div className={styles.profileSubSection} data-flx="user.voice-tab.profile-sub-section">
{renderPttControls()}
{renderAutoGainControlSwitch()}
</div>
)}
{voiceProcessingMode === 'studio' && pttCombo?.key && isPushToTalk && (
@@ -682,7 +684,7 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
outputVolume,
echoCancellation,
noiseSuppression,
autoGainControl: effectiveAutoGainControl,
autoGainControl,
deepFilterNoiseSuppression,
deepFilterNoiseSuppressionLevel,
voiceProcessingMode,
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/features/platform/utils/AppLogger';
import {createVoiceSoftClipNode} from '@app/features/voice/engine/VoiceSharedAudioContext';
import {buildDeepFilterAudioChain, type DeepFilterAudioChain} from '@app/features/voice/utils/DeepFilterNoiseProcessor';
const logger = new Logger('MicTestAudioGraph');
@@ -11,6 +12,8 @@ export interface MicTestAudioGraph {
inputGain: GainNode;
delay: DelayNode;
outputGain: GainNode;
softClipInput: GainNode;
softClipOutput: AudioNode;
playbackTarget: AudioNode;
dispose: () => Promise<void>;
}
@@ -80,10 +83,14 @@ export async function createMicTestAudioGraph({
analyser.smoothingTimeConstant = 0.2;
delay.delayTime.value = playbackDelaySeconds;
outputGainNode.gain.value = outputGain;
const softClip = createVoiceSoftClipNode(audioContext);
const softClipInput = softClip?.input ?? outputGainNode;
const softClipOutput: AudioNode = softClip?.output ?? outputGainNode;
inputGainNode.connect(analyser);
analyser.connect(delay);
delay.connect(outputGainNode);
outputGainNode.connect(playbackTarget);
if (softClip) outputGainNode.connect(softClip.input);
softClipOutput.connect(playbackTarget);
const dispose = async () => {
source.disconnect();
@@ -91,6 +98,8 @@ export async function createMicTestAudioGraph({
inputGainNode.disconnect();
delay.disconnect();
outputGainNode.disconnect();
softClipInput.disconnect();
softClipOutput.disconnect();
passthroughDestination?.disconnect();
passthroughDestination?.stream.getTracks().forEach((track) => track.stop());
if (deepFilterChain) {
@@ -106,6 +115,8 @@ export async function createMicTestAudioGraph({
inputGain: inputGainNode,
delay,
outputGain: outputGainNode,
softClipInput,
softClipOutput,
playbackTarget,
dispose,
};
@@ -244,7 +244,7 @@ export const useMicTest = (settings: MicTestSettings) => {
playbackDestination.disconnect();
playbackDestination.stream.getTracks().forEach((track) => track.stop());
playbackDestinationRef.current = null;
graphRef.current.outputGain.disconnect();
graphRef.current.softClipOutput.disconnect();
playbackTarget = audioContext.destination;
const sinkableAudioContext = audioContext as SinkableAudioContext;
if (settings.outputDeviceId !== 'default' && sinkableAudioContext.setSinkId) {
@@ -254,7 +254,7 @@ export const useMicTest = (settings: MicTestSettings) => {
logger.warn('Failed to set mic test AudioContext output device', sinkError);
}
}
graphRef.current.outputGain.connect(playbackTarget);
graphRef.current.softClipOutput.connect(playbackTarget);
graphRef.current.playbackTarget = playbackTarget;
}
setIsTesting(true);
@@ -9,6 +9,7 @@ import {
} from '@app/features/voice/components/CallVolumeState';
import {MediaVerticalVolumeControl} from '@app/features/voice/components/media_player/components/MediaVerticalVolumeControl';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {VOICE_VOLUME_MAX_SLIDER_VOLUME} from '@app/features/voice/utils/VoiceVolumeUtils';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useRef} from 'react';
@@ -30,7 +31,9 @@ export const CallVolumeControl: React.FC<CallVolumeControlProps> = observer(func
lastNonZeroVolumeRef.current = resolveLastNonZeroCallVolume(outputVolume, lastNonZeroVolumeRef.current);
}, [outputVolume]);
const handleVolumeChange = useCallback((volume: number) => {
VoiceSettingsCommands.update({outputVolume: sliderVolumeToCallVolumePercent(volume)});
const nextPercent = sliderVolumeToCallVolumePercent(volume);
if (nextPercent === VoiceSettings.outputVolume) return;
VoiceSettingsCommands.update({outputVolume: nextPercent});
}, []);
const handleToggleMute = useCallback(() => {
VoiceSettingsCommands.update({
@@ -41,6 +44,7 @@ export const CallVolumeControl: React.FC<CallVolumeControlProps> = observer(func
<MediaVerticalVolumeControl
volume={callVolumePercentToSliderVolume(outputVolume)}
isMuted={outputVolume === 0}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleVolumeChange}
onToggleMute={handleToggleMute}
iconSize={iconSize}
@@ -23,6 +23,11 @@ describe('callVolumePercentToSliderVolume', () => {
it('clamps negative percent to zero', () => {
expect(callVolumePercentToSliderVolume(-10)).toBe(0);
});
it('reaches the boosted ceiling and clamps past it', () => {
expect(callVolumePercentToSliderVolume(200)).toBe(2);
expect(callVolumePercentToSliderVolume(250)).toBe(2);
});
});
describe('sliderVolumeToCallVolumePercent', () => {
@@ -34,7 +39,12 @@ describe('sliderVolumeToCallVolumePercent', () => {
it('clamps out-of-range slider values', () => {
expect(sliderVolumeToCallVolumePercent(-0.5)).toBe(0);
expect(sliderVolumeToCallVolumePercent(2)).toBe(100);
expect(sliderVolumeToCallVolumePercent(2.5)).toBe(200);
});
it('maps the boosted range above unity', () => {
expect(sliderVolumeToCallVolumePercent(1.5)).toBe(150);
expect(sliderVolumeToCallVolumePercent(2)).toBe(200);
});
it('falls back to the default for non-finite input', () => {
@@ -70,3 +80,18 @@ describe('resolveCallVolumeMuteToggle', () => {
expect(resolveCallVolumeMuteToggle(0, 0)).toBe(CALL_VOLUME_DEFAULT_PERCENT);
});
});
describe('call volume round trip', () => {
it('preserves a stored boosted volume through the slider', () => {
expect(sliderVolumeToCallVolumePercent(callVolumePercentToSliderVolume(200))).toBe(200);
});
it('steps a boosted volume down by one keyboard step instead of collapsing to unity', () => {
expect(sliderVolumeToCallVolumePercent(Math.max(0, callVolumePercentToSliderVolume(200) - 0.1))).toBe(190);
});
it('restores a boosted volume after a mute toggle', () => {
expect(resolveCallVolumeMuteToggle(200, 200)).toBe(0);
expect(resolveCallVolumeMuteToggle(0, resolveLastNonZeroCallVolume(0, 200))).toBe(200);
});
});
@@ -1,20 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {VOICE_VOLUME_MAX_PERCENT} from '@app/features/voice/utils/VoiceVolumeUtils';
export const CALL_VOLUME_DEFAULT_PERCENT = 100;
export const CALL_VOLUME_SLIDER_MAX_PERCENT = 100;
export const CALL_VOLUME_UNITY_PERCENT = 100;
export const CALL_VOLUME_SLIDER_MAX_PERCENT = VOICE_VOLUME_MAX_PERCENT;
export function callVolumePercentToSliderVolume(percent: number): number {
if (!Number.isFinite(percent)) {
return CALL_VOLUME_DEFAULT_PERCENT / CALL_VOLUME_SLIDER_MAX_PERCENT;
return CALL_VOLUME_DEFAULT_PERCENT / CALL_VOLUME_UNITY_PERCENT;
}
return Math.max(0, percent / CALL_VOLUME_SLIDER_MAX_PERCENT);
const clamped = Math.max(0, Math.min(CALL_VOLUME_SLIDER_MAX_PERCENT, percent));
return clamped / CALL_VOLUME_UNITY_PERCENT;
}
export function sliderVolumeToCallVolumePercent(volume: number): number {
if (!Number.isFinite(volume)) {
return CALL_VOLUME_DEFAULT_PERCENT;
}
const percent = Math.round(volume * CALL_VOLUME_SLIDER_MAX_PERCENT);
const percent = Math.round(volume * CALL_VOLUME_UNITY_PERCENT);
return Math.max(0, Math.min(CALL_VOLUME_SLIDER_MAX_PERCENT, percent));
}
@@ -4,7 +4,6 @@ import PiP from '@app/features/ui/state/PiP';
import {PiPOverlayInner} from '@app/features/voice/components/pip_overlay/PiPOverlayInner';
import {pipOverlayLogger} from '@app/features/voice/components/pip_overlay/shared';
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import {isVoiceEngineV2NativeProjectionActiveFromMediaEngine} from '@app/features/voice/engine/VoiceMediaEngineBridge';
import type {Room} from 'livekit-client';
import {observer} from 'mobx-react-lite';
import {useEffect, useState} from 'react';
@@ -19,7 +18,7 @@ export const PiPOverlay = observer(function PiPOverlay() {
useEffect(() => {
if (room) setActiveRoom(room);
}, [room]);
const renderRoom = isVoiceEngineV2NativeProjectionActiveFromMediaEngine() ? room : activeRoom;
const renderRoom = activeRoom;
const portalRoot = typeof document === 'undefined' ? null : document.body;
const blockReason = !portalRoot
? 'missing-portal-root'
@@ -327,11 +327,17 @@ export async function pushActiveStreamSettings(
interface StreamSettingsMenuContentProps {
applyToLiveStream?: boolean;
shareContext?: StreamSettingsShareContext;
shareContextResolved?: boolean;
displayShareEnvironment: DisplayShareEnvironment;
}
export const StreamSettingsMenuContent = observer(
({applyToLiveStream = true, shareContext = 'display', displayShareEnvironment}: StreamSettingsMenuContentProps) => {
({
applyToLiveStream = true,
shareContext = 'display',
shareContextResolved = true,
displayShareEnvironment,
}: StreamSettingsMenuContentProps) => {
const {i18n} = useLingui();
useMediaEngineVersion();
const hasHigherVideoQuality = useHasHigherVideoQuality();
@@ -511,16 +517,22 @@ export const StreamSettingsMenuContent = observer(
);
const handleCaptureAudioToggle = useCallback(
(checked: boolean) => {
if (isAppShare) {
VoiceSettingsCommands.update({shareAppAudio: checked, muteStreamAudio: !checked});
} else if (isDeviceShare) {
if (isDeviceShare) {
VoiceSettingsCommands.update({shareDeviceAudio: checked, muteStreamAudio: !checked});
} else if (!shareContextResolved) {
VoiceSettingsCommands.update({
shareAppAudio: checked,
shareDesktopAudio: checked,
muteStreamAudio: !checked,
});
} else if (isAppShare) {
VoiceSettingsCommands.update({shareAppAudio: checked, muteStreamAudio: !checked});
} else {
VoiceSettingsCommands.update({shareDesktopAudio: checked, muteStreamAudio: !checked});
}
runApply({audioSettingsChanged: true});
},
[isAppShare, isDeviceShare, runApply],
[isAppShare, isDeviceShare, shareContextResolved, runApply],
);
const handleHidePreviewToggle = useCallback((checked: boolean) => {
VoiceSettingsCommands.update({hideStreamPreview: checked});
@@ -68,6 +68,7 @@ import VoiceCallLayout from '@app/features/voice/state/VoiceCallLayout';
import {hasValidRoomForVoiceCallContext} from '@app/features/voice/utils/VoiceCallContext';
import {VOICE_CALL_DESCRIPTOR} from '@app/features/voice/utils/VoiceMessageDescriptors';
import {parseVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
import {VOICE_VOLUME_MAX_SLIDER_VOLUME} from '@app/features/voice/utils/VoiceVolumeUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
import {msg, plural} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -620,6 +621,7 @@ const VoiceCallViewInner = observer(
<MediaVerticalVolumeControl
volume={focusedStreamVolume / 100}
isMuted={isFocusedStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleFocusedStreamVolumeChange}
onToggleMute={handleFocusedStreamToggleMute}
iconSize={18}
@@ -400,6 +400,7 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
applyToLiveStream={isScreenShareEnabled}
displayShareEnvironment={displayShareEnvironment}
shareContext={ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'}
shareContextResolved={ActiveScreenShareSource.getSourceId() != null}
data-flx="voice.voice-control-bar.render-screen-share-menu.stream-settings-menu-content"
/>
<MenuGroup data-flx="voice.voice-control-bar.render-screen-share-menu.menu-group--2">
@@ -2,9 +2,6 @@
import styles from '@app/features/voice/components/VoiceE2EEIndicator.module.css';
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import NativeVoiceE2EEStore from '@app/features/voice/engine/native_voice_engine/NativeVoiceE2EEStore';
import {useStoreVersion} from '@app/features/voice/engine/Store';
import {isVoiceEngineV2NativeProjectionActiveFromMediaEngine} from '@app/features/voice/engine/VoiceMediaEngineBridge';
import {computeChannelE2EEStatus} from '@app/features/voice/state/ChannelE2EEStatus';
import {
VOICE_CALL_E2EE_BROKEN_DESCRIPTOR,
@@ -28,14 +25,9 @@ export const VoiceE2EEIndicator = observer(function VoiceE2EEIndicator({
}: VoiceE2EEIndicatorProps) {
const {i18n} = useLingui();
useMediaEngineVersion();
useStoreVersion(NativeVoiceE2EEStore);
void MediaEngine.getAllVoiceStates();
const gatewayStatus = computeChannelE2EEStatus(guildId, channelId, {emptyChannelStatus: 'encrypted'});
const nativeAggregate = NativeVoiceE2EEStore.aggregateStatus();
const status =
isVoiceEngineV2NativeProjectionActiveFromMediaEngine() && nativeAggregate !== 'none'
? nativeAggregate
: gatewayStatus;
const status = gatewayStatus;
if (status === 'none') return null;
const isEncrypted = status === 'encrypted';
const descriptor = isEncrypted
@@ -61,17 +61,12 @@ import {
useAutoVideoSubscription,
useEffectiveTrackRef,
useIntersection,
useNativeCameraSubscriptionQuality,
useScreenShareAudioPublication,
useScreensharePreviewUploader,
useScreenshareWatchSubscription,
useTileContextMenuActive,
} from '@app/features/voice/components/voice_participant_tile/hooks';
import LastFrameSnapshotCache from '@app/features/voice/components/voice_participant_tile/LastFrameSnapshotCache';
import {
NativeParticipantVideo,
useNativeParticipantVideoTrack,
} from '@app/features/voice/components/voice_participant_tile/NativeParticipantVideo';
import {ScreenSharePlaceholder} from '@app/features/voice/components/voice_participant_tile/ScreenSharePlaceholder';
import {
CAMERA_BUFFERING_DESCRIPTOR,
@@ -79,7 +74,6 @@ import {
CONNECTION_DESCRIPTOR,
DESKTOP_DEVICE_DESCRIPTOR,
getSourceDataAttr,
isAudioTrackWithVolume,
isCameraSource,
logger,
MOBILE_DEVICE_DESCRIPTOR,
@@ -102,10 +96,8 @@ import {
} from '@app/features/voice/components/voice_participant_tile/shared';
import {WatchStreamOverlay} from '@app/features/voice/components/voice_participant_tile/WatchStreamOverlay';
import MediaEngine, {useMediaEngineVersion, useVoiceEngineV2Model} from '@app/features/voice/engine/MediaEngineFacade';
import NativeVideoTileManager from '@app/features/voice/engine/native_voice_engine/NativeVideoTileManager';
import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSharePublicationMigration';
import {useStoreVersion} from '@app/features/voice/engine/Store';
import {isVoiceEngineV2NativeProjectionActiveFromMediaEngine} from '@app/features/voice/engine/VoiceMediaEngineBridge';
import {
selectVoiceMediaGraphDeferredStopKeys,
selectVoiceMediaGraphFailure,
@@ -118,11 +110,9 @@ import {
asVoiceTrackSource,
VoiceTrackSource,
} from '@app/features/voice/engine/VoiceTrackSource';
import voiceEngineV2AppDebugLoggingHostAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppDebugLoggingHostAdapter';
import {selectVoiceEngineV2AppEffectiveSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
import CallMediaPrefs from '@app/features/voice/state/CallMediaPrefs';
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
import ParticipantVolume from '@app/features/voice/state/ParticipantVolume';
import PopoutWindowManager, {getVoiceTilePopoutKey} from '@app/features/voice/state/PopoutWindowManager';
import {
getScreenShareWatchFailureForPublicationOperation,
@@ -145,7 +135,7 @@ import {
} from '@app/features/voice/utils/VoiceMessageDescriptors';
import {parseVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
import {isParticipantVoicePermissionMuted} from '@app/features/voice/utils/VoicePermissionUtils';
import {boostedVoiceVolumePercentToTrackVolume} from '@app/features/voice/utils/VoiceVolumeUtils';
import {VOICE_VOLUME_MAX_SLIDER_VOLUME} from '@app/features/voice/utils/VoiceVolumeUtils';
import {DEFAULT_ACCENT_COLOR} from '@fluxer/constants/src/AppConstants';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {msg, plural} from '@lingui/core/macro';
@@ -360,26 +350,16 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
isScreenShare,
);
const hasScreenShareAudio = Boolean(screenShareAudioPublication);
const isNativeEngine = isVoiceEngineV2NativeProjectionActiveFromMediaEngine();
const nativeParticipantSid = isNativeEngine ? (MediaEngine.participants[identity]?.sid ?? participant.sid ?? '') : '';
const nativeVideoSource = asPinnableVoiceTrackSource(trackRef.source);
const nativeVideoTrack = useNativeParticipantVideoTrack(nativeParticipantSid, nativeVideoSource, identity);
const hasNativeVideo = nativeVideoTrack != null;
const hasNativeVideoFrame = (nativeVideoTrack?.width ?? 0) > 0 && (nativeVideoTrack?.height ?? 0) > 0;
const hasVideo = useMemo(() => {
if (isNativeEngine) {
const hasRenderableNativeVideo = hasNativeVideo;
return hasRenderableNativeVideo && !cameraLocallyDisabled;
}
if (!isTrackReference(trackRef)) return false;
const pub = trackRef.publication;
return Boolean(pub?.track) && !pub?.isMuted && !cameraLocallyDisabled;
}, [cameraLocallyDisabled, hasNativeVideo, isNativeEngine, trackRef]);
}, [cameraLocallyDisabled, trackRef]);
const videoRef = useRef<HTMLVideoElement | null>(null);
const liveKitCameraTrackSid = isTrackReference(trackRef) ? trackRef.publication.trackSid : '';
const cameraVideoFrameTrackKey = nativeVideoTrack?.trackSid ?? publication?.trackSid ?? liveKitCameraTrackSid ?? '';
const cameraVideoFrameTrackKey = publication?.trackSid ?? liveKitCameraTrackSid ?? '';
const cameraVideoFrameResetKey = isCameraTile
? `${isNativeEngine ? 'native' : 'livekit'}:${identity}:${cameraVideoFrameTrackKey}:${hasVideo ? 'video' : 'waiting'}`
? `livekit:${identity}:${cameraVideoFrameTrackKey}:${hasVideo ? 'video' : 'waiting'}`
: '';
const hasRenderedCameraVideoFrame = useVideoRenderedFrame({
enabled: isCameraTile && hasVideo,
@@ -388,7 +368,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
});
const streamVolume = StreamAudioPrefs.getVolume(streamKey);
const isStreamMuted = StreamAudioPrefs.isMuted(streamKey);
const isParticipantLocallyMuted = ParticipantVolume.isLocalMuted(userId);
const hasStreamAudioPrefsEntry = StreamAudioPrefs.hasEntry(streamKey);
const isSubscribed = Boolean(publication?.isSubscribed);
const hasSubscribedScreenShareVideo = isSubscribed && hasVideo;
@@ -396,10 +375,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
isScreenShare && !isOwnScreenShare && ScreenSharePublicationMigration.isScreenShareBuffering(participant);
const shouldAutoSubscribe = allowAutoSubscribe && !isFocusedPlaceholderTile;
const {ref: tileRef, isIntersecting} = useIntersection<HTMLDivElement>(shouldAutoSubscribe);
const nativeCameraQuality = useNativeCameraSubscriptionQuality(
tileRef,
isNativeEngine && isCameraTile && shouldAutoSubscribe && isIntersecting,
);
useAutoVideoSubscription({
enabled: shouldAutoSubscribe,
trackRef,
@@ -407,7 +382,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
videoLocallyDisabled: cameraLocallyDisabled,
isLocalParticipant,
isScreenShare,
nativeCameraQuality,
});
useStoreVersion(voiceMediaGraphStore);
const graphSnapshot = voiceMediaGraphStore.getGraphSnapshot();
@@ -453,15 +427,14 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
ScreenShareWatchFailures.reportFailure({
streamKey,
participantIdentity: identity,
participantSid: nativeParticipantSid || undefined,
trackSid: publication?.trackSid ?? nativeVideoTrack?.trackSid,
trackSid: publication?.trackSid,
source: VoiceTrackSource.ScreenShare,
code: failure.code,
reason: failure.reason,
error,
});
},
[identity, isWatching, nativeParticipantSid, nativeVideoTrack?.trackSid, publication?.trackSid, streamKey],
[identity, isWatching, publication?.trackSid, streamKey],
);
useScreenshareWatchSubscription({
isScreenShare: isInteractiveScreenShareTile,
@@ -469,7 +442,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
userWantsToWatch: isWatching,
videoLocallyDisabled: false,
isOwnScreenShare,
audioEnabled: !isStreamMuted && !isParticipantLocallyMuted,
audioEnabled: !isStreamMuted,
audioPublication: screenShareAudioPublication,
streamKey,
onVideoSubscriptionError: reportVideoSubscriptionError,
@@ -480,20 +453,11 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
if (!isWatching) return;
const pub = screenShareAudioPublication;
if (!pub) return;
const track = pub.track;
if (isAudioTrackWithVolume(track)) {
try {
track.setVolume(boostedVoiceVolumePercentToTrackVolume(streamVolume));
} catch (err) {
logger.error('setVolume failed for stream audio', err);
}
}
const shouldEnable = !isStreamMuted && !isParticipantLocallyMuted;
const shouldEnable = !isStreamMuted;
logger.debug('Applying runtime screen share audio enabled state', {
trackSid: pub.trackSid,
isWatching,
isStreamMuted,
isParticipantLocallyMuted,
shouldEnable,
});
syncScreenSharePublication({
@@ -513,7 +477,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
screenShareAudioPublication,
streamVolume,
isStreamMuted,
isParticipantLocallyMuted,
userId,
]);
useEffect(() => {
@@ -545,7 +508,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
canFetchStreamPreview;
const {previewUrl, isPreviewLoading} = useStreamPreview(previewEnabled, streamKey);
const isStreamPlaceholder = isScreenShare && !isTrackReference(trackRef);
const screenShareTrackSid = publication?.trackSid ?? nativeVideoTrack?.trackSid ?? null;
const screenShareTrackSid = publication?.trackSid ?? null;
const trackInfo = useStreamTrackInfo(isScreenShare && !isFocusPresentationTile ? trackRef : null, {
nativeSource: isScreenShare ? VoiceTrackSource.ScreenShare : null,
nativeTrackSid: screenShareTrackSid,
@@ -562,12 +525,10 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
!isStreamPlaceholder,
streamKey,
participantIdentity: identity,
participantSid: nativeParticipantSid || undefined,
trackSid: screenShareTrackSid,
hasPublication: publication != null || nativeVideoTrack != null,
hasPublication: publication != null,
isPublicationDesired,
hasSubscribedVideo: hasSubscribedScreenShareVideo,
hasNativeFrame: hasNativeVideoFrame,
operationKey: isScreenShareRepublishBuffering ? `republish:${screenSharePublicationMigrationVersion}` : null,
videoRef,
});
@@ -580,12 +541,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
if (!lastFrameSnapshotKey) return;
if (!isScreenShareRepublishBuffering) return;
LastFrameSnapshotCache.captureFromVideoElement(lastFrameSnapshotKey, videoRef.current);
if (screenShareTrackSid && LastFrameSnapshotCache.getSnapshotUrl(lastFrameSnapshotKey) === null) {
LastFrameSnapshotCache.captureFromNativeFrame(
lastFrameSnapshotKey,
NativeVideoTileManager.getRetainedLastFrame(screenShareTrackSid),
);
}
}, [isScreenShareRepublishBuffering, lastFrameSnapshotKey, screenShareTrackSid]);
useEffect(() => {
if (!lastFrameSnapshotKey) return;
@@ -619,12 +574,10 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
const isLocalCameraRequested = isCameraTile && isOwnContent && LocalVoiceState.getSelfVideo();
const isCameraActive = selectVoiceParticipantTileCameraActive({
isCameraTile,
isNativeEngine,
isOwnContent,
isCameraPublicationActive,
isParticipantCameraActive,
isLocalCameraRequested,
hasNativeVideo,
});
const isCameraBuffering = shouldShowCameraBuffering({
isScreenShare,
@@ -635,50 +588,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
hasVideo,
hasRenderedVideoFrame: hasRenderedCameraVideoFrame,
});
useEffect(() => {
if (!isNativeEngine) return;
if (!isCameraTile) return;
voiceEngineV2AppDebugLoggingHostAdapter.recordNativeVideoDiagnostic('tile.camera_state', {
participantIdentity: identity,
participantSid: nativeParticipantSid,
source: nativeVideoSource,
trackSid: nativeVideoTrack?.trackSid ?? null,
hasNativeVideo,
hasNativeVideoFrame,
hasVideo,
hasRenderedVideoFrame: hasRenderedCameraVideoFrame,
isCameraActive,
isCameraPublicationActive,
isParticipantCameraActive,
isLocalCameraRequested,
isCameraBuffering,
cameraLocallyDisabled,
isOwnCameraHidden,
width: nativeVideoTrack?.width ?? null,
height: nativeVideoTrack?.height ?? null,
});
}, [
cameraLocallyDisabled,
hasNativeVideo,
hasNativeVideoFrame,
hasRenderedCameraVideoFrame,
hasVideo,
identity,
isCameraActive,
isCameraPublicationActive,
isCameraBuffering,
isCameraTile,
isNativeEngine,
isParticipantCameraActive,
isLocalCameraRequested,
isOwnCameraHidden,
isOwnContent,
nativeParticipantSid,
nativeVideoSource,
nativeVideoTrack?.height,
nativeVideoTrack?.trackSid,
nativeVideoTrack?.width,
]);
const cameraBufferingLabel = i18n._(CAMERA_BUFFERING_DESCRIPTOR);
const screenShareBufferingLabel = i18n._(STREAM_BUFFERING_DESCRIPTOR);
const watchFailedTitle = i18n._(WATCHING_FAILED_DESCRIPTOR);
@@ -854,10 +763,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
videoRef,
});
const hasVisibleMediaTile =
!isFocusedPlaceholderTile &&
(isNativeEngine ? hasVideo : isTrackReference(trackRef)) &&
hasVideo &&
!shouldHideOwnScreenShareVideo;
!isFocusedPlaceholderTile && isTrackReference(trackRef) && hasVideo && !shouldHideOwnScreenShareVideo;
const isAvatarOnlyTile = !hasVisibleMediaTile && !isScreenShare;
const shouldShowTileSpeakingIndicator =
!isFocusedPlaceholderTile && isActuallySpeaking && !isScreenShare && !isAvatarOnlyTile;
@@ -898,17 +804,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
/>
);
}
if (isNativeEngine && hasVideo && nativeVideoSource != null && !shouldHideOwnScreenShareVideo) {
return (
<NativeParticipantVideo
ref={videoRef}
participantSid={nativeParticipantSid}
participantIdentity={identity}
source={nativeVideoSource}
data-flx="voice.voice-participant-tile.media-node.native-participant-video"
/>
);
}
if (isTrackReference(trackRef) && hasVideo && !shouldHideOwnScreenShareVideo) {
return (
<VideoTrack
@@ -993,10 +888,8 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
hasVideo,
isActuallySpeaking,
isFocusedPlaceholderTile,
isNativeEngine,
isOwnScreenShare,
isScreenShare,
nativeParticipantSid,
participantUser,
placeholderStyle,
previewUrl,
@@ -1258,6 +1151,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
<MediaVerticalVolumeControl
volume={streamVolume / 100}
isMuted={isStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleStreamVolumeChange}
onToggleMute={handleStreamAudioToggle}
iconSize={14}
@@ -69,12 +69,10 @@ function cameraActiveSignals(
): VoiceParticipantTileCameraActiveSignals {
return {
isCameraTile: true,
isNativeEngine: false,
isOwnContent: false,
isCameraPublicationActive: false,
isParticipantCameraActive: false,
isLocalCameraRequested: false,
hasNativeVideo: false,
...overrides,
};
}
@@ -99,34 +97,20 @@ describe('VoiceParticipantTileStateMachine camera buffering state', () => {
});
describe('VoiceParticipantTileStateMachine camera active state', () => {
it('keeps stale participant camera flags from holding a stopped own native camera active', () => {
expect(
selectVoiceParticipantTileCameraActive(
cameraActiveSignals({
isNativeEngine: true,
isOwnContent: true,
isParticipantCameraActive: true,
}),
),
).toBe(false);
});
it('treats own native camera as active while local capture is requested or native video exists', () => {
expect(
selectVoiceParticipantTileCameraActive(
cameraActiveSignals({isNativeEngine: true, isOwnContent: true, isLocalCameraRequested: true}),
),
).toBe(true);
expect(
selectVoiceParticipantTileCameraActive(
cameraActiveSignals({isNativeEngine: true, isOwnContent: true, hasNativeVideo: true}),
),
).toBe(true);
});
it('preserves participant camera flags for remote camera tiles', () => {
expect(selectVoiceParticipantTileCameraActive(cameraActiveSignals({isParticipantCameraActive: true}))).toBe(true);
});
it('treats a local capture request as an active own camera tile', () => {
expect(
selectVoiceParticipantTileCameraActive(cameraActiveSignals({isOwnContent: true, isLocalCameraRequested: true})),
).toBe(true);
});
it('keeps a camera tile inactive without a publication, participant flag, or local request', () => {
expect(selectVoiceParticipantTileCameraActive(cameraActiveSignals())).toBe(false);
expect(selectVoiceParticipantTileCameraActive(cameraActiveSignals({isCameraTile: false}))).toBe(false);
});
});
describe('VoiceParticipantTileStateMachine graph-derived screen share state', () => {
@@ -33,12 +33,10 @@ export interface VoiceParticipantTileCameraBufferingSignals {
export interface VoiceParticipantTileCameraActiveSignals {
isCameraTile: boolean;
isNativeEngine: boolean;
isOwnContent: boolean;
isCameraPublicationActive: boolean;
isParticipantCameraActive: boolean;
isLocalCameraRequested: boolean;
hasNativeVideo: boolean;
}
type VoiceParticipantTileEvent = {
@@ -153,14 +151,10 @@ export function shouldShowCameraBuffering(signals: VoiceParticipantTileCameraBuf
export function selectVoiceParticipantTileCameraActive(signals: VoiceParticipantTileCameraActiveSignals): boolean {
if (!signals.isCameraTile) return false;
if (signals.isNativeEngine && signals.isOwnContent) {
if (signals.isLocalCameraRequested) return true;
return signals.hasNativeVideo;
}
if (signals.isCameraPublicationActive) return true;
if (signals.isParticipantCameraActive) return true;
if (signals.isLocalCameraRequested) return true;
return signals.hasNativeVideo;
return false;
}
export const voiceParticipantTileStateMachine = setup({
@@ -359,8 +359,7 @@ export const VoiceAudioSettingsMenu: React.FC<VoiceAudioSettingsMenuProps> = obs
data-flx="voice.voice-settings-menus.voice-audio-settings-menu.icon--2"
/>
}
checked={voiceSettings.autoGainControl && !deepFilterEnabled}
disabled={deepFilterEnabled}
checked={voiceSettings.autoGainControl}
onCheckedChange={(checked) => VoiceSettingsCommands.update({autoGainControl: checked})}
data-flx="voice.voice-settings-menus.voice-audio-settings-menu.checkbox-item--2"
>
@@ -281,7 +281,6 @@ export const VoiceAudioSettingsBottomSheet: React.FC<VoiceAudioSettingsBottomShe
/>
),
label: i18n._(VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR),
disabled: deepFilterEnabled,
onClick: () => {
VoiceSettingsCommands.update({autoGainControl: !voiceSettings.autoGainControl});
},
@@ -48,6 +48,7 @@ interface MediaVerticalVolumeControlProps {
iconSize?: number;
className?: string;
position?: 'above' | 'below';
maxVolume?: number;
}
function getVolumeIcon(volume: number, isMuted: boolean) {
@@ -73,6 +74,7 @@ export function MediaVerticalVolumeControl({
iconSize = 18,
className,
position = 'above',
maxVolume = 1,
}: MediaVerticalVolumeControlProps) {
const {i18n} = useLingui();
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -178,9 +180,9 @@ export function MediaVerticalVolumeControl({
}, [isDragging, scheduleClose]);
const handleSliderValueChange = useCallback(
(nextValue: number) => {
onVolumeChange(Math.max(0, Math.min(1, nextValue / 100)));
onVolumeChange(Math.max(0, Math.min(maxVolume, nextValue / 100)));
},
[onVolumeChange],
[maxVolume, onVolumeChange],
);
const handleSliderInteractionChange = useCallback(
(nextIsDragging: boolean) => {
@@ -209,7 +211,7 @@ export function MediaVerticalVolumeControl({
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
newVolume = Math.min(1, volume + step);
newVolume = Math.min(maxVolume, volume + step);
break;
case 'ArrowDown':
case 'ArrowLeft':
@@ -228,7 +230,7 @@ export function MediaVerticalVolumeControl({
}
onVolumeChange(newVolume);
},
[volume, onVolumeChange, onToggleMute],
[maxVolume, volume, onVolumeChange, onToggleMute],
);
useEffect(() => {
return () => {
@@ -264,7 +266,7 @@ export function MediaVerticalVolumeControl({
defaultValue={sliderValue}
factoryDefaultValue={100}
minValue={0}
maxValue={100}
maxValue={maxVolume * 100}
step={1}
value={sliderValue}
orientation="vertical"
@@ -20,14 +20,8 @@ import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting';
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
import styles from '@app/features/voice/components/modals/CameraPreviewModal.module.css';
import {
NATIVE_CAMERA_PREVIEW_RETRY_DELAY_MS,
selectNativeCameraPreviewFallback,
} from '@app/features/voice/components/modals/CameraPreviewSessionPolicy';
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import {VOICE_CAMERA_USER_LIMIT_REACHED_DESCRIPTOR} from '@app/features/voice/engine/media_engine_facade/shared';
import NativeVideoTileManager from '@app/features/voice/engine/native_voice_engine/NativeVideoTileManager';
import {useStoreVersion} from '@app/features/voice/engine/Store';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import {useCameraUserCapBlocked} from '@app/features/voice/hooks/useCameraUserCapBlocked';
import VoiceSettings, {
@@ -82,11 +76,6 @@ const YOU_DON_T_HAVE_PERMISSION_TO_TURN_ON_DESCRIPTOR = msg({
comment:
'Tooltip / error shown in the camera preview modal when the user lacks Video permission in the current channel. Tone stays plain.',
});
const EFFECTS_PREVIEW_UNAVAILABLE_DESCRIPTOR = msg({
message: 'Effects preview unavailable — showing unprocessed camera',
comment:
'Inline notice in the camera preview modal when the native effects preview cannot start and the raw camera feed is shown instead.',
});
const BLUR_STRENGTH_DESCRIPTOR = msg({
message: 'Blur strength',
comment: 'Slider label in the camera preview modal controlling how strong the background blur effect is.',
@@ -326,93 +315,6 @@ async function setupPreviewTrackAndProcessor(args: CameraPreviewTrackSetupArgs):
return 'ready';
}
function usePublishedNativeCameraPreviewStream(
enabled: boolean,
localParticipant: LocalParticipant | undefined,
): MediaStream | null {
useStoreVersion(NativeVideoTileManager);
if (!enabled) return null;
return MediaEngine.getNativeCameraLocalPreviewStream(localParticipant ?? null);
}
interface NativeCameraPreviewSession {
stream: MediaStream | null;
failed: boolean;
}
function useNativeCameraPreviewSession(enabled: boolean): NativeCameraPreviewSession {
useStoreVersion(NativeVideoTileManager);
const [trackSid, setTrackSid] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [retryNonce, setRetryNonce] = useState(0);
const retryAttemptRef = useRef(0);
const voiceSettings = VoiceSettings;
const videoDeviceId = voiceSettings.videoDeviceId;
const backgroundImageId = voiceSettings.backgroundImageId;
const cameraResolution = voiceSettings.cameraResolution;
const videoFrameRate = voiceSettings.videoFrameRate;
const backgroundBlurStrength = voiceSettings.backgroundBlurStrength;
useEffect(() => {
retryAttemptRef.current = 0;
}, [enabled, videoDeviceId, backgroundImageId, cameraResolution, videoFrameRate, backgroundBlurStrength]);
useEffect(() => {
if (!enabled) {
return;
}
let cancelled = false;
let retryTimeoutId: number | null = null;
const scheduleRetry = () => {
const decision = selectNativeCameraPreviewFallback({
sessionFailed: true,
backgroundEffectConfigured: backgroundImageId !== NONE_BACKGROUND_ID,
retryAttempt: retryAttemptRef.current,
});
if (!decision.shouldScheduleRetry) return;
retryAttemptRef.current += 1;
retryTimeoutId = window.setTimeout(() => {
retryTimeoutId = null;
setRetryNonce((nonce) => nonce + 1);
}, NATIVE_CAMERA_PREVIEW_RETRY_DELAY_MS);
};
setFailed(false);
MediaEngine.startNativeCameraPreviewSession()
.then((startedTrackSid) => {
if (cancelled) return;
setTrackSid(startedTrackSid);
if (startedTrackSid) {
retryAttemptRef.current = 0;
return;
}
setFailed(true);
scheduleRetry();
})
.catch((error) => {
logger.warn('Failed to start native camera preview session', {error});
if (cancelled) return;
setTrackSid(null);
setFailed(true);
scheduleRetry();
});
return () => {
cancelled = true;
if (retryTimeoutId !== null) {
window.clearTimeout(retryTimeoutId);
}
};
}, [enabled, retryNonce, videoDeviceId, backgroundImageId, cameraResolution, videoFrameRate, backgroundBlurStrength]);
useEffect(() => {
if (!enabled) {
return;
}
return () => {
setTrackSid(null);
void MediaEngine.stopNativeCameraPreviewSession();
};
}, [enabled]);
const stream = enabled && trackSid ? (NativeVideoTileManager.tracks[trackSid]?.stream ?? null) : null;
return {stream, failed};
}
interface CameraEffectStrengthSliderProps {
label: string;
value: number;
@@ -493,27 +395,13 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
const {i18n} = useLingui();
useMediaEngineVersion();
const {localParticipant, onEnabled, onEnableCamera, isCameraEnabled, showEnableCameraButton = true} = props;
const nativeCameraPublished = MediaEngine.isNativeCameraPublished();
const nativePreviewStream = usePublishedNativeCameraPreviewStream(nativeCameraPublished, localParticipant);
const hasPublishedNativePreview = nativePreviewStream != null;
const cameraAlreadyOn = isCameraEnabled === true || nativeCameraPublished;
const nativePreviewSessionEnabled =
!nativeCameraPublished && !hasPublishedNativePreview && MediaEngine.isNativeCameraPreviewSessionAvailable();
const nativePreviewSession = useNativeCameraPreviewSession(nativePreviewSessionEnabled);
const usesNativePreviewSession = nativePreviewSessionEnabled && !nativePreviewSession.failed;
const activeNativeStream = nativePreviewStream ?? (usesNativePreviewSession ? nativePreviewSession.stream : null);
const cameraAlreadyOn = isCameraEnabled === true;
const voiceBackgroundsAvailable = areVoiceBackgroundsAvailable();
const channelId = MediaEngine.channelId;
const guildId = MediaEngine.guildId;
const canStream = !localParticipant || !guildId || !channelId || Permission.can(Permissions.STREAM, {channelId});
const cameraCapBlocked = useCameraUserCapBlocked(cameraAlreadyOn);
const selectedBackgroundImageId = voiceBackgroundsAvailable ? VoiceSettings.backgroundImageId : NONE_BACKGROUND_ID;
const backgroundEffectConfigured = selectedBackgroundImageId !== NONE_BACKGROUND_ID;
const showEffectsUnavailableNotice = selectNativeCameraPreviewFallback({
sessionFailed: nativePreviewSessionEnabled && nativePreviewSession.failed,
backgroundEffectConfigured,
retryAttempt: 0,
}).showEffectsUnavailableNotice;
const [videoDevices, setVideoDevices] = useState<Array<MediaDeviceInfo>>([]);
const [status, setStatus] = useState<
'idle' | 'initializing' | 'ready' | 'error' | 'fixing' | 'fix-settling' | 'fix-switching-back'
@@ -580,12 +468,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
}
return;
}
if (hasPublishedNativePreview || usesNativePreviewSession || nativeCameraPublished) {
if (isCurrentInitialization()) {
setError(null);
}
return;
}
if (!isCurrentInitialization()) {
return;
}
@@ -654,16 +536,7 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
});
}
}
}, [
applyResolutionFix,
backgroundOverrideId,
hasPublishedNativePreview,
usesNativePreviewSession,
nativeCameraPublished,
i18n,
videoDevices,
voiceBackgroundsAvailable,
]);
}, [applyResolutionFix, backgroundOverrideId, i18n, videoDevices, voiceBackgroundsAvailable]);
const handleDeviceChange = useCallback((deviceId: string) => {
VoiceSettingsCommands.update({videoDeviceId: deviceId});
}, []);
@@ -679,7 +552,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
}, [voiceBackgroundsAvailable]);
const handleEnableCamera = useCallback(async () => {
try {
await MediaEngine.stopNativeCameraPreviewSession();
if (!localParticipant) {
await onEnableCamera?.();
onEnabled?.();
@@ -740,44 +612,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
};
}, [handleDeviceUpdate]);
useEffect(() => {
if (!activeNativeStream) return;
initializationGenerationRef.current++;
if (trackRef.current) {
trackRef.current.stop();
trackRef.current = null;
}
if (processorRef.current) {
processorRef.current.destroy().catch((error) => {
logger.warn('Failed to destroy browser camera preview processor after native preview became available', {
error,
});
});
processorRef.current = null;
}
const videoElement = videoRef.current;
if (!videoElement) return;
videoElement.muted = true;
videoElement.autoplay = true;
videoElement.playsInline = true;
videoElement.srcObject = activeNativeStream;
setStatus('ready');
setError(null);
const playResult = videoElement.play();
if (playResult && typeof playResult.catch === 'function') {
playResult.catch((error) => {
logger.debug('Native camera preview play() rejected', {error});
});
}
return () => {
if (videoRef.current?.srcObject === activeNativeStream) {
videoRef.current.srcObject = null;
}
};
}, [activeNativeStream]);
useEffect(() => {
if (hasPublishedNativePreview || usesNativePreviewSession || nativeCameraPublished) {
return;
}
const voiceSettings = VoiceSettings;
const backgroundImageId =
backgroundOverrideId ?? (voiceBackgroundsAvailable ? voiceSettings.backgroundImageId : NONE_BACKGROUND_ID);
@@ -794,9 +628,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
}, [
initializeCamera,
backgroundOverrideId,
hasPublishedNativePreview,
usesNativePreviewSession,
nativeCameraPublished,
VoiceSettings.videoDeviceId,
VoiceSettings.backgroundImageId,
VoiceSettings.mirrorCamera,
@@ -806,8 +637,7 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
]);
const voiceSettings = VoiceSettings;
const effectiveVideoDeviceId = resolveEffectiveDeviceId(voiceSettings.videoDeviceId, videoDevices) ?? 'default';
const previewVideoClassName =
activeNativeStream && voiceSettings.mirrorCamera ? `${styles.video} ${styles.videoMirrored}` : styles.video;
const previewVideoClassName = styles.video;
const videoDeviceOptions =
videoDevices.length > 0
? videoDevices.map((device) => ({
@@ -871,14 +701,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
)}
</div>
)}
{showEffectsUnavailableNotice && (
<div
className={styles.effectsNotice}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.effects-notice"
>
{i18n._(EFFECTS_PREVIEW_UNAVAILABLE_DESCRIPTOR)}
</div>
)}
<div
className={styles.videoContainer}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.video-container"

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