mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -106,10 +106,10 @@ jobs:
|
||||
|
||||
- name: upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
STATIC_BUCKET: fluxer-static
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.STATIC_S3_ENDPOINT }}
|
||||
STATIC_BUCKET: ${{ vars.STATIC_S3_BUCKET }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
|
||||
--step upload_assets
|
||||
|
||||
@@ -139,10 +139,10 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
S3_BUCKET: fluxer-downloads
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
|
||||
@@ -524,11 +524,11 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
S3_BUCKET: fluxer-downloads
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: "1.93.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install pnpm
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: "1.93.0"
|
||||
components: rustfmt
|
||||
|
||||
- name: Sync ci helper dependencies
|
||||
|
||||
Generated
+9
@@ -1777,6 +1777,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
@@ -1800,6 +1801,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
@@ -1836,6 +1838,7 @@ dependencies = [
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"fluxer_common",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.4.2",
|
||||
@@ -1875,6 +1878,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
@@ -1943,6 +1947,7 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"moka",
|
||||
@@ -2038,10 +2043,14 @@ dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"axum",
|
||||
"base64",
|
||||
"hmac 0.13.0",
|
||||
"maxminddb",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
|
||||
@@ -14913,7 +14913,7 @@
|
||||
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
|
||||
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"deletion_public_reason": {"nullable": true, "type": "string"},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 115},
|
||||
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
|
||||
@@ -15481,7 +15481,7 @@
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 100,
|
||||
"maxItems": 115,
|
||||
"description": "List of access control permissions to assign"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -112,6 +112,7 @@ fn is_urlencoded_form(request: &Request) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn extract_csrf_from_form_body(
|
||||
request: Request,
|
||||
) -> Result<(Request, Option<String>), Response> {
|
||||
|
||||
@@ -29,12 +29,16 @@
|
||||
"main": "./src/MediaProxyUtils.ts",
|
||||
"types": "./src/MediaProxyUtils.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
buildExternalMediaProxyPath,
|
||||
buildV2ExternalMediaProxyPath,
|
||||
reconstructOriginalUrl,
|
||||
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const ROUND_TRIP_URLS = [
|
||||
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
|
||||
'https://example.com:8443/a.png',
|
||||
'https://avatars.githubusercontent.com/u/241303489?v=4',
|
||||
'http://example.com/plain.gif',
|
||||
'https://example.com/deep/nested/path/to/file.jpeg',
|
||||
'https://example.com/file.png?a=1&b=2&c=3',
|
||||
'https://example.com/spaced%20name.png',
|
||||
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
|
||||
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
|
||||
'https://sub.domain.example.co.uk/a/b.webp',
|
||||
];
|
||||
|
||||
describe('buildExternalMediaProxyPath', () => {
|
||||
it('emits the plain path shape with the extension last', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
|
||||
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
|
||||
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non default port on the host segment', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
|
||||
});
|
||||
|
||||
it('preserves the http scheme', () => {
|
||||
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
|
||||
});
|
||||
|
||||
it('handles a root url with no path', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
|
||||
});
|
||||
|
||||
it('never emits the v2 prefix any more', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('ends in the source file extension so extension based cdn caching applies', () => {
|
||||
for (const [url, ext] of [
|
||||
['https://example.com/a.webp', '.webp'],
|
||||
['https://example.com/a.png?x=1', '.png'],
|
||||
['https://example.com/a/b/c.jpeg', '.jpeg'],
|
||||
] as const) {
|
||||
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a url it cannot parse', () => {
|
||||
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconstructOriginalUrl', () => {
|
||||
it('round trips every supported shape', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes an externally produced path verbatim', () => {
|
||||
expect(
|
||||
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
|
||||
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
|
||||
});
|
||||
|
||||
it('does not double the question mark when the query segment carries one', () => {
|
||||
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
|
||||
expect(decoded).toBe('https://example.com/x.png?a=1');
|
||||
expect(decoded).not.toContain('??');
|
||||
});
|
||||
|
||||
it('still accepts a query segment without a leading question mark', () => {
|
||||
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
|
||||
});
|
||||
|
||||
it('still decodes v2 paths so links already sent keep working', () => {
|
||||
expect(reconstructOriginalUrl(buildV2ExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
|
||||
'https://example.com/a.png?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a path that has no host after the protocol', () => {
|
||||
expect(() => reconstructOriginalUrl('https')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty v2 payload', () => {
|
||||
expect(() => reconstructOriginalUrl('v2/')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,8 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
|
||||
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
|
||||
const path = decodeLegacyComponent(encodedPath);
|
||||
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
|
||||
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
|
||||
}
|
||||
|
||||
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
|
||||
@@ -85,11 +86,28 @@ function reconstructV2OriginalUrl(proxyUrlPath: string): string {
|
||||
return decodeV2PathComponent(encodedOriginalUrl);
|
||||
}
|
||||
|
||||
export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
export function buildV2ExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
|
||||
}
|
||||
|
||||
export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
const protocol = parsedUrl.protocol.replace(/:$/u, '');
|
||||
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
|
||||
const path = parsedUrl.pathname
|
||||
.replace(/^\//u, '')
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
|
||||
segments.push(protocol, host);
|
||||
if (path) {
|
||||
segments.push(path);
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
export function reconstructOriginalUrl(proxyUrlPath: string): string {
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
|
||||
? reconstructV2OriginalUrl(proxyUrlPath)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider selection', () => {
|
||||
interface ClientProbe {
|
||||
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
|
||||
}
|
||||
|
||||
it('defaults to the shared S3 configuration', async () => {
|
||||
const service = new StorageService() as unknown as ClientProbe;
|
||||
expect(await service.client.config.region()).toBe(Config.s3.region);
|
||||
});
|
||||
|
||||
it('uses an explicitly supplied provider instead of the shared one', async () => {
|
||||
const service = new StorageService({
|
||||
endpoint: 'https://downloads.example.net',
|
||||
forcePathStyle: false,
|
||||
region: 'eu-central-9',
|
||||
accessKeyId: 'DL_KEY',
|
||||
secretAccessKey: 'DL_SECRET',
|
||||
}) as unknown as ClientProbe;
|
||||
expect(await service.client.config.region()).toBe('eu-central-9');
|
||||
expect(await service.client.config.region()).not.toBe(Config.s3.region);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash} from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
|
||||
export class StorageService implements IStorageService {
|
||||
private readonly client: S3Client;
|
||||
private readonly presignClient: S3Client;
|
||||
private readonly provider: S3ProviderSettings;
|
||||
|
||||
constructor() {
|
||||
this.client = buildPooledS3Client({
|
||||
constructor(provider?: S3ProviderSettings) {
|
||||
this.provider = provider ?? {
|
||||
endpoint: Config.s3.endpoint,
|
||||
presignedUrlBase: Config.s3.presignedUrlBase,
|
||||
forcePathStyle: Config.s3.forcePathStyle,
|
||||
region: Config.s3.region,
|
||||
accessKeyId: Config.s3.accessKeyId,
|
||||
secretAccessKey: Config.s3.secretAccessKey,
|
||||
};
|
||||
this.client = buildPooledS3Client({
|
||||
endpoint: this.provider.endpoint,
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
});
|
||||
this.presignClient = buildPooledS3Client({
|
||||
endpoint: this.resolvePresignEndpoint(),
|
||||
region: Config.s3.region,
|
||||
accessKeyId: Config.s3.accessKeyId,
|
||||
secretAccessKey: Config.s3.secretAccessKey,
|
||||
forcePathStyle: Config.s3.forcePathStyle,
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePresignEndpoint(): string {
|
||||
const fallbackEndpoint = Config.s3.endpoint;
|
||||
const configuredEndpoint = Config.s3.presignedUrlBase;
|
||||
const fallbackEndpoint = this.provider.endpoint;
|
||||
const configuredEndpoint = this.provider.presignedUrlBase;
|
||||
if (!configuredEndpoint) {
|
||||
return fallbackEndpoint;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../Config';
|
||||
import {createDownloadsStorageService} from './StorageServiceFactory';
|
||||
|
||||
describe('createDownloadsStorageService', () => {
|
||||
it('returns null when no downloads override is configured', () => {
|
||||
expect(Config.s3Downloads.isOverridden).toBe(false);
|
||||
expect(createDownloadsStorageService()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the downloads provider to the shared provider by default', () => {
|
||||
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
|
||||
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
|
||||
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
import type {IStorageService} from './IStorageService';
|
||||
import {StorageService} from './StorageService';
|
||||
|
||||
export function createStorageService(): IStorageService {
|
||||
return new StorageService();
|
||||
}
|
||||
|
||||
export function createDownloadsStorageService(): IStorageService | null {
|
||||
if (!Config.s3Downloads.isOverridden) {
|
||||
return null;
|
||||
}
|
||||
return new StorageService(Config.s3Downloads.settings);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
|
||||
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
|
||||
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
|
||||
import {createStorageService} from '../infrastructure/StorageServiceFactory';
|
||||
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
|
||||
import {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
|
||||
import {VirusScanService} from '../infrastructure/VirusScanService';
|
||||
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
|
||||
const fallback = singleton(() => createStorageService());
|
||||
return () => _injectedStorageService ?? fallback();
|
||||
})();
|
||||
const getDownloadsStorageService: () => IStorageService = (() => {
|
||||
const override = singleton(() => createDownloadsStorageService());
|
||||
return () => override() ?? getStorageService();
|
||||
})();
|
||||
export const getErrorI18nService = singleton(() => new ErrorI18nService());
|
||||
export const getLimitConfigService = singleton(
|
||||
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
|
||||
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
|
||||
return accountDeletionQueue;
|
||||
}
|
||||
|
||||
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
|
||||
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
|
||||
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
|
||||
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
|
||||
const getNcmecRepository = singleton(() => new NcmecRepository());
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {UserContentServiceTestHooks} from './UserContentService';
|
||||
|
||||
const {isUnreachableEntityError} = UserContentServiceTestHooks;
|
||||
|
||||
describe('isUnreachableEntityError', () => {
|
||||
it('treats a deleted or left community as unreachable rather than fatal', () => {
|
||||
expect(isUnreachableEntityError(new UnknownGuildError())).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a gone channel and a lost permission as unreachable', () => {
|
||||
expect(isUnreachableEntityError(new UnknownChannelError())).toBe(true);
|
||||
expect(isUnreachableEntityError(new MissingPermissionsError())).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a deleted message to the delete path instead of marking it unavailable', () => {
|
||||
expect(isUnreachableEntityError(new UnknownMessageError())).toBe(false);
|
||||
});
|
||||
|
||||
it('still lets unexpected failures surface', () => {
|
||||
expect(isUnreachableEntityError(new Error('database is on fire'))).toBe(false);
|
||||
expect(isUnreachableEntityError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
|
||||
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
|
||||
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
|
||||
@@ -94,6 +95,13 @@ function normalizeProviderEnvironment(
|
||||
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
||||
}
|
||||
|
||||
const isUnreachableEntityError = (error: unknown): boolean =>
|
||||
error instanceof MissingPermissionsError ||
|
||||
error instanceof UnknownChannelError ||
|
||||
error instanceof UnknownGuildError;
|
||||
|
||||
export const UserContentServiceTestHooks = {isUnreachableEntityError};
|
||||
|
||||
export class UserContentService {
|
||||
private readonly updatePropagator: BaseUserUpdatePropagator;
|
||||
private readonly userRepository: UserContentRepository;
|
||||
@@ -138,11 +146,7 @@ export class UserContentService {
|
||||
messageId: mention.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof UnknownMessageError ||
|
||||
error instanceof MissingPermissionsError ||
|
||||
error instanceof UnknownChannelError
|
||||
) {
|
||||
if (error instanceof UnknownMessageError || isUnreachableEntityError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -188,7 +192,7 @@ export class UserContentService {
|
||||
await this.userRepository.deleteSavedMessage(userId, savedMessage.messageId);
|
||||
return null;
|
||||
}
|
||||
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
|
||||
if (isUnreachableEntityError(error)) {
|
||||
status = 'missing_permissions';
|
||||
} else {
|
||||
throw error;
|
||||
|
||||
@@ -52,6 +52,15 @@ source edits in this package.
|
||||
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
|
||||
510 kbps maximum average bitrate in local offers and remote answers.
|
||||
|
||||
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
|
||||
`elementVolume` with a truthiness check, so a track deliberately held at `0`
|
||||
came back at full volume whenever it was re-attached or its Web Audio graph
|
||||
was rebuilt. All three guards now test `!== undefined`. Note that remote
|
||||
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
|
||||
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
|
||||
`webAudioMix` must stay unconditional.
|
||||
|
||||
## Updating from upstream
|
||||
|
||||
1. Check the upstream changelog for the target version.
|
||||
|
||||
@@ -64,7 +64,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
}
|
||||
|
||||
getVolume(): number {
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
return this.elementVolume;
|
||||
}
|
||||
if (isReactNative()) {
|
||||
@@ -113,7 +113,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
element.muted = true;
|
||||
}
|
||||
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
this.setVolume(this.elementVolume);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
lastNode.connect(this.gainNode);
|
||||
this.gainNode.connect(context.destination);
|
||||
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
this.gainNode.gain.setTargetAtTime(this.elementVolume, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -332,6 +332,7 @@ export const GuildListItemPresentation = forwardRef<
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-guild-list-focus-item="true"
|
||||
data-guild-id={guild.id}
|
||||
onLongPress={onLongPress}
|
||||
disabled={false}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-list-item.click"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
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."
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -152,8 +152,8 @@ export class ComposerCustomEmojiNode extends DecoratorNode<JSX.Element> {
|
||||
return true;
|
||||
}
|
||||
|
||||
override isKeyboardSelectable(): boolean {
|
||||
return true;
|
||||
override isKeyboardSelectable(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
override decorate(): JSX.Element {
|
||||
|
||||
@@ -184,8 +184,8 @@ export class ComposerMentionNode extends DecoratorNode<JSX.Element> {
|
||||
return true;
|
||||
}
|
||||
|
||||
override isKeyboardSelectable(): boolean {
|
||||
return true;
|
||||
override isKeyboardSelectable(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
override decorate(): JSX.Element {
|
||||
|
||||
@@ -143,8 +143,8 @@ export class ComposerStandardEmojiNode extends DecoratorNode<JSX.Element> {
|
||||
return true;
|
||||
}
|
||||
|
||||
override isKeyboardSelectable(): boolean {
|
||||
return true;
|
||||
override isKeyboardSelectable(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
override decorate(): JSX.Element {
|
||||
|
||||
@@ -834,7 +834,7 @@ export async function forward(
|
||||
logger.warn(`Forward send failed in channel ${channelId}`);
|
||||
return false;
|
||||
}
|
||||
SlowmodeCommands.recordMessageSend(channelId);
|
||||
SlowmodeCommands.confirmMessageSend(channelId, forwardedMessage.timestamp);
|
||||
if (optionalMessage) {
|
||||
const commentNonce = SnowflakeUtils.fromTimestamp(Date.now() + 1);
|
||||
const commentMessage = await send(channelId, {
|
||||
@@ -845,7 +845,7 @@ export async function forward(
|
||||
logger.warn(`Forward comment send failed in channel ${channelId}`);
|
||||
return false;
|
||||
}
|
||||
SlowmodeCommands.recordMessageSend(channelId);
|
||||
SlowmodeCommands.confirmMessageSend(channelId, commentMessage.timestamp);
|
||||
}
|
||||
}
|
||||
logger.debug('Successfully forwarded message to all channels');
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
normalizeMessageContent,
|
||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
|
||||
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
||||
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
@@ -66,9 +67,7 @@ type ScheduledMessageRequest = MessageCreateRequest & {
|
||||
|
||||
interface ApiErrorBody {
|
||||
code?: number | string;
|
||||
retry_after?: number;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface ScheduleMessageParams {
|
||||
@@ -372,37 +371,6 @@ const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
|
||||
return typeof error.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
|
||||
};
|
||||
|
||||
function parseNestedRetryAfterSeconds(body: ApiErrorBody | undefined): number | undefined {
|
||||
if (body === undefined || typeof body.details !== 'object' || body.details === null || Array.isArray(body.details)) {
|
||||
return undefined;
|
||||
}
|
||||
const details = body.details as Record<string, unknown>;
|
||||
const retry = details.retry;
|
||||
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) return undefined;
|
||||
const afterSeconds = (retry as Record<string, unknown>).after_seconds;
|
||||
if (typeof afterSeconds !== 'number' || !Number.isFinite(afterSeconds) || afterSeconds <= 0) return undefined;
|
||||
return afterSeconds;
|
||||
}
|
||||
|
||||
function resolveRetryAfterSeconds(error: HttpError): number | undefined {
|
||||
const body = getApiErrorBody(error);
|
||||
const nestedRetryAfter = parseNestedRetryAfterSeconds(body);
|
||||
if (nestedRetryAfter !== undefined) return nestedRetryAfter;
|
||||
const bodyRetryAfter = body === undefined ? undefined : body.retry_after;
|
||||
if (typeof bodyRetryAfter === 'number' && Number.isFinite(bodyRetryAfter) && bodyRetryAfter > 0) {
|
||||
return bodyRetryAfter;
|
||||
}
|
||||
const responseHeaders: Record<string, string> | undefined = error.responseHeaders;
|
||||
const header = responseHeaders === undefined ? undefined : responseHeaders['retry-after'];
|
||||
if (header === undefined || header.trim() === '') return undefined;
|
||||
const numeric = Number(header);
|
||||
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
||||
const deadline = Date.parse(header);
|
||||
if (!Number.isFinite(deadline)) return undefined;
|
||||
const remainingSeconds = (deadline - Date.now()) / 1000;
|
||||
return remainingSeconds > 0 ? remainingSeconds : undefined;
|
||||
}
|
||||
|
||||
function handleScheduleError(
|
||||
i18n: I18n,
|
||||
error: unknown,
|
||||
@@ -430,8 +398,7 @@ function handleScheduleError(
|
||||
return;
|
||||
}
|
||||
if (isSlowmodeError(error)) {
|
||||
const retryAfterSeconds = resolveRetryAfterSeconds(error);
|
||||
const retryAfterMs = SlowmodeCommands.retryAfterSecondsToMs(retryAfterSeconds);
|
||||
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
|
||||
if (retryAfterMs <= 0) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
@@ -490,8 +457,8 @@ function handleScheduleError(
|
||||
}
|
||||
|
||||
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
|
||||
const retryAfterSecondsValue = resolveRetryAfterSeconds(error);
|
||||
const retryAfterSeconds = retryAfterSecondsValue === undefined ? undefined : Math.ceil(retryAfterSecondsValue);
|
||||
const retryAfterMs = resolveRetryAfterMs(error);
|
||||
const retryAfterSeconds = retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<MessageSendTooQuickModal
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export function useCachedImageLoaded(src: string | null | undefined): boolean {
|
||||
const resolved = src ?? null;
|
||||
const [readySrc, setReadySrc] = useState<string | null>(() =>
|
||||
resolved !== null && ImageCacheUtils.hasImage(resolved) ? resolved : null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (resolved === null) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const markReady = (): void => {
|
||||
if (active) setReadySrc(resolved);
|
||||
};
|
||||
const cleanup = ImageCacheUtils.loadImage(resolved, markReady, markReady);
|
||||
return () => {
|
||||
active = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [resolved]);
|
||||
return resolved !== null && readySrc === resolved;
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
|
||||
referenced_message: referencedMessage?.toJSON(),
|
||||
});
|
||||
SlowmodeCommands.prepareMessageSend(channel.id);
|
||||
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
|
||||
void MessageCommands.send(channel.id, {
|
||||
content: message.content,
|
||||
nonce,
|
||||
@@ -141,11 +142,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
|
||||
stickers,
|
||||
favoriteMemeId,
|
||||
tts,
|
||||
}).then((sentMessage) => {
|
||||
if (sentMessage) {
|
||||
SlowmodeCommands.recordMessageSend(channel.id);
|
||||
}
|
||||
});
|
||||
})
|
||||
.then((sentMessage) => {
|
||||
if (sentMessage) {
|
||||
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
|
||||
return;
|
||||
}
|
||||
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
|
||||
})
|
||||
.catch(() => {
|
||||
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
|
||||
});
|
||||
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
|
||||
return true;
|
||||
},
|
||||
@@ -196,6 +203,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
|
||||
});
|
||||
SlowmodeCommands.prepareMessageSend(channel.id);
|
||||
const allowedMentions: AllowedMentions = {replied_user: replyingMessage?.mentioning ?? true};
|
||||
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
|
||||
void MessageCommands.send(channel.id, {
|
||||
content: messageData.content,
|
||||
nonce,
|
||||
@@ -207,11 +215,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
|
||||
flags: 0,
|
||||
stickers: messageData.stickers || [],
|
||||
favoriteMemeId: sendOptions.favoriteMemeId,
|
||||
}).then((sentMessage) => {
|
||||
if (sentMessage) {
|
||||
SlowmodeCommands.recordMessageSend(channel.id);
|
||||
}
|
||||
});
|
||||
})
|
||||
.then((sentMessage) => {
|
||||
if (sentMessage) {
|
||||
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
|
||||
return;
|
||||
}
|
||||
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
|
||||
})
|
||||
.catch(() => {
|
||||
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
|
||||
});
|
||||
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
|
||||
},
|
||||
[channel?.id, referencedMessage, replyingMessage],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user