mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b4511ae4 | ||
|
|
137edc7cfb | ||
|
|
14e772a751 | ||
|
|
32afbf12d6 | ||
|
|
ffaf5119d8 | ||
|
|
10bc8c1efa | ||
|
|
85a03a9e39 | ||
|
|
51ee6567b4 | ||
|
|
090220a29d | ||
|
|
e7973b8be0 | ||
|
|
b5324c9223 | ||
|
|
edb8d80077 | ||
|
|
ddee116339 | ||
|
|
bdacaea4a8 | ||
|
|
9e28e02b5d | ||
|
|
3527dc95a2 | ||
|
|
27c7b2722d | ||
|
|
ba96f52ed6 | ||
|
|
2c8b3ff45c | ||
|
|
631bc2307a | ||
|
|
8f4f9a8601 | ||
|
|
1c920f966e | ||
|
|
2b1de38949 | ||
|
|
d5daf61dbd | ||
|
|
06c42ce02f | ||
|
|
4f21430880 | ||
|
|
9731bac40f | ||
|
|
b144e650f2 | ||
|
|
ef6536644c | ||
|
|
17eab43245 | ||
|
|
fbd7f1e3b8 | ||
|
|
25af7516a4 | ||
|
|
c020eded31 | ||
|
|
5331c0216a | ||
|
|
528777926c | ||
|
|
47b5c3d4f0 | ||
|
|
d9bfca66d6 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+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;
|
||||
};
|
||||
+20
-19
@@ -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});
|
||||
},
|
||||
|
||||
+7
-5
@@ -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
Reference in New Issue
Block a user