mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
ded51b65d3 | ||
|
|
ddc8837d4f | ||
|
|
df16957c95 | ||
|
|
f38b19d4bd | ||
|
|
f7cd4f2c74 | ||
|
|
a078392888 | ||
|
|
3060f23f8c | ||
|
|
6a5f0d4d29 | ||
|
|
91d989dc7c | ||
|
|
7c82804df6 | ||
|
|
b7de83f7fc | ||
|
|
97bd022bee | ||
|
|
62324df039 | ||
|
|
9f78b3d9a6 | ||
|
|
362a89f2b2 | ||
|
|
ce41960fcd | ||
|
|
2083eaddd6 | ||
|
|
d398ebc44b | ||
|
|
59887ad404 | ||
|
|
5aa283e8e0 | ||
|
|
d9ed256a4b | ||
|
|
a297f89b83 | ||
|
|
4a16921242 | ||
|
|
a6f83c4fb1 | ||
|
|
7b5c82c6cf | ||
|
|
30a61ce90f | ||
|
|
22bc2cab74 | ||
|
|
53df9a0d6d | ||
|
|
f9b7ec4d0b | ||
|
|
569abf57b7 | ||
|
|
ae8e32d809 | ||
|
|
a81b1abec7 | ||
|
|
8836565c32 | ||
|
|
a1b595d52f | ||
|
|
abb71ed558 | ||
|
|
c006d413ac | ||
|
|
fa11acae15 | ||
|
|
300f467ad0 | ||
|
|
698469fa96 | ||
|
|
591e9fe2ba | ||
|
|
d148b4e5b7 | ||
|
|
324f333bb5 |
@@ -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> {
|
||||
|
||||
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_instance_config_hides_self_host_setup_controls() {
|
||||
let app = setup().await;
|
||||
let body = get(&app, "/instance-config", &[]).await;
|
||||
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Registration Controls"), "{body}");
|
||||
assert!(body.contains("Runtime Integrations"), "{body}");
|
||||
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
|
||||
assert!(!body.contains("Public App Identity"), "{body}");
|
||||
assert!(!body.contains("Setup complete"), "{body}");
|
||||
assert!(!body.contains("Community & Policy"), "{body}");
|
||||
assert!(!body.contains("Single community"), "{body}");
|
||||
assert!(!body.contains("Direct messages & friends"), "{body}");
|
||||
assert!(!body.contains("Premium model"), "{body}");
|
||||
assert!(!body.contains("Optional services"), "{body}");
|
||||
assert!(!body.contains("Registration Fields"), "{body}");
|
||||
assert!(
|
||||
!body.contains("Collect date of birth during registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_public"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_policy"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
|
||||
let app = setup().await;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -93,19 +93,7 @@ export class MessageEditService {
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
if (data.message_snapshots !== undefined) {
|
||||
const isAuthor = message.authorId === userId;
|
||||
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!isAuthor && !canManage) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
|
||||
this.deps.persistenceService.updateSnapshotAttachments({
|
||||
message,
|
||||
snapshotEdits: data.message_snapshots ?? [],
|
||||
}),
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
return updatedMessage;
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
this.deps.validationService.validateMessageEditable(message);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -27402,9 +27402,9 @@
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 25,
|
||||
"maximum": 50,
|
||||
"format": "int32",
|
||||
"description": "Number of messages to return for this channel (1-25)"
|
||||
"description": "Number of messages to return for this channel (1-50)"
|
||||
},
|
||||
"before": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"after": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
@@ -33253,12 +33253,20 @@
|
||||
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refunded_amount_cents": {"type": "integer", "format": "int53"},
|
||||
"refunded_amount_cents": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
|
||||
},
|
||||
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
|
||||
"currency": {"type": "string"},
|
||||
"subscription_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Subscription that was cancelled along with the refund, when applicable"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -33269,7 +33277,8 @@
|
||||
"refunded_amount_cents",
|
||||
"invoice_amount_paid_cents",
|
||||
"currency",
|
||||
"subscription_id"
|
||||
"subscription_id",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
SelfServeRefundResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
@@ -206,6 +206,51 @@ export class StripeRefundService {
|
||||
};
|
||||
}
|
||||
|
||||
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
|
||||
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
|
||||
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
|
||||
}
|
||||
|
||||
private async finalizeIfSucceeded(refund: Stripe.Refund): Promise<void> {
|
||||
if (refund.status !== 'succeeded' || refund.metadata?.refund_kind !== 'self_serve') {
|
||||
return;
|
||||
}
|
||||
const userIdRaw = refund.metadata.user_id;
|
||||
if (!userIdRaw) {
|
||||
return;
|
||||
}
|
||||
let userId: UserID;
|
||||
try {
|
||||
userId = createUserID(BigInt(userIdRaw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user || user.firstRefundAt) {
|
||||
return;
|
||||
}
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
Logger.info(
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
|
||||
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
|
||||
);
|
||||
}
|
||||
|
||||
async handleRefundWebhookEvent(refund: Stripe.Refund): Promise<void> {
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
}
|
||||
|
||||
async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> {
|
||||
const stripe = this.ensureStripe();
|
||||
const user = await this.getRequiredUser(userId);
|
||||
@@ -220,6 +265,14 @@ export class StripeRefundService {
|
||||
if (this.cooldownExpiresAt(user)) {
|
||||
throw new StripeRefundCooldownActiveError();
|
||||
}
|
||||
const priorFailures = await this.countPriorTerminalFailures(target.invoiceId);
|
||||
const idempotencyKey = [
|
||||
'self-serve-refund',
|
||||
user.id.toString(),
|
||||
target.invoiceId,
|
||||
target.paymentIntentId ?? target.chargeId,
|
||||
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
|
||||
].join(':');
|
||||
let refund: Stripe.Response<Stripe.Refund>;
|
||||
try {
|
||||
refund = await stripe.refunds.create(
|
||||
@@ -232,11 +285,10 @@ export class StripeRefundService {
|
||||
invoice_id: target.invoiceId,
|
||||
refund_kind: 'self_serve',
|
||||
refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS),
|
||||
...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`,
|
||||
},
|
||||
{idempotencyKey},
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
@@ -254,36 +306,29 @@ export class StripeRefundService {
|
||||
} catch (mirrorErr) {
|
||||
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
|
||||
}
|
||||
if (target.subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId: target.subscriptionId},
|
||||
'Self-serve refund issued but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
const succeeded = refund.status === 'succeeded';
|
||||
Logger.info(
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
invoiceId: target.invoiceId,
|
||||
refundId: refund.id,
|
||||
status: refund.status,
|
||||
amountCents: refund.amount,
|
||||
subscriptionId: target.subscriptionId,
|
||||
},
|
||||
'Self-serve refund issued',
|
||||
succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider',
|
||||
);
|
||||
return {
|
||||
invoice_id: target.invoiceId,
|
||||
payment_intent_id: target.paymentIntentId,
|
||||
charge_id: target.chargeId,
|
||||
refund_id: refund.id,
|
||||
refunded_amount_cents: refund.amount,
|
||||
refunded_amount_cents: succeeded ? refund.amount : 0,
|
||||
invoice_amount_paid_cents: target.amountPaidCents,
|
||||
currency: target.currency,
|
||||
subscription_id: target.subscriptionId,
|
||||
subscription_id: succeeded ? target.subscriptionId : null,
|
||||
status: refund.status ?? 'pending',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
|
||||
import type {StripeGiftService} from './StripeGiftService';
|
||||
import {StripePaymentFraudService} from './StripePaymentFraudService';
|
||||
import type {StripePremiumService} from './StripePremiumService';
|
||||
import type {StripeRefundService} from './StripeRefundService';
|
||||
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
|
||||
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
|
||||
|
||||
@@ -61,6 +62,7 @@ export class StripeWebhookService {
|
||||
adminRepository: AdminRepository,
|
||||
snowflakeService: ISnowflakeService,
|
||||
private billingRepository: BillingRepository,
|
||||
private refundService: StripeRefundService,
|
||||
) {
|
||||
this.checkoutHandler = new StripeCheckoutWebhookHandler(
|
||||
stripe,
|
||||
@@ -336,6 +338,7 @@ export class StripeWebhookService {
|
||||
livemode: event.livemode,
|
||||
}),
|
||||
);
|
||||
await this.refundService.handleRefundWebhookEvent(r);
|
||||
break;
|
||||
}
|
||||
case 'invoice.created':
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
@@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
|
||||
});
|
||||
}
|
||||
|
||||
function refundCreateHandler() {
|
||||
function refundCreateHandler(opts?: {
|
||||
status?: 'succeeded' | 'pending' | 'failed';
|
||||
failureReason?: string;
|
||||
onRequest?: (idempotencyKey: string | null) => void;
|
||||
}) {
|
||||
return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => {
|
||||
opts?.onRequest?.(request.headers.get('idempotency-key'));
|
||||
const formData = await request.formData();
|
||||
const params = Object.fromEntries(formData.entries());
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const match = key.match(/^metadata\[(.+)\]$/);
|
||||
if (match) metadata[match[1]] = value as string;
|
||||
}
|
||||
return HttpResponse.json({
|
||||
id: 're_test_self_serve',
|
||||
object: 'refund',
|
||||
amount: Number.parseInt((params.amount as string) ?? '0', 10),
|
||||
currency: 'usd',
|
||||
status: 'succeeded',
|
||||
status: opts?.status ?? 'succeeded',
|
||||
failure_reason: opts?.failureReason ?? null,
|
||||
payment_intent: params.payment_intent ?? null,
|
||||
charge: params.charge ?? null,
|
||||
metadata,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
.expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY)
|
||||
.execute();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription while the refund is still pending at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'pending'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('pending');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription when the refund fails at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'failed', failureReason: 'unknown'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('failed');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
const idempotencyKeys: Array<string | null> = [];
|
||||
server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)}));
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
expect(idempotencyKeys).toHaveLength(2);
|
||||
expect(idempotencyKeys[0]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]);
|
||||
expect(idempotencyKeys[1]).toContain('retry-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
|
||||
@@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => {
|
||||
expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME);
|
||||
});
|
||||
});
|
||||
describe('refund.updated', () => {
|
||||
test('finalizes self-serve cooldown and cancels the subscription once the refund is confirmed succeeded', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
const subscriptionId = 'sub_test_webhook_finalize';
|
||||
await userRepository.patchUpsert(
|
||||
userId,
|
||||
{stripe_subscription_id: subscriptionId},
|
||||
(await userRepository.findUnique(userId))!.toRow(),
|
||||
);
|
||||
server.use(
|
||||
http.delete('https://api.stripe.com/v1/subscriptions/:id', ({params}) =>
|
||||
HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}),
|
||||
),
|
||||
);
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_finalize',
|
||||
status: 'succeeded',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_finalize',
|
||||
subscription_id: subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).not.toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown while the refund is still pending', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_pending',
|
||||
status: 'pending',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_pending',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('refund.failed', () => {
|
||||
test('does not finalize cooldown when the refund ultimately fails', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.failed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_failed',
|
||||
status: 'failed',
|
||||
failure_reason: 'unknown',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi
|
||||
import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService';
|
||||
import {StripeGiftService} from '../../stripe/services/StripeGiftService';
|
||||
import {StripePremiumService} from '../../stripe/services/StripePremiumService';
|
||||
import {StripeRefundService} from '../../stripe/services/StripeRefundService';
|
||||
import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService';
|
||||
import {StripeWebhookService} from '../../stripe/services/StripeWebhookService';
|
||||
import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate';
|
||||
@@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const ageVerificationService = deps.stripe
|
||||
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
|
||||
: null;
|
||||
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
|
||||
const webhookService = new StripeWebhookService(
|
||||
deps.stripe,
|
||||
checkoutService,
|
||||
@@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
deps.adminRepository,
|
||||
deps.snowflakeService,
|
||||
deps.billingRepository,
|
||||
refundService,
|
||||
);
|
||||
await webhookService.handleWebhook({body, signature});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
|
||||
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream';
|
||||
@@ -131,8 +135,6 @@ interface ChannelPreviewData {
|
||||
|
||||
const INITIAL_VISIBLE_CHANNELS = 10;
|
||||
const LOAD_MORE_CHUNK = 10;
|
||||
const UNREAD_PREVIEW_MESSAGE_LIMIT = 5;
|
||||
const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 10;
|
||||
|
||||
interface CacheEntry {
|
||||
cacheKey: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import {BulkMessageFetchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const buildBatch = (limit: number) => ({
|
||||
requests: Array.from({length: BULK_PREVIEW_CHANNEL_BATCH_SIZE}, (_, index) => ({
|
||||
channel_id: String(1000000000000000000n + BigInt(index)),
|
||||
limit,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('unread preview fetch budget', () => {
|
||||
it('keeps the anchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2))).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the unanchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT))).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a batch one channel wider than the client sends', () => {
|
||||
const oversized = buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2);
|
||||
oversized.requests.push({channel_id: '1000000000000000099', limit: UNREAD_PREVIEW_MESSAGE_LIMIT * 2});
|
||||
expect(() => BulkMessageFetchRequest.parse(oversized)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const UNREAD_PREVIEW_MESSAGE_LIMIT = 25;
|
||||
export const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 5;
|
||||
@@ -3,7 +3,3 @@
|
||||
.nativeDragRegion {
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .nativeDragRegion {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
+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"
|
||||
|
||||
@@ -5,7 +5,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import type {CSSProperties, FC, ReactNode} from 'react';
|
||||
import type {FC, ReactNode} from 'react';
|
||||
|
||||
const REVEAL_SPOILER_DESCRIPTOR = msg({
|
||||
message: 'Reveal spoiler',
|
||||
@@ -23,24 +23,14 @@ interface SpoilerOverlayProps {
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({
|
||||
hidden,
|
||||
onReveal,
|
||||
children,
|
||||
label,
|
||||
inline,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, children, label, inline, className}) => {
|
||||
const {i18n} = useLingui();
|
||||
const ariaLabel = label ?? i18n._(REVEAL_SPOILER_DESCRIPTOR);
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, inline && styles.inline, hidden && styles.hidden, className)}
|
||||
style={style}
|
||||
data-flx="app.spoiler-overlay.container"
|
||||
>
|
||||
<div className={styles.content} aria-hidden={hidden} data-flx="app.spoiler-overlay.content">
|
||||
|
||||
@@ -19,10 +19,12 @@ export interface PlaceholderMessageGroup {
|
||||
readonly usernameWidth: number;
|
||||
readonly timestampWidth: number;
|
||||
readonly attachment: PlaceholderAttachmentSize | null;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup {
|
||||
attachment: PlaceholderAttachmentSize | null;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PlaceholderSpecs {
|
||||
@@ -100,20 +102,20 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE);
|
||||
}
|
||||
const groupHeight = compact
|
||||
? MESSAGE_HEIGHT_COMPACT * lineCount
|
||||
: COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
|
||||
groups.push({
|
||||
lineWidths,
|
||||
usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE,
|
||||
timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE,
|
||||
attachment: null,
|
||||
height: groupHeight,
|
||||
});
|
||||
if (index > 0) {
|
||||
totalHeight += groupSpacing;
|
||||
}
|
||||
if (compact) {
|
||||
totalHeight += MESSAGE_HEIGHT_COMPACT * lineCount;
|
||||
} else {
|
||||
totalHeight += COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
|
||||
}
|
||||
totalHeight += groupHeight;
|
||||
}
|
||||
const availableGroupIndices = Array.from(Array(groups.length).keys());
|
||||
for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) {
|
||||
@@ -123,6 +125,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
|
||||
height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX),
|
||||
};
|
||||
groups[groupIndex].attachment = attachment;
|
||||
groups[groupIndex].height += attachment.height + ATTACHMENT_MARGIN;
|
||||
totalHeight += attachment.height + ATTACHMENT_MARGIN;
|
||||
}
|
||||
return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing};
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
.messageGroup {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
|
||||
@@ -52,7 +52,10 @@ function CozyScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-group-row
|
||||
@@ -170,7 +173,10 @@ function CompactScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-compact-messages
|
||||
|
||||
@@ -28,6 +28,8 @@ export const SUPPORT_EMAIL = '[email protected]';
|
||||
export const SUPPORT_EMAIL_MAILTO = `mailto:${SUPPORT_EMAIL}`;
|
||||
export const I18N_EMAIL = '[email protected]';
|
||||
export const I18N_EMAIL_MAILTO = `mailto:${I18N_EMAIL}`;
|
||||
export const I18N_WEBLATE_DOMAIN = 'weblate.fluxer.tools';
|
||||
export const I18N_WEBLATE_URL = `https://${I18N_WEBLATE_DOMAIN}`;
|
||||
export const EXAMPLE_DOMAIN = 'example.com';
|
||||
export const EXAMPLE_URL = `https://${EXAMPLE_DOMAIN}`;
|
||||
export const EXAMPLE_CALLBACK_URL = `${EXAMPLE_URL}/callback`;
|
||||
|
||||
@@ -16,7 +16,6 @@ class Initialization {
|
||||
state: InitializationState = InitializationState.LOADING;
|
||||
hasCompletedInitialLoad = false;
|
||||
error: string | null = null;
|
||||
readyPayload: unknown = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -46,29 +45,25 @@ class Initialization {
|
||||
setLoading(): void {
|
||||
this.state = InitializationState.LOADING;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setConnecting(): void {
|
||||
this.state = InitializationState.CONNECTING;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setReady(payload: unknown): void {
|
||||
setReady(): void {
|
||||
this.state = InitializationState.READY;
|
||||
this.hasCompletedInitialLoad = true;
|
||||
this.error = null;
|
||||
this.readyPayload = payload;
|
||||
}
|
||||
|
||||
@action
|
||||
setError(error: string): void {
|
||||
this.state = InitializationState.ERROR;
|
||||
this.error = error;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -76,7 +71,6 @@ class Initialization {
|
||||
this.state = InitializationState.LOADING;
|
||||
this.hasCompletedInitialLoad = false;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1375,7 +1375,10 @@ export const LexicalChannelTextareaContent = observer(
|
||||
showStickersButton={showStickersButton}
|
||||
showEmojiButton={showEmojiButton}
|
||||
showMessageSendButton={showMessageSendButton}
|
||||
showVoiceMessageButton={false}
|
||||
canRecordVoice={canAttachFilesInChannel(channel)}
|
||||
isEditingMessage={isEditingMessageInComposer || editingMessageId != null}
|
||||
hasPendingSticker={hasPendingSticker}
|
||||
voiceTooltipAnchorRef={contentAreaRef}
|
||||
expressionPickerOpen={expressionPickerOpen}
|
||||
selectedTab={selectedTab}
|
||||
isMobile={mobileLayout.enabled}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
export const ATTACHMENT_CARD_WIDTH = 400;
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/avif'];
|
||||
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
margin-top: var(--message-container-gap, 0.25rem);
|
||||
}
|
||||
|
||||
.urlEmbedSpoiler {
|
||||
div.urlEmbedSpoiler {
|
||||
width: 100%;
|
||||
max-width: 22.5rem;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@ const SpoileredUrlEmbed = observer(function SpoileredUrlEmbed({
|
||||
hidden={hidden}
|
||||
onReveal={reveal}
|
||||
className={styles.urlEmbedSpoiler}
|
||||
style={{width: '100%', maxWidth: 360}}
|
||||
data-flx="channel.message-attachments.spoilered-url-embed"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
|
||||
import styles from '@app/features/channel/components/MessageUploadProgress.module.css';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import {useMessageUpload} from '@app/features/messaging/hooks/useCloudUpload';
|
||||
@@ -77,8 +78,8 @@ export const MessageUploadProgress = observer(({attachment, message}: MessageUpl
|
||||
}
|
||||
: {
|
||||
display: 'grid',
|
||||
width: '400px',
|
||||
maxWidth: '400px',
|
||||
width: remFromPx(ATTACHMENT_CARD_WIDTH),
|
||||
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
|
||||
};
|
||||
return (
|
||||
<div style={containerStyles} data-flx="channel.message-upload-progress.div">
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -74,7 +74,11 @@ export function formatSlowmodeDuration(ms: number, locale: string): string {
|
||||
return formatDurationPart(totalSeconds, 'second', locale);
|
||||
}
|
||||
if (totalSeconds < SECONDS_PER_HOUR) {
|
||||
return formatDurationPart(Math.round(totalSeconds / SECONDS_PER_MINUTE), 'minute', locale);
|
||||
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
|
||||
const remainingSeconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const minutePart = formatDurationPart(minutes, 'minute', locale);
|
||||
if (remainingSeconds === 0) return minutePart;
|
||||
return `${minutePart} ${formatDurationPart(remainingSeconds, 'second', locale)}`;
|
||||
}
|
||||
if (totalSeconds < SECONDS_PER_DAY) {
|
||||
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
||||
|
||||
@@ -770,8 +770,46 @@ export const UserMessage = observer(() => {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(message.editedTimestamp || message.isEditing) &&
|
||||
(message.isEditing ? (
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--3">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
) : (
|
||||
<TimestampWithTooltip
|
||||
date={message.editedTimestamp!}
|
||||
className={styles.editedTimestamp}
|
||||
data-flx="channel.user-message.edited-timestamp--2"
|
||||
>
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--4">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
</TimestampWithTooltip>
|
||||
))}
|
||||
</AuthorHeading>
|
||||
)}
|
||||
{((!message.content && !isEditing) || (shouldHideContent && !isEditing)) &&
|
||||
shouldGroup &&
|
||||
(message.editedTimestamp || message.isEditing) &&
|
||||
(message.isEditing ? (
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--5">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
) : (
|
||||
<TimestampWithTooltip
|
||||
date={message.editedTimestamp!}
|
||||
className={styles.editedTimestamp}
|
||||
data-flx="channel.user-message.edited-timestamp--3"
|
||||
>
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--6">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
</TimestampWithTooltip>
|
||||
))}
|
||||
<MessageAttachments data-flx="channel.user-message.message-attachments--3" />
|
||||
{renderFailedFooter()}
|
||||
</div>
|
||||
|
||||
@@ -32,14 +32,18 @@ export const VerificationBarrier = observer(({channel}: Props) => {
|
||||
case VerificationFailureReason.ACCOUNT_TOO_NEW:
|
||||
return (
|
||||
<AccountTooNewBarrier
|
||||
initialTimeRemaining={verificationStatus.timeRemaining || 0}
|
||||
initialTimeRemaining={
|
||||
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
|
||||
}
|
||||
data-flx="channel.verification-barrier.account-too-new-barrier"
|
||||
/>
|
||||
);
|
||||
case VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH:
|
||||
return (
|
||||
<NotMemberLongEnoughBarrier
|
||||
initialTimeRemaining={verificationStatus.timeRemaining || 0}
|
||||
initialTimeRemaining={
|
||||
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
|
||||
}
|
||||
data-flx="channel.verification-barrier.not-member-long-enough-barrier"
|
||||
/>
|
||||
);
|
||||
@@ -50,7 +54,9 @@ export const VerificationBarrier = observer(({channel}: Props) => {
|
||||
case VerificationFailureReason.TIMED_OUT:
|
||||
return (
|
||||
<TimeoutBarrier
|
||||
initialTimeRemaining={verificationStatus.timeRemaining || 0}
|
||||
initialTimeRemaining={
|
||||
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
|
||||
}
|
||||
data-flx="channel.verification-barrier.timeout-barrier"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,34 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.voiceButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--textarea-button-height);
|
||||
height: var(--textarea-button-height);
|
||||
padding: 0;
|
||||
color: var(--text-primary-muted);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.voiceButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.voiceButtonIcon {
|
||||
width: var(--textarea-button-icon-size);
|
||||
height: var(--textarea-button-icon-size);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.holdTooltip {
|
||||
background-color: var(--brand-primary);
|
||||
color: var(--text-on-brand-primary);
|
||||
@@ -33,6 +61,10 @@
|
||||
}
|
||||
|
||||
.overlayInner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
@@ -133,6 +165,7 @@
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
|
||||
import layoutStyles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
|
||||
import styles from '@app/features/channel/components/VoiceMessageRecorder.module.css';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
||||
@@ -529,7 +528,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
||||
>
|
||||
<motion.button
|
||||
type="button"
|
||||
className={layoutStyles.mobileVoiceButton}
|
||||
className={styles.voiceButton}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -541,7 +540,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
||||
data-flx="channel.voice-message-recorder.button.pointer-down"
|
||||
>
|
||||
<MicrophoneIcon
|
||||
className={layoutStyles.mobileRightButtonIcon}
|
||||
className={styles.voiceButtonIcon}
|
||||
data-flx="channel.voice-message-recorder.microphone-icon"
|
||||
/>
|
||||
</motion.button>
|
||||
@@ -578,10 +577,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
||||
transition={resolvedOverlayMotion.transition}
|
||||
data-flx="channel.voice-message-recorder.recording-overlay"
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.overlayInner, layoutStyles.mobileTextareaWrapper)}
|
||||
data-flx="channel.voice-message-recorder.overlay-inner"
|
||||
>
|
||||
<div className={styles.overlayInner} data-flx="channel.voice-message-recorder.overlay-inner">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.iconButton, styles.trashButton)}
|
||||
@@ -610,7 +606,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.lockIndicatorWrapper, layoutStyles.mobileRightButtonContainer)}
|
||||
className={styles.lockIndicatorWrapper}
|
||||
data-flx="channel.voice-message-recorder.lock-indicator-wrapper"
|
||||
>
|
||||
<button
|
||||
|
||||
+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
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.embedIconPending {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.embedTitle {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH,
|
||||
BLUESKY_EMBED_MIN_CONTENT_WIDTH,
|
||||
calculateBlueskyMediaContainerWidth,
|
||||
calculateBlueskyOuterMaxWidth,
|
||||
EMBED_TEXT_OUTER_WIDTH,
|
||||
formatResponsiveEmbedWidth,
|
||||
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('formatResponsiveEmbedWidth', () => {
|
||||
it('caps in rem so the width tracks the root font size at any zoom', () => {
|
||||
expect(formatResponsiveEmbedWidth(EMBED_TEXT_OUTER_WIDTH)).toBe('min(100%, 32.25rem)');
|
||||
expect(formatResponsiveEmbedWidth(BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH)).toBe('min(100%, 27rem)');
|
||||
});
|
||||
|
||||
it('rounds to whole pixels before converting', () => {
|
||||
expect(formatResponsiveEmbedWidth(400.4)).toBe('min(100%, 25rem)');
|
||||
});
|
||||
|
||||
it('clamps non-positive and non-finite widths', () => {
|
||||
expect(formatResponsiveEmbedWidth(0)).toBe('min(100%, 0rem)');
|
||||
expect(formatResponsiveEmbedWidth(Number.NaN)).toBe('min(100%, 0rem)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBlueskyOuterMaxWidth', () => {
|
||||
it('falls back to the shared text width when there is no media', () => {
|
||||
expect(calculateBlueskyOuterMaxWidth({hasMedia: false, chromeWidth: 29})).toBe(EMBED_TEXT_OUTER_WIDTH);
|
||||
});
|
||||
|
||||
it('adds chrome around the media container width', () => {
|
||||
expect(calculateBlueskyOuterMaxWidth({mediaWidth: 480, hasMedia: true, chromeWidth: 29})).toBe(509);
|
||||
});
|
||||
|
||||
it('never goes below the minimum content width', () => {
|
||||
expect(calculateBlueskyMediaContainerWidth(120)).toBe(BLUESKY_EMBED_MIN_CONTENT_WIDTH);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
|
||||
@@ -28,7 +29,7 @@ export function isMediaOnlyEmbed(embed: MediaOnlyEmbedFields): boolean {
|
||||
|
||||
export const BLUESKY_EMBED_MIN_CONTENT_WIDTH = 320;
|
||||
export const BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH = 432;
|
||||
export const BLUESKY_EMBED_TEXT_OUTER_WIDTH = 516;
|
||||
export const EMBED_TEXT_OUTER_WIDTH = 516;
|
||||
const normalizePositiveWidth = (width: number): number => {
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
return 0;
|
||||
@@ -55,9 +56,9 @@ export function calculateBlueskyOuterMaxWidth({
|
||||
if (mediaContainerWidth !== undefined) {
|
||||
return mediaContainerWidth + normalizePositiveWidth(chromeWidth);
|
||||
}
|
||||
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : BLUESKY_EMBED_TEXT_OUTER_WIDTH;
|
||||
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : EMBED_TEXT_OUTER_WIDTH;
|
||||
}
|
||||
|
||||
export function formatResponsiveEmbedWidth(width: number): string {
|
||||
return `min(100%, ${normalizePositiveWidth(width)}px)`;
|
||||
return `min(100%, ${remFromPx(normalizePositiveWidth(width))})`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {TextualAttachmentPreview} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreview';
|
||||
import {splitFilename} from '@app/features/channel/components/embeds/EmbedUtils';
|
||||
import {canDeleteAttachmentUtil} from '@app/features/channel/components/MessageActionUtils';
|
||||
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
|
||||
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
|
||||
import {DELETE_ATTACHMENT_DESCRIPTOR, DOWNLOAD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
|
||||
@@ -181,8 +182,8 @@ export const AttachmentFile = observer(({attachment, message, isPreview}: Attach
|
||||
containerStyles = {
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
minWidth: 'min(400px, 100%)',
|
||||
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
|
||||
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
|
||||
};
|
||||
}
|
||||
const handleDownload = async (e: React.MouseEvent) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import styles from '@app/features/channel/components/embeds/ChannelEmbed.module.
|
||||
import {EmbedLink} from '@app/features/channel/components/embeds/channel_embed/EmbedLink';
|
||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {useCachedImageLoaded} from '@app/features/messaging/hooks/useCachedImageLoaded';
|
||||
import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import * as DateUtils from '@app/features/user/utils/DateFormatting';
|
||||
import type {EmbedAuthor, EmbedField, EmbedFooter} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
@@ -32,13 +33,14 @@ export const EmbedAuthorComponent: FC<{author?: EmbedAuthor}> = observer(({autho
|
||||
const iconSrc = author?.proxy_icon_url
|
||||
? buildMediaProxyURL(author.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
|
||||
: undefined;
|
||||
const iconLoaded = useCachedImageLoaded(iconSrc);
|
||||
if (!author) return null;
|
||||
return (
|
||||
<div className={styles.embedAuthor} data-flx="channel.embeds.embed.embed-author-component.embed-author">
|
||||
{iconSrc && (
|
||||
<img
|
||||
alt=""
|
||||
className={styles.embedAuthorIcon}
|
||||
className={clsx(styles.embedAuthorIcon, !iconLoaded && styles.embedIconPending)}
|
||||
src={iconSrc}
|
||||
width={24}
|
||||
height={24}
|
||||
@@ -181,6 +183,7 @@ export const EmbedFooterComponent: FC<{
|
||||
const iconSrc = footer?.proxy_icon_url
|
||||
? buildMediaProxyURL(footer.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
|
||||
: undefined;
|
||||
const iconLoaded = useCachedImageLoaded(iconSrc);
|
||||
if (!(footer || formattedTimestamp)) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -190,7 +193,7 @@ export const EmbedFooterComponent: FC<{
|
||||
{iconSrc && (
|
||||
<img
|
||||
alt=""
|
||||
className={styles.embedFooterIcon}
|
||||
className={clsx(styles.embedFooterIcon, !iconLoaded && styles.embedIconPending)}
|
||||
src={iconSrc}
|
||||
width={20}
|
||||
height={20}
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
EmbedProvider,
|
||||
EmbedTitle,
|
||||
} from '@app/features/channel/components/embeds/channel_embed/EmbedParts';
|
||||
import {
|
||||
EMBED_TEXT_OUTER_WIDTH,
|
||||
formatResponsiveEmbedWidth,
|
||||
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
|
||||
import markupStyles from '@app/features/theme/styles/Markup.module.css';
|
||||
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {EmbedMedia} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
@@ -58,7 +62,7 @@ export const RichEmbed: FC<EmbedProps> = observer(
|
||||
data-search-highlight-scope="message"
|
||||
style={{
|
||||
borderLeft: `4px solid ${getBorderColor(embed.color)}`,
|
||||
maxWidth: useNarrowWidth ? `min(100%, ${EMBED_MEDIA_MAX_WIDTH}px)` : 'min(100%, 516px)',
|
||||
maxWidth: formatResponsiveEmbedWidth(useNarrowWidth ? EMBED_MEDIA_MAX_WIDTH : EMBED_TEXT_OUTER_WIDTH),
|
||||
}}
|
||||
data-flx="channel.embeds.embed.rich-embed.embed"
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {deriveDefaultNameFromMessage} from '@app/features/channel/components/emb
|
||||
import styles from '@app/features/channel/components/embeds/media/EmbedAudio.module.css';
|
||||
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
|
||||
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
|
||||
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
|
||||
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
|
||||
import {DELETE_ATTACHMENT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
|
||||
@@ -130,8 +131,8 @@ const EmbedAudio: FC<EmbedAudioProps> = observer(
|
||||
: {
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
maxWidth: 'min(100%, 400px)',
|
||||
minWidth: 'min(400px, 100%)',
|
||||
maxWidth: `min(100%, ${remFromPx(ATTACHMENT_CARD_WIDTH)})`,
|
||||
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
|
||||
};
|
||||
const {showDeleteButton, showDownloadButton} = getMediaButtonVisibility(
|
||||
canFavorite,
|
||||
|
||||
@@ -233,7 +233,7 @@ export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOU
|
||||
className={styles.videoContainer}
|
||||
style={{
|
||||
...containerStyle,
|
||||
width: `${dimensions.width}px`,
|
||||
width: remFromPx(dimensions.width),
|
||||
aspectRatio,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
|
||||
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
|
||||
import styles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {prepareVoiceMessageWav} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
|
||||
import {sendVoiceMessage} from '@app/features/voice/utils/VoiceMessageSendUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {MicrophoneIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {motion, type Variants} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: 'Recording failed. Try again.',
|
||||
comment: 'Error toast shown when the hold-to-record voice message recording fails.',
|
||||
});
|
||||
const VOICE_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Voice message',
|
||||
comment: 'Filename label used for voice messages sent via the hold-to-record button.',
|
||||
});
|
||||
const UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: 'Unable to send voice message. Try again.',
|
||||
comment: 'Error toast shown when sending a hold-to-record voice message fails.',
|
||||
});
|
||||
const VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR = msg({
|
||||
message: 'Voice recording is not supported in this browser.',
|
||||
comment: 'Inline error when MediaRecorder is unsupported for the hold-to-record voice message flow.',
|
||||
});
|
||||
const UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR = msg({
|
||||
message: 'Unable to start recording. Allow microphone access.',
|
||||
comment: 'Inline error when microphone permission is denied for the hold-to-record voice message flow.',
|
||||
});
|
||||
const RELEASE_TO_SEND_DESCRIPTOR = msg({
|
||||
message: 'Release to send',
|
||||
comment: 'Tooltip on the hold-to-record button while the user is actively holding to record.',
|
||||
});
|
||||
const HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Hold to send voice message',
|
||||
comment: 'Tooltip on the hold-to-record button in its idle state.',
|
||||
});
|
||||
const logger = new Logger('HoldToRecordButton');
|
||||
const recordingVariants: Variants = {
|
||||
idle: {
|
||||
scale: 1,
|
||||
boxShadow: '0 0 0 0px rgba(252, 95, 105, 0)',
|
||||
},
|
||||
recording: {
|
||||
scale: [1, 1.05, 1],
|
||||
boxShadow: [
|
||||
'0 0 0 0px rgba(252, 95, 105, 0.35)',
|
||||
'0 0 0 8px rgba(252, 95, 105, 0.15)',
|
||||
'0 0 0 0px rgba(252, 95, 105, 0.35)',
|
||||
],
|
||||
transition: {
|
||||
duration: 1.2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface HoldToRecordButtonProps {
|
||||
channelId: string;
|
||||
disabled?: boolean;
|
||||
onFallback?: () => void;
|
||||
}
|
||||
|
||||
const HoldToRecordButton: React.FC<HoldToRecordButtonProps> = ({channelId, disabled, onFallback}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [status, setStatus] = useState<'idle' | 'recording' | 'sending'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Array<Blob>>([]);
|
||||
const pointerIdRef = useRef<number | null>(null);
|
||||
const maxTimerRef = useRef<number | null>(null);
|
||||
const maxRecordingSeconds = useMemo(
|
||||
() => LimitResolver.resolve({key: 'max_voice_message_duration', fallback: 1200}),
|
||||
[],
|
||||
);
|
||||
const clearMaxTimer = useCallback(() => {
|
||||
if (maxTimerRef.current) {
|
||||
clearTimeout(maxTimerRef.current);
|
||||
maxTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const releaseStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const cleanupRecording = useCallback(() => {
|
||||
clearMaxTimer();
|
||||
releaseStream();
|
||||
mediaRecorderRef.current = null;
|
||||
}, [clearMaxTimer, releaseStream]);
|
||||
const handleRecordingStop = useCallback(
|
||||
async (blob: Blob) => {
|
||||
if (blob.size === 0) {
|
||||
setStatus('idle');
|
||||
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
setStatus('sending');
|
||||
try {
|
||||
const {file, duration, waveform} = await prepareVoiceMessageWav(blob, `voice-message-${Date.now()}.wav`);
|
||||
await sendVoiceMessage({
|
||||
channelId,
|
||||
file,
|
||||
waveform,
|
||||
duration,
|
||||
title: i18n._(VOICE_MESSAGE_DESCRIPTOR),
|
||||
});
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
logger.error('Failed to send voice message', err);
|
||||
setError(i18n._(UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
setStatus('idle');
|
||||
}
|
||||
},
|
||||
[channelId, i18n],
|
||||
);
|
||||
const handleStopEvent = useCallback(() => {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (!recorder) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
const recordedChunks = chunksRef.current;
|
||||
const blob = new Blob(recordedChunks, {type: recorder.mimeType || 'audio/ogg'});
|
||||
chunksRef.current = [];
|
||||
cleanupRecording();
|
||||
if (recordedChunks.length === 0) {
|
||||
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
void handleRecordingStop(blob);
|
||||
}, [cleanupRecording, handleRecordingStop, i18n]);
|
||||
const handleDataAvailable = useCallback((event: BlobEvent) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
chunksRef.current = [...chunksRef.current, event.data];
|
||||
}
|
||||
}, []);
|
||||
const stopRecording = useCallback(() => {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.stop();
|
||||
}
|
||||
}, []);
|
||||
const startRecording = useCallback(async () => {
|
||||
if (status === 'recording' || status === 'sending') return;
|
||||
if (typeof navigator.mediaDevices === 'undefined' || typeof MediaRecorder === 'undefined') {
|
||||
setError(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
|
||||
onFallback?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
const preferredType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
|
||||
? 'audio/ogg;codecs=opus'
|
||||
: 'audio/webm;codecs=opus';
|
||||
const recorder = new MediaRecorder(stream, {mimeType: preferredType});
|
||||
chunksRef.current = [];
|
||||
recorder.addEventListener('dataavailable', handleDataAvailable);
|
||||
recorder.addEventListener('stop', handleStopEvent, {once: true});
|
||||
recorder.start();
|
||||
mediaRecorderRef.current = recorder;
|
||||
streamRef.current = stream;
|
||||
setStatus('recording');
|
||||
setError(null);
|
||||
clearMaxTimer();
|
||||
maxTimerRef.current = window.setTimeout(() => {
|
||||
stopRecording();
|
||||
}, maxRecordingSeconds * 1000);
|
||||
} catch (err) {
|
||||
logger.error('Failed to start voice recording', err);
|
||||
setError(i18n._(UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR));
|
||||
}
|
||||
}, [clearMaxTimer, handleDataAvailable, handleStopEvent, maxRecordingSeconds, status, i18n]);
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled || status === 'sending') return;
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = event.pointerId;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
void startRecording();
|
||||
},
|
||||
[disabled, startRecording, status],
|
||||
);
|
||||
const handlePointerUp = useCallback(
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {}
|
||||
pointerIdRef.current = null;
|
||||
stopRecording();
|
||||
},
|
||||
[stopRecording],
|
||||
);
|
||||
const handlePointerCancel = useCallback(
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = null;
|
||||
stopRecording();
|
||||
},
|
||||
[stopRecording],
|
||||
);
|
||||
const indicatorText = useMemo(() => {
|
||||
if (error) return error;
|
||||
if (status === 'recording') return i18n._(RELEASE_TO_SEND_DESCRIPTOR);
|
||||
return i18n._(HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR);
|
||||
}, [error, status, i18n.locale]);
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.holdButtonWrapper}
|
||||
variants={recordingVariants}
|
||||
animate={status === 'recording' ? 'recording' : 'idle'}
|
||||
data-flx="channel.textarea.hold-to-record-button.hold-button-wrapper"
|
||||
>
|
||||
<TextareaButton
|
||||
icon={MicrophoneIcon}
|
||||
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerLeave={handlePointerCancel}
|
||||
disabled={disabled || status === 'sending'}
|
||||
className={clsx(styles.holdButton, status === 'recording' && styles.holdButtonRecording)}
|
||||
data-flx="channel.textarea.hold-to-record-button.hold-button.pointer-down"
|
||||
/>
|
||||
<motion.span
|
||||
key={indicatorText}
|
||||
className={clsx(
|
||||
styles.holdIndicator,
|
||||
status === 'recording' && styles.holdIndicatorRecording,
|
||||
error && styles.holdIndicatorError,
|
||||
)}
|
||||
initial={Accessibility.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.15}}
|
||||
data-flx="channel.textarea.hold-to-record-button.hold-indicator"
|
||||
>
|
||||
{indicatorText}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoldToRecordButton;
|
||||
@@ -1,158 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.mobileTextareaWrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mobilePlusButtonContainer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.mobilePlusButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobilePlusButtonIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.mobileContentWrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mobileInputContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
background-color: var(--background-tertiary);
|
||||
border-radius: 1.125rem;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.mobileInputContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.mobileEmojiButtonContainer {
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
bottom: 0.125rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mobileEmojiButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-muted);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobileEmojiButtonIcon {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
|
||||
.mobileRightButtonContainer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.mobileRightButtonSlot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobileSendButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobileSendButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mobileVoiceButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobileVoiceButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mobileRightButtonIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import type {AutocompleteOption} from '@app/features/channel/components/Autocomplete';
|
||||
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
|
||||
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
|
||||
import styles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
|
||||
import textareaStyles from '@app/features/channel/components/textarea/TextareaInput.module.css';
|
||||
import {TextareaInputField} from '@app/features/channel/components/textarea/TextareaInputField';
|
||||
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
|
||||
import {EMOJIS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {TextareaTextChangeHint} from '@app/features/messaging/utils/TextareaSegmentManager';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowUpIcon, PlusIcon, SmileyIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
import {type Dispatch, type SetStateAction, useMemo, useRef} from 'react';
|
||||
|
||||
const OPEN_MENU_DESCRIPTOR = msg({
|
||||
message: 'Open menu',
|
||||
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
|
||||
});
|
||||
const SEND_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Send message',
|
||||
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
|
||||
});
|
||||
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
|
||||
initial: {opacity: 0, scale: 0.86, y: 3},
|
||||
animate: {opacity: 1, scale: 1, y: 0},
|
||||
exit: {opacity: 0, scale: 0.9, y: -2},
|
||||
transition: {
|
||||
opacity: {duration: 0.08},
|
||||
y: {duration: 0.08, ease: 'easeOut'},
|
||||
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
|
||||
},
|
||||
};
|
||||
|
||||
interface MobileTextareaLayoutProps {
|
||||
disabled: boolean;
|
||||
canAttachFiles: boolean;
|
||||
value: string;
|
||||
placeholderText: string;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
scrollerRef: React.RefObject<ScrollerHandle | null>;
|
||||
isFocused: boolean;
|
||||
isAutocompleteAttached: boolean;
|
||||
autocompleteListId?: string;
|
||||
autocompleteOptions: Array<AutocompleteOption>;
|
||||
selectedIndex: number;
|
||||
channelId: string;
|
||||
isSlowmodeActive: boolean;
|
||||
isOverCharacterLimit: boolean;
|
||||
isEditingMessage: boolean;
|
||||
hasContent: boolean;
|
||||
hasAttachments: boolean;
|
||||
hasPendingSticker?: boolean;
|
||||
isEditingScheduledMessage: boolean;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onChange: (value: string, inputType?: string, hint?: TextareaTextChangeHint) => void;
|
||||
onHeightChange: (height: number) => void;
|
||||
onCursorMove: () => void;
|
||||
onArrowUp: (event: React.KeyboardEvent) => void;
|
||||
onSubmit: () => void;
|
||||
onAutocompleteSelect: (option: AutocompleteOption) => void;
|
||||
setSelectedIndex: Dispatch<SetStateAction<number>>;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onPlusClick: () => void;
|
||||
onEmojiClick: () => void;
|
||||
}
|
||||
|
||||
export function MobileTextareaLayout({
|
||||
disabled,
|
||||
canAttachFiles,
|
||||
value,
|
||||
placeholderText,
|
||||
textareaRef,
|
||||
scrollerRef,
|
||||
isFocused,
|
||||
isAutocompleteAttached,
|
||||
autocompleteListId,
|
||||
autocompleteOptions,
|
||||
selectedIndex,
|
||||
channelId,
|
||||
isSlowmodeActive,
|
||||
isOverCharacterLimit,
|
||||
isEditingMessage,
|
||||
hasContent,
|
||||
hasAttachments,
|
||||
hasPendingSticker,
|
||||
isEditingScheduledMessage,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChange,
|
||||
onHeightChange,
|
||||
onCursorMove,
|
||||
onArrowUp,
|
||||
onSubmit,
|
||||
onAutocompleteSelect,
|
||||
setSelectedIndex,
|
||||
onKeyDown,
|
||||
onPlusClick,
|
||||
onEmojiClick,
|
||||
}: MobileTextareaLayoutProps) {
|
||||
const {i18n} = useLingui();
|
||||
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
|
||||
const rightButtonState = useMemo(
|
||||
() =>
|
||||
transitionMobileTextareaButtonState({
|
||||
disabled,
|
||||
canRecordVoice: canAttachFiles,
|
||||
value,
|
||||
isSlowmodeActive,
|
||||
isOverCharacterLimit,
|
||||
isEditingMessage,
|
||||
hasContent,
|
||||
hasAttachments,
|
||||
hasPendingSticker: Boolean(hasPendingSticker),
|
||||
isEditingScheduledMessage,
|
||||
}),
|
||||
[
|
||||
canAttachFiles,
|
||||
disabled,
|
||||
hasAttachments,
|
||||
hasContent,
|
||||
hasPendingSticker,
|
||||
isEditingMessage,
|
||||
isEditingScheduledMessage,
|
||||
isOverCharacterLimit,
|
||||
isSlowmodeActive,
|
||||
value,
|
||||
],
|
||||
);
|
||||
const sendButtonDisabled = rightButtonState.sendButton.disabled;
|
||||
const sendLockRef = useRef(false);
|
||||
const handleSendClick = () => {
|
||||
if (sendLockRef.current) {
|
||||
return;
|
||||
}
|
||||
sendLockRef.current = true;
|
||||
setTimeout(() => {
|
||||
sendLockRef.current = false;
|
||||
}, 500);
|
||||
onSubmit();
|
||||
};
|
||||
const shouldShowVoiceButton = rightButtonState.visibleButton === 'voice';
|
||||
const voiceButtonDisabled = rightButtonState.voiceButton.disabled;
|
||||
const voiceTooltipAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.mobileTextareaWrapper, disabled && wrapperStyles.disabled)}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-textarea-wrapper"
|
||||
>
|
||||
{!disabled && canAttachFiles && (
|
||||
<div
|
||||
className={styles.mobilePlusButtonContainer}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-container"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mobilePlusButton}
|
||||
onClick={onPlusClick}
|
||||
aria-label={i18n._(OPEN_MENU_DESCRIPTOR)}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button.plus-click"
|
||||
>
|
||||
<PlusIcon
|
||||
className={styles.mobilePlusButtonIcon}
|
||||
weight="bold"
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-icon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={styles.mobileContentWrapper}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-content-wrapper"
|
||||
>
|
||||
<div
|
||||
className={styles.mobileInputContainer}
|
||||
ref={voiceTooltipAnchorRef}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-container"
|
||||
>
|
||||
<div
|
||||
className={styles.mobileInputContent}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-content"
|
||||
>
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
fade={true}
|
||||
className={textareaStyles.scroller}
|
||||
key="mobile-textarea-scroller"
|
||||
data-flx="channel.textarea.mobile-textarea-layout.scroller"
|
||||
>
|
||||
<div className={textareaStyles.flexColumn} data-flx="channel.textarea.mobile-textarea-layout.div">
|
||||
<TextareaInputField
|
||||
channelId={channelId}
|
||||
disabled={disabled}
|
||||
isMobile={true}
|
||||
value={value}
|
||||
placeholder={placeholderText}
|
||||
textareaRef={textareaRef}
|
||||
isFocused={isFocused}
|
||||
isAutocompleteAttached={isAutocompleteAttached}
|
||||
autocompleteListId={autocompleteListId}
|
||||
autocompleteOptions={autocompleteOptions}
|
||||
selectedIndex={selectedIndex}
|
||||
className={textareaStyles.textareaMobile}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
onHeightChange={onHeightChange}
|
||||
onCursorMove={onCursorMove}
|
||||
onArrowUp={onArrowUp}
|
||||
onEnter={onSubmit}
|
||||
onAutocompleteSelect={onAutocompleteSelect}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
onKeyDown={onKeyDown}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.textarea-input-field.change"
|
||||
/>
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div
|
||||
className={styles.mobileEmojiButtonContainer}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-container"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mobileEmojiButton}
|
||||
onClick={onEmojiClick}
|
||||
aria-label={i18n._(EMOJIS_DESCRIPTOR)}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button.emoji-click"
|
||||
>
|
||||
<SmileyIcon
|
||||
className={styles.mobileEmojiButtonIcon}
|
||||
weight="fill"
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-icon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={styles.mobileRightButtonContainer}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-container"
|
||||
>
|
||||
<AnimatePresence initial={false} data-flx="channel.textarea.mobile-textarea-layout.animate-presence">
|
||||
{shouldShowVoiceButton ? (
|
||||
<motion.div
|
||||
key="voice-button"
|
||||
className={styles.mobileRightButtonSlot}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.div--2"
|
||||
{...buttonSwapMotion}
|
||||
>
|
||||
<VoiceMessageRecorder
|
||||
channelId={channelId}
|
||||
disabled={voiceButtonDisabled}
|
||||
tooltipAnchorRef={voiceTooltipAnchorRef}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.voice-message-recorder"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="send-button"
|
||||
className={styles.mobileRightButtonSlot}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.send-click"
|
||||
{...buttonSwapMotion}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mobileSendButton}
|
||||
onClick={handleSendClick}
|
||||
aria-label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
|
||||
disabled={sendButtonDisabled}
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.button"
|
||||
>
|
||||
<ArrowUpIcon
|
||||
className={styles.mobileRightButtonIcon}
|
||||
weight="bold"
|
||||
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-icon"
|
||||
/>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MobileTextareaLayout.displayName = 'MobileTextareaLayout';
|
||||
@@ -14,33 +14,17 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.holdButtonWrapper {
|
||||
.mobileRightButtonContainer {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: var(--textarea-button-height);
|
||||
height: var(--textarea-button-height);
|
||||
}
|
||||
|
||||
.mobileRightButtonSlot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.holdButton {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.holdButtonRecording {
|
||||
box-shadow: 0 0 0 0.125rem rgba(252, 95, 105, 0.35);
|
||||
}
|
||||
|
||||
.holdIndicator {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.holdIndicatorRecording {
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.holdIndicatorError {
|
||||
color: var(--accent-danger);
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import HoldToRecordButton from '@app/features/channel/components/textarea/HoldToRecordButton';
|
||||
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
|
||||
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
|
||||
import textareaButtonsStyles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
|
||||
import styles from '@app/features/channel/components/textarea/TextareaInput.module.css';
|
||||
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
|
||||
import type {ExpressionPickerTabType} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
||||
import {
|
||||
EMOJIS_DESCRIPTOR,
|
||||
@@ -12,29 +13,28 @@ import {
|
||||
MEDIA_DESCRIPTOR,
|
||||
STICKERS_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
|
||||
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
GifIcon,
|
||||
ImageSquareIcon,
|
||||
MicrophoneIcon,
|
||||
PaperPlaneRightIcon,
|
||||
SmileyIcon,
|
||||
StickerIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {GifIcon, ImageSquareIcon, PaperPlaneRightIcon, SmileyIcon, StickerIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
import React, {useMemo} from 'react';
|
||||
|
||||
const VOICE_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Voice message',
|
||||
comment: 'Short label in the channel and chat textarea buttons. Keep it concise.',
|
||||
});
|
||||
const SEND_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Send message',
|
||||
comment: 'Button or menu action label in the channel and chat textarea buttons. Keep it concise.',
|
||||
});
|
||||
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
|
||||
initial: {opacity: 0, scale: 0.86, y: 3},
|
||||
animate: {opacity: 1, scale: 1, y: 0},
|
||||
exit: {opacity: 0, scale: 0.9, y: -2},
|
||||
transition: {
|
||||
opacity: {duration: 0.08},
|
||||
y: {duration: 0.08, ease: 'easeOut'},
|
||||
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
|
||||
},
|
||||
};
|
||||
|
||||
interface TextareaButtonsProps {
|
||||
disabled: boolean;
|
||||
@@ -44,8 +44,10 @@ interface TextareaButtonsProps {
|
||||
showStickersButton: boolean;
|
||||
showEmojiButton: boolean;
|
||||
showMessageSendButton: boolean;
|
||||
showVoiceMessageButton?: boolean;
|
||||
onVoiceMessageClick?: () => void;
|
||||
canRecordVoice: boolean;
|
||||
isEditingMessage: boolean;
|
||||
hasPendingSticker: boolean;
|
||||
voiceTooltipAnchorRef?: React.RefObject<HTMLElement | null>;
|
||||
channelId: string;
|
||||
expressionPickerOpen: boolean;
|
||||
selectedTab: ExpressionPickerTabType;
|
||||
@@ -71,8 +73,10 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
|
||||
showStickersButton,
|
||||
showEmojiButton,
|
||||
showMessageSendButton,
|
||||
showVoiceMessageButton,
|
||||
onVoiceMessageClick,
|
||||
canRecordVoice,
|
||||
isEditingMessage,
|
||||
hasPendingSticker,
|
||||
voiceTooltipAnchorRef,
|
||||
channelId,
|
||||
expressionPickerOpen,
|
||||
selectedTab,
|
||||
@@ -90,22 +94,40 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
|
||||
ref,
|
||||
) => {
|
||||
const {i18n} = useLingui();
|
||||
const buttonModel = useMemo(
|
||||
() =>
|
||||
transitionMobileTextareaButtonState({
|
||||
disabled,
|
||||
canRecordVoice,
|
||||
value: '',
|
||||
isSlowmodeActive,
|
||||
isOverCharacterLimit: isOverLimit,
|
||||
isEditingMessage,
|
||||
hasContent,
|
||||
hasAttachments,
|
||||
hasPendingSticker,
|
||||
isEditingScheduledMessage: false,
|
||||
}),
|
||||
[
|
||||
canRecordVoice,
|
||||
disabled,
|
||||
hasAttachments,
|
||||
hasContent,
|
||||
hasPendingSticker,
|
||||
isEditingMessage,
|
||||
isOverLimit,
|
||||
isSlowmodeActive,
|
||||
],
|
||||
);
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
const buttonSwapMotion = getReducedMotionProps(
|
||||
{
|
||||
initial: {scale: 0.8, opacity: 0},
|
||||
animate: {scale: 1, opacity: 1},
|
||||
exit: {scale: 0.8, opacity: 0},
|
||||
transition: {duration: 0.15, ease: 'easeOut'},
|
||||
},
|
||||
Accessibility.useReducedMotion,
|
||||
);
|
||||
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
|
||||
const shouldShowDesktopSendButton = showMessageSendButton;
|
||||
const baseSendDisabled = isSlowmodeActive || isOverLimit || disableSendButton;
|
||||
const sendButtonDisabled = baseSendDisabled || (!hasContent && !hasAttachments);
|
||||
const shouldShowHoldToRecord = isMobile && showVoiceMessageButton && !hasContent && !hasAttachments;
|
||||
const desktopSendDisabled =
|
||||
isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton;
|
||||
const shouldShowVoiceButton = buttonModel.visibleButton === 'voice';
|
||||
const mobileSendDisabled = buttonModel.sendButton.disabled || Boolean(disableSendButton);
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.buttonContainerDense, styles.sideButtonPadding)}
|
||||
@@ -167,48 +189,51 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
|
||||
className={textareaButtonsStyles.invisibleTrigger}
|
||||
data-flx="channel.textarea.textarea-buttons.div"
|
||||
/>
|
||||
{isMobile && showVoiceMessageButton && onVoiceMessageClick && !shouldShowHoldToRecord && (
|
||||
<TextareaButton
|
||||
icon={MicrophoneIcon}
|
||||
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
|
||||
onClick={onVoiceMessageClick}
|
||||
data-flx="channel.textarea.textarea-buttons.textarea-button.voice-message-click"
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
<AnimatePresence mode="wait" initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
|
||||
{shouldShowHoldToRecord ? (
|
||||
<motion.div
|
||||
key="hold-to-record"
|
||||
data-flx="channel.textarea.textarea-buttons.div--2"
|
||||
{...buttonSwapMotion}
|
||||
>
|
||||
<HoldToRecordButton
|
||||
channelId={channelId}
|
||||
disabled={baseSendDisabled}
|
||||
onFallback={onVoiceMessageClick}
|
||||
data-flx="channel.textarea.textarea-buttons.hold-to-record-button"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="send-button" data-flx="channel.textarea.textarea-buttons.div--3" {...buttonSwapMotion}>
|
||||
<TextareaButton
|
||||
disabled={sendButtonDisabled}
|
||||
icon={PaperPlaneRightIcon}
|
||||
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
|
||||
onClick={onSubmit}
|
||||
keybindCombo={{key: 'Enter'}}
|
||||
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
className={textareaButtonsStyles.mobileRightButtonContainer}
|
||||
data-flx="channel.textarea.textarea-buttons.mobile-right-button-container"
|
||||
>
|
||||
<AnimatePresence initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
|
||||
{shouldShowVoiceButton ? (
|
||||
<motion.div
|
||||
key="voice-button"
|
||||
className={textareaButtonsStyles.mobileRightButtonSlot}
|
||||
data-flx="channel.textarea.textarea-buttons.div--2"
|
||||
{...buttonSwapMotion}
|
||||
>
|
||||
<VoiceMessageRecorder
|
||||
channelId={channelId}
|
||||
disabled={buttonModel.voiceButton.disabled}
|
||||
tooltipAnchorRef={voiceTooltipAnchorRef}
|
||||
data-flx="channel.textarea.textarea-buttons.voice-message-recorder"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="send-button"
|
||||
className={textareaButtonsStyles.mobileRightButtonSlot}
|
||||
data-flx="channel.textarea.textarea-buttons.div--3"
|
||||
{...buttonSwapMotion}
|
||||
>
|
||||
<TextareaButton
|
||||
disabled={mobileSendDisabled}
|
||||
icon={PaperPlaneRightIcon}
|
||||
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
|
||||
onClick={onSubmit}
|
||||
keybindCombo={{key: 'Enter'}}
|
||||
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && shouldShowDesktopSendButton && (
|
||||
<>
|
||||
<div className={styles.divider} data-flx="channel.textarea.textarea-buttons.divider" />
|
||||
<TextareaButton
|
||||
disabled={isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton}
|
||||
disabled={desktopSendDisabled}
|
||||
icon={PaperPlaneRightIcon}
|
||||
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
|
||||
onClick={onSubmit}
|
||||
|
||||
@@ -125,7 +125,8 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
|
||||
}
|
||||
const room = MediaEngine.room;
|
||||
const connectedChannelId = MediaEngine.channelId;
|
||||
const currentMute = LocalVoiceState.getSelfMute();
|
||||
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
|
||||
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched;
|
||||
const currentDeaf = LocalVoiceState.getSelfDeaf();
|
||||
const willUndeafen = currentDeaf;
|
||||
const willUnmute = currentMute;
|
||||
@@ -139,6 +140,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
|
||||
logger.info('toggleSelfMute', {
|
||||
currentMute,
|
||||
currentDeaf,
|
||||
microphoneFailureLatched,
|
||||
willUnmute,
|
||||
willUndeafen,
|
||||
willMute,
|
||||
@@ -158,6 +160,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
|
||||
handleMicrophonePermissionBlocked();
|
||||
return;
|
||||
}
|
||||
MediaEngine.clearMicrophoneFailureLatch();
|
||||
if (!MediaPermission.isMicrophoneGranted()) {
|
||||
logger.info('Microphone permission not granted, requesting permission');
|
||||
const permissionGranted = room?.localParticipant
|
||||
@@ -175,6 +178,11 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!LocalVoiceState.getSelfMute() && !LocalVoiceState.getSelfDeaf()) {
|
||||
logger.debug('Unmute intent only had to clear the microphone failure latch');
|
||||
SoundCommands.playSound(SoundType.Unmute);
|
||||
return;
|
||||
}
|
||||
}
|
||||
LocalVoiceState.toggleSelfMute();
|
||||
const newMute = LocalVoiceState.getSelfMute();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
|
||||
import {DiscoveryLanguageDropdown} from '@app/features/discovery/discovery/DiscoveryLanguageDropdown';
|
||||
import Discovery from '@app/features/discovery/state/Discovery';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import {useContextMenuTrigger} from '@app/features/ui/hooks/useContextMenuTrigger';
|
||||
import {getDiscoveryLanguageLabel} from '@app/features/user/utils/LocaleUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {TranslateIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const FILTER_BY_LANGUAGE_DESCRIPTOR = msg({
|
||||
message: 'Filter by language',
|
||||
comment: 'Label for the discovery language filter button. Keep it concise.',
|
||||
});
|
||||
const FILTERING_BY_LANGUAGE_DESCRIPTOR = msg({
|
||||
message: 'Filtering by {language}',
|
||||
comment:
|
||||
'Label for the discovery language filter button once a language is chosen. Preserve {language}; it is inserted by code.',
|
||||
});
|
||||
|
||||
export const DiscoveryLanguageButton = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {isOpen, withTracking} = useContextMenuTrigger();
|
||||
const activeLanguage = Discovery.language;
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuCommands.openFromElementBottomRight(
|
||||
event,
|
||||
({onClose}) => (
|
||||
<DiscoveryLanguageDropdown
|
||||
onClose={onClose}
|
||||
data-flx="discovery.discovery.discovery-language-button.handle-click.discovery-language-dropdown"
|
||||
/>
|
||||
),
|
||||
withTracking(),
|
||||
);
|
||||
},
|
||||
[withTracking],
|
||||
);
|
||||
return (
|
||||
<ChannelHeaderIcon
|
||||
icon={TranslateIcon}
|
||||
iconWeight="bold"
|
||||
label={
|
||||
activeLanguage == null
|
||||
? i18n._(FILTER_BY_LANGUAGE_DESCRIPTOR)
|
||||
: i18n._(FILTERING_BY_LANGUAGE_DESCRIPTOR, {language: getDiscoveryLanguageLabel(activeLanguage)})
|
||||
}
|
||||
isSelected={isOpen || activeLanguage != null}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
onClick={handleClick}
|
||||
data-flx="discovery.discovery.discovery-language-button.channel-header-icon.click"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Discovery from '@app/features/discovery/state/Discovery';
|
||||
import {ContextMenuCloseProvider} from '@app/features/ui/action_menu/ContextMenu';
|
||||
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
|
||||
import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
|
||||
import {getSortedDiscoveryLanguages} from '@app/features/user/utils/LocaleUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
|
||||
const ALL_LANGUAGES_DESCRIPTOR = msg({
|
||||
message: 'All languages',
|
||||
comment: 'Option in the discovery language filter that clears the filter. Keep it concise.',
|
||||
});
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DiscoveryLanguageDropdown: React.FC<Props> = observer(({onClose}) => {
|
||||
const {i18n} = useLingui();
|
||||
const languages = useMemo(() => getSortedDiscoveryLanguages(), [i18n.locale]);
|
||||
const selectLanguage = useCallback(
|
||||
(language: string | null) => {
|
||||
if (Discovery.language !== language) {
|
||||
void Discovery.search({language, offset: 0});
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
return (
|
||||
<ContextMenuCloseProvider
|
||||
value={onClose}
|
||||
data-flx="discovery.discovery.discovery-language-dropdown.context-menu-close-provider"
|
||||
>
|
||||
<MenuGroup data-flx="discovery.discovery.discovery-language-dropdown.menu-group">
|
||||
<MenuItemRadio
|
||||
label={i18n._(ALL_LANGUAGES_DESCRIPTOR)}
|
||||
selected={Discovery.language == null}
|
||||
onSelect={() => selectLanguage(null)}
|
||||
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.all"
|
||||
/>
|
||||
{languages.map((language) => (
|
||||
<MenuItemRadio
|
||||
key={language.code}
|
||||
label={language.label}
|
||||
selected={Discovery.language === language.code}
|
||||
onSelect={() => selectLanguage(language.code)}
|
||||
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.language"
|
||||
/>
|
||||
))}
|
||||
</MenuGroup>
|
||||
</ContextMenuCloseProvider>
|
||||
);
|
||||
});
|
||||
@@ -114,6 +114,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.searchArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchForm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {reportSkeletonDiscoveryCategoryTabs} from '@app/features/app/components/
|
||||
import {measureSkeletonWidthPx, useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
|
||||
import searchBarStyles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
|
||||
import {DiscoveryLanguageButton} from '@app/features/discovery/discovery/DiscoveryLanguageButton';
|
||||
import styles from '@app/features/discovery/discovery/DiscoveryNavbar.module.css';
|
||||
import {DiscoveryMotionKind, DiscoveryTransition} from '@app/features/discovery/discovery/DiscoveryTransition';
|
||||
import Discovery from '@app/features/discovery/state/Discovery';
|
||||
@@ -251,51 +252,54 @@ export const DiscoveryNavbar = observer(function DiscoveryNavbar({
|
||||
)}
|
||||
</DiscoveryTransition>
|
||||
</div>
|
||||
<form
|
||||
className={styles.searchForm}
|
||||
role="search"
|
||||
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
|
||||
onSubmit={handleSubmit}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
|
||||
>
|
||||
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
|
||||
<MagnifyingGlassIcon
|
||||
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
|
||||
weight="bold"
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.search-icon"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className={styles.searchInput}
|
||||
type="search"
|
||||
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
aria-controls={resultsRegionId}
|
||||
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
value={draftQuery}
|
||||
onChange={(event) => setDraftQuery(event.target.value)}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
|
||||
/>
|
||||
{draftQuery.length > 0 && (
|
||||
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={searchBarStyles.clearButton}
|
||||
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
|
||||
onClick={handleClear}
|
||||
data-flx="discovery.discovery.discovery-navbar.button.clear"
|
||||
>
|
||||
<XIcon
|
||||
weight="bold"
|
||||
className={searchBarStyles.optionMetaIcon}
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.x-icon"
|
||||
/>
|
||||
</button>
|
||||
</FocusRing>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className={styles.searchArea} data-flx="discovery.discovery.discovery-navbar.search-area">
|
||||
<form
|
||||
className={styles.searchForm}
|
||||
role="search"
|
||||
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
|
||||
onSubmit={handleSubmit}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
|
||||
>
|
||||
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
|
||||
<MagnifyingGlassIcon
|
||||
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
|
||||
weight="bold"
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.search-icon"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className={styles.searchInput}
|
||||
type="search"
|
||||
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
aria-controls={resultsRegionId}
|
||||
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
value={draftQuery}
|
||||
onChange={(event) => setDraftQuery(event.target.value)}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
|
||||
/>
|
||||
{draftQuery.length > 0 && (
|
||||
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={searchBarStyles.clearButton}
|
||||
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
|
||||
onClick={handleClear}
|
||||
data-flx="discovery.discovery.discovery-navbar.button.clear"
|
||||
>
|
||||
<XIcon
|
||||
weight="bold"
|
||||
className={searchBarStyles.optionMetaIcon}
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.x-icon"
|
||||
/>
|
||||
</button>
|
||||
</FocusRing>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<DiscoveryLanguageButton data-flx="discovery.discovery.discovery-navbar.discovery-language-button" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
|
||||
GuildReadState.handleConnectionOpen();
|
||||
Presence.handleConnectionOpen(data.user, guilds, data.presences);
|
||||
MediaEngine.handleConnectionOpen(guilds);
|
||||
Initialization.setReady(data);
|
||||
Initialization.setReady();
|
||||
context.setReady();
|
||||
Messages.handleConnectionOpen((channelId) => ReadStates.getUnreadJumpAnchor(channelId));
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type VerificationFailureReason = ValueOf<typeof VerificationFailureReason
|
||||
interface VerificationStatus {
|
||||
canAccess: boolean;
|
||||
reason?: VerificationFailureReason;
|
||||
timeRemaining?: number;
|
||||
verificationEndsAt?: number;
|
||||
}
|
||||
|
||||
class GuildVerification {
|
||||
@@ -81,10 +81,11 @@ class GuildVerification {
|
||||
for (const guild of guilds) {
|
||||
const status = this.computeVerificationStatus(guild);
|
||||
newVerificationStatus[guild.id] = status;
|
||||
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
|
||||
const delay = status.verificationEndsAt ? status.verificationEndsAt - Date.now() : undefined;
|
||||
if (!status.canAccess && delay && delay > 0) {
|
||||
newTimers[guild.id] = setTimeout(() => {
|
||||
this.recomputeGuild(guild.id);
|
||||
}, status.timeRemaining);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
this.verificationStatus = Object.freeze(newVerificationStatus);
|
||||
@@ -106,10 +107,11 @@ class GuildVerification {
|
||||
}
|
||||
const newTimers = {...this.timers};
|
||||
delete newTimers[guildId];
|
||||
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
|
||||
const delay = status.verificationEndsAt ? status.verificationEndsAt - Date.now() : undefined;
|
||||
if (!status.canAccess && delay && delay > 0) {
|
||||
newTimers[guildId] = setTimeout(() => {
|
||||
this.recomputeGuild(guildId);
|
||||
}, status.timeRemaining);
|
||||
}, delay);
|
||||
}
|
||||
this.verificationStatus = Object.freeze(newVerificationStatus);
|
||||
this.timers = newTimers;
|
||||
@@ -124,12 +126,11 @@ class GuildVerification {
|
||||
const now = Date.now();
|
||||
if (member?.communicationDisabledUntil) {
|
||||
const timeoutUntil = member.communicationDisabledUntil;
|
||||
const timeRemaining = timeoutUntil.getTime() - now;
|
||||
if (timeRemaining > 0) {
|
||||
if (timeoutUntil.getTime() > now) {
|
||||
return {
|
||||
canAccess: false,
|
||||
reason: VerificationFailureReason.TIMED_OUT,
|
||||
timeRemaining,
|
||||
verificationEndsAt: timeoutUntil.getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -164,18 +165,16 @@ class GuildVerification {
|
||||
}
|
||||
}
|
||||
if (verificationLevel >= GuildVerificationLevel.MEDIUM) {
|
||||
const accountAge = Date.now() - user.createdAt.getTime();
|
||||
if (accountAge < FIVE_MINUTES_MS) {
|
||||
const timeRemaining = FIVE_MINUTES_MS - accountAge;
|
||||
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, timeRemaining};
|
||||
const verificationEndsAt = user.createdAt.getTime() + FIVE_MINUTES_MS;
|
||||
if (verificationEndsAt > now) {
|
||||
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, verificationEndsAt};
|
||||
}
|
||||
}
|
||||
if (verificationLevel >= GuildVerificationLevel.HIGH) {
|
||||
if (member?.joinedAt) {
|
||||
const membershipDuration = Date.now() - member.joinedAt.getTime();
|
||||
if (membershipDuration < TEN_MINUTES_MS) {
|
||||
const timeRemaining = TEN_MINUTES_MS - membershipDuration;
|
||||
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, timeRemaining};
|
||||
const verificationEndsAt = member.joinedAt.getTime() + TEN_MINUTES_MS;
|
||||
if (verificationEndsAt > now) {
|
||||
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, verificationEndsAt};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +195,8 @@ class GuildVerification {
|
||||
}
|
||||
|
||||
getTimeRemaining(guildId: string): number | null {
|
||||
return this.verificationStatus[guildId]?.timeRemaining ?? null;
|
||||
const verificationEndsAt = this.verificationStatus[guildId]?.verificationEndsAt;
|
||||
return verificationEndsAt ? Math.max(0, verificationEndsAt - Date.now()) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1380,6 +1380,9 @@
|
||||
{
|
||||
"msgid": "File ordering"
|
||||
},
|
||||
{
|
||||
"msgid": "Filtering by {language}"
|
||||
},
|
||||
{
|
||||
"msgid": "Finish"
|
||||
},
|
||||
@@ -1407,6 +1410,9 @@
|
||||
{
|
||||
"msgid": "Granted"
|
||||
},
|
||||
{
|
||||
"msgid": "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMAIN}</0>."
|
||||
},
|
||||
{
|
||||
"msgid": "How much of the member's recent message history to delete."
|
||||
},
|
||||
@@ -1743,6 +1749,9 @@
|
||||
{
|
||||
"msgid": "online"
|
||||
},
|
||||
{
|
||||
"msgid": "{PRODUCT_NAME} needs to restart for the title bar change to take effect."
|
||||
},
|
||||
{
|
||||
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
|
||||
},
|
||||
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user