mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
176
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86497155cd | ||
|
|
af82974c8a | ||
|
|
bd4117fa0a | ||
|
|
f004685424 | ||
|
|
b268320406 | ||
|
|
7a33b3198a | ||
|
|
66791d3ca2 | ||
|
|
a0d4a33fd4 | ||
|
|
fdd12194b1 | ||
|
|
bf11445299 | ||
|
|
61bf8f6ad3 | ||
|
|
52282bfccf | ||
|
|
cf3a31ac42 | ||
|
|
f56ccf5ef5 | ||
|
|
51e2eee15a | ||
|
|
de97bf908d | ||
|
|
099fb80da2 | ||
|
|
022ccc794d | ||
|
|
987768e8dc | ||
|
|
a3ffe963c3 | ||
|
|
b51562d0e3 | ||
|
|
38e86acffe | ||
|
|
38a299d852 | ||
|
|
55b25c919d | ||
|
|
2af4cc98fd | ||
|
|
e68b5b8b5a | ||
|
|
317b4e26c0 | ||
|
|
af5bee9149 | ||
|
|
26939eccce | ||
|
|
54360708d9 | ||
|
|
e441c7229c | ||
|
|
54e5fe6ef9 | ||
|
|
6fc0f1ccd7 | ||
|
|
6cd30d893a | ||
|
|
dceb9061d9 | ||
|
|
21438b2d8f | ||
|
|
793e853eb3 | ||
|
|
9cbe0efbbb | ||
|
|
9219b886fe | ||
|
|
2377a4c9d0 | ||
|
|
986c586683 | ||
|
|
7be52fa9fc | ||
|
|
32d7ae3eef | ||
|
|
ef6fb4903f | ||
|
|
0fe3f7523d | ||
|
|
9991534c83 | ||
|
|
455681b24c | ||
|
|
14e63085dd | ||
|
|
e8cb1cefbd | ||
|
|
919e890011 | ||
|
|
299172c8aa | ||
|
|
6cac93ef39 | ||
|
|
e5c8ef7d60 | ||
|
|
d0b4688a6c | ||
|
|
e0464ee5be | ||
|
|
cbcd299bd9 | ||
|
|
1300e5a690 | ||
|
|
fbd653d8e0 | ||
|
|
eb4f41e80c | ||
|
|
589a01a2da | ||
|
|
aae6b99761 | ||
|
|
5d35047734 | ||
|
|
98d10215d6 | ||
|
|
6656628bba | ||
|
|
37f46fc62d | ||
|
|
9efd2b0a73 | ||
|
|
b1fb2c14a1 | ||
|
|
c5d910425e | ||
|
|
0a9e64d36a | ||
|
|
7d02b7959f | ||
|
|
0670380360 | ||
|
|
904987795a | ||
|
|
4ee1b2294a | ||
|
|
97eb84fd46 | ||
|
|
5eb7822691 | ||
|
|
79cf57abe4 | ||
|
|
075bdb5128 | ||
|
|
65698051ac | ||
|
|
95559f17d8 | ||
|
|
aabc13e3cb | ||
|
|
b71ced9b2e | ||
|
|
bb34c73069 | ||
|
|
94bbbd1021 | ||
|
|
670a3a970e | ||
|
|
7a938c85f6 | ||
|
|
3a6bc4bc7b | ||
|
|
c54d7bcc88 | ||
|
|
b81bfc80fd | ||
|
|
d8bad50eec | ||
|
|
3fe6217809 | ||
|
|
50a947a722 | ||
|
|
7fe5aad16e | ||
|
|
97d559f749 | ||
|
|
188f783fae | ||
|
|
3a064dd728 | ||
|
|
202856c0f7 | ||
|
|
406340fa65 | ||
|
|
735ecc1cca | ||
|
|
7b646f891e | ||
|
|
19264d7f81 | ||
|
|
76ce060d13 | ||
|
|
9b3dc19037 | ||
|
|
5821a68816 | ||
|
|
e21544eac2 | ||
|
|
1f0f7d1222 | ||
|
|
69e0086144 | ||
|
|
61ce8729de | ||
|
|
44869b0ce4 | ||
|
|
213dd53ee8 | ||
|
|
844952db51 | ||
|
|
eda29c0e34 | ||
|
|
5834e32aa5 | ||
|
|
a59f3d0d0c | ||
|
|
5b8a46d087 | ||
|
|
677e6e5d6e | ||
|
|
62f40116be | ||
|
|
d2d199e136 | ||
|
|
39e2c89d5c | ||
|
|
15f4417c68 | ||
|
|
943b948de7 | ||
|
|
b7f75e25ad | ||
|
|
2af0405317 | ||
|
|
a2099fb0e2 | ||
|
|
52781cfa2d | ||
|
|
af7469fa1f | ||
|
|
4c14ba0a5e | ||
|
|
b49cf349a8 | ||
|
|
02406925d7 | ||
|
|
aad7e1a551 | ||
|
|
a98a9fe6a9 | ||
|
|
ac6fcc8c40 | ||
|
|
b0e882645e | ||
|
|
8c431c7562 | ||
|
|
3e267b8104 | ||
|
|
7c5cab2144 | ||
|
|
5cb893245f | ||
|
|
aa1c636cc2 | ||
|
|
c285854b71 | ||
|
|
01a29d629b | ||
|
|
bd381ccc74 | ||
|
|
c3e747aaa4 | ||
|
|
480d56125d | ||
|
|
7ec155eac1 | ||
|
|
c8ff177f85 | ||
|
|
f276bb1cd2 | ||
|
|
208632e648 | ||
|
|
8cfac127f5 | ||
|
|
ac76bee5a3 | ||
|
|
171dc7e5b7 | ||
|
|
051985b767 | ||
|
|
1eb85410bf | ||
|
|
6697db7cf8 | ||
|
|
56f6009390 | ||
|
|
d339b82f8e | ||
|
|
82eb87bc47 | ||
|
|
991be1c5a2 | ||
|
|
0d96a4574a | ||
|
|
61b4511ae4 | ||
|
|
137edc7cfb | ||
|
|
14e772a751 | ||
|
|
32afbf12d6 | ||
|
|
ffaf5119d8 | ||
|
|
10bc8c1efa | ||
|
|
85a03a9e39 | ||
|
|
51ee6567b4 | ||
|
|
090220a29d | ||
|
|
e7973b8be0 | ||
|
|
b5324c9223 | ||
|
|
edb8d80077 | ||
|
|
ddee116339 | ||
|
|
bdacaea4a8 | ||
|
|
9e28e02b5d | ||
|
|
3527dc95a2 | ||
|
|
27c7b2722d | ||
|
|
ba96f52ed6 | ||
|
|
2c8b3ff45c |
@@ -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
+1
@@ -2053,6 +2053,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
|
||||
+8
-1
@@ -145,7 +145,14 @@
|
||||
"!fluxer_static",
|
||||
"!packages/fonts",
|
||||
"!fluxer_admin/static/htmx.min.js",
|
||||
"!fluxer_api/src/api/openapi/openapi.json"
|
||||
"!fluxer_api/src/api/openapi/openapi.json",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/locales",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/weblate",
|
||||
"!fluxer_api/src/api/content_i18n/locales",
|
||||
"!fluxer_api/src/api/content_i18n/weblate",
|
||||
"!packages/errors/src/i18n/locales",
|
||||
"!packages/errors/src/i18n/weblate",
|
||||
"!**/auto-i18n-reviewed-unchanged.json"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
}
|
||||
|
||||
@@ -13669,7 +13669,7 @@
|
||||
"required": ["participants"]
|
||||
},
|
||||
"referenced_message": {
|
||||
"description": "The message that this message is replying to or forwarding",
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -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> {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {
|
||||
buildExternalMediaProxyPath,
|
||||
buildV2ExternalMediaProxyPath,
|
||||
buildOpaqueExternalMediaProxyPath,
|
||||
reconstructOriginalUrl,
|
||||
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
@@ -91,7 +91,7 @@ describe('reconstructOriginalUrl', () => {
|
||||
});
|
||||
|
||||
it('still decodes v2 paths so links already sent keep working', () => {
|
||||
expect(reconstructOriginalUrl(buildV2ExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
|
||||
expect(reconstructOriginalUrl(buildOpaqueExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
|
||||
'https://example.com/a.png?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
const BASE64_URL_PADDING_REGEX = /=*$/;
|
||||
const LEGACY_PROTOCOL_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*$/;
|
||||
const V2_PATH_PREFIX = 'v2/';
|
||||
const OPAQUE_PATH_PREFIX = 'v2/';
|
||||
|
||||
function encodeV2PathComponent(value: string): string {
|
||||
function encodeOpaquePathComponent(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url').replace(BASE64_URL_PADDING_REGEX, '');
|
||||
}
|
||||
|
||||
function decodeV2PathComponent(value: string): string {
|
||||
function decodeOpaquePathComponent(value: string): string {
|
||||
return Buffer.from(value, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
function decodeLegacyComponent(component: string): string {
|
||||
function decodeSegmentedComponent(component: string): string {
|
||||
return decodeURIComponent(component);
|
||||
}
|
||||
|
||||
function getLegacyProtocolIndex(parts: Array<string>): number {
|
||||
function getSegmentedProtocolIndex(parts: Array<string>): number {
|
||||
const firstPart = parts[0];
|
||||
if (firstPart && LEGACY_PROTOCOL_REGEX.test(firstPart)) {
|
||||
return 0;
|
||||
@@ -33,15 +33,15 @@ function getLegacyProtocolIndex(parts: Array<string>): number {
|
||||
throw new Error('Protocol is missing in the proxy URL path.');
|
||||
}
|
||||
|
||||
interface LegacyHostAndPort {
|
||||
interface SegmentedHostAndPort {
|
||||
hostname: string;
|
||||
port: string;
|
||||
}
|
||||
|
||||
function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
|
||||
function decodeSegmentedHostAndPort(hostPart: string): SegmentedHostAndPort {
|
||||
const separatorIndex = hostPart.lastIndexOf(':');
|
||||
if (separatorIndex === -1) {
|
||||
const hostname = decodeLegacyComponent(hostPart);
|
||||
const hostname = decodeSegmentedComponent(hostPart);
|
||||
if (!hostname) {
|
||||
throw new Error('Hostname is invalid in the proxy URL path.');
|
||||
}
|
||||
@@ -53,14 +53,14 @@ function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
|
||||
throw new Error('Hostname is invalid in the proxy URL path.');
|
||||
}
|
||||
return {
|
||||
hostname: decodeLegacyComponent(encodedHostname),
|
||||
port: encodedPort ? decodeLegacyComponent(encodedPort) : '',
|
||||
hostname: decodeSegmentedComponent(encodedHostname),
|
||||
port: encodedPort ? decodeSegmentedComponent(encodedPort) : '',
|
||||
};
|
||||
}
|
||||
|
||||
function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
|
||||
function reconstructSegmentedOriginalUrl(proxyUrlPath: string): string {
|
||||
const parts = proxyUrlPath.split('/');
|
||||
const protocolIndex = getLegacyProtocolIndex(parts);
|
||||
const protocolIndex = getSegmentedProtocolIndex(parts);
|
||||
const protocol = parts[protocolIndex];
|
||||
if (!protocol) {
|
||||
throw new Error('Protocol is missing in the proxy URL path.');
|
||||
@@ -71,24 +71,24 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
|
||||
}
|
||||
const encodedQuery = parts.slice(0, protocolIndex).join('/');
|
||||
const encodedPath = parts.slice(protocolIndex + 2).join('/');
|
||||
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
|
||||
const path = decodeLegacyComponent(encodedPath);
|
||||
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
|
||||
const query = encodedQuery ? decodeSegmentedComponent(encodedQuery) : '';
|
||||
const path = decodeSegmentedComponent(encodedPath);
|
||||
const {hostname, port} = decodeSegmentedHostAndPort(hostPart);
|
||||
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
|
||||
}
|
||||
|
||||
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
|
||||
const encodedOriginalUrl = proxyUrlPath.slice(V2_PATH_PREFIX.length);
|
||||
function reconstructOpaqueOriginalUrl(proxyUrlPath: string): string {
|
||||
const encodedOriginalUrl = proxyUrlPath.slice(OPAQUE_PATH_PREFIX.length);
|
||||
if (!encodedOriginalUrl) {
|
||||
throw new Error('Encoded URL is missing in the proxy URL path.');
|
||||
}
|
||||
return decodeV2PathComponent(encodedOriginalUrl);
|
||||
return decodeOpaquePathComponent(encodedOriginalUrl);
|
||||
}
|
||||
|
||||
export function buildV2ExternalMediaProxyPath(inputUrl: string): string {
|
||||
export function buildOpaqueExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
|
||||
return `${OPAQUE_PATH_PREFIX}${encodeOpaquePathComponent(parsedUrl.toString())}`;
|
||||
}
|
||||
|
||||
export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
@@ -109,9 +109,9 @@ export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
}
|
||||
|
||||
export function reconstructOriginalUrl(proxyUrlPath: string): string {
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
|
||||
? reconstructV2OriginalUrl(proxyUrlPath)
|
||||
: reconstructLegacyOriginalUrl(proxyUrlPath);
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(OPAQUE_PATH_PREFIX)
|
||||
? reconstructOpaqueOriginalUrl(proxyUrlPath)
|
||||
: reconstructSegmentedOriginalUrl(proxyUrlPath);
|
||||
new URL(reconstructedUrl);
|
||||
return reconstructedUrl;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
|
||||
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
|
||||
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
|
||||
},
|
||||
s3Downloads: resolveDownloadsProvider(master),
|
||||
s3: {
|
||||
endpoint: s3Config.endpoint,
|
||||
presignedUrlBase: s3Config.presigned_url_base,
|
||||
@@ -430,6 +432,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
|
||||
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
|
||||
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
|
||||
attachmentDecayEnabled: master.attachment_decay_enabled,
|
||||
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
|
||||
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
|
||||
@@ -461,6 +464,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
|
||||
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
|
||||
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
|
||||
voiceReconciliation: {
|
||||
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
|
||||
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
|
||||
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
|
||||
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
|
||||
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
|
||||
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
|
||||
},
|
||||
laneConcurrencyOverrides: {
|
||||
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
|
||||
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
||||
|
||||
@@ -84,7 +84,8 @@ export async function mapUserToAdminResponse(
|
||||
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
|
||||
suspicious_activity_flags: user.suspiciousActivityFlags,
|
||||
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
|
||||
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
|
||||
temp_banned_until:
|
||||
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
|
||||
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
|
||||
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
|
||||
deletion_reason_code: user.deletionReasonCode,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
ListGuildMembersRequest,
|
||||
@@ -18,6 +19,8 @@ import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {mapGuildsToAdminResponse} from '../../models/GuildTypes';
|
||||
|
||||
const ADMIN_STICKER_MEDIA_RUNG: MediaProxyImageSize = 320;
|
||||
|
||||
interface AdminGuildLookupServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
userRepository: IUserRepository;
|
||||
@@ -201,6 +204,6 @@ export class AdminGuildLookupService {
|
||||
}
|
||||
|
||||
private buildStickerMediaUrl(id: string, animated: boolean): string {
|
||||
return `${Config.endpoints.media}/stickers/${id}.webp?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}${animated ? '&animated=true' : ''}`;
|
||||
return `${Config.endpoints.media}/stickers/${id}.webp?size=${ADMIN_STICKER_MEDIA_RUNG}${animated ? '&animated=true' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,12 +160,8 @@ export class MessageValidationService {
|
||||
}
|
||||
}
|
||||
|
||||
calculateMessageFlags(data: {flags?: number; favorite_meme_id?: bigint | null}): number {
|
||||
let flags = data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
|
||||
if (data.favorite_meme_id) {
|
||||
flags |= MessageFlags.COMPACT_ATTACHMENTS;
|
||||
}
|
||||
return flags;
|
||||
calculateMessageFlags(data: {flags?: number}): number {
|
||||
return data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
|
||||
}
|
||||
|
||||
validateTotalAttachmentSize(
|
||||
|
||||
@@ -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';
|
||||
@@ -328,6 +330,7 @@ export interface APIConfig {
|
||||
};
|
||||
presignedAttachmentUploadsEnabled: boolean;
|
||||
presignedDownloadsEnabled: boolean;
|
||||
presignedHarvestDownloadsEnabled: boolean;
|
||||
attachmentDecayEnabled: boolean;
|
||||
deletionGracePeriodHours: number;
|
||||
inactivityDeletionThresholdDays?: number;
|
||||
@@ -359,6 +362,14 @@ export interface APIConfig {
|
||||
taskName?: WorkerTaskName;
|
||||
enableCronScheduler?: boolean;
|
||||
enableVoiceReconciliation: boolean;
|
||||
voiceReconciliation: {
|
||||
intervalMs: number | undefined;
|
||||
staggerDelayMs: number | undefined;
|
||||
lockTtlSeconds: number | undefined;
|
||||
cadenceTtlSeconds: number | undefined;
|
||||
gatewayOnlyGraceMs: number | undefined;
|
||||
liveKitOnlyGraceMs: number | undefined;
|
||||
};
|
||||
laneConcurrencyOverrides: {
|
||||
realtime?: number;
|
||||
unfurl?: number;
|
||||
|
||||
@@ -18,7 +18,12 @@ import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {Validator} from '../Validator';
|
||||
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
|
||||
import {DESKTOP_REDIRECT_PREFIX, DOWNLOAD_PREFIX, UnsatisfiableRangeError} from './DownloadService';
|
||||
import {
|
||||
DESKTOP_REDIRECT_PREFIX,
|
||||
DOWNLOAD_PREFIX,
|
||||
downloadCacheControlForKey,
|
||||
UnsatisfiableRangeError,
|
||||
} from './DownloadService';
|
||||
|
||||
function artifactFilename(key: string, filenameOverride?: string): string {
|
||||
return filenameOverride ?? key.split('/').pop() ?? 'download';
|
||||
@@ -290,7 +295,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
if (!checksum) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return checksumFileResponse(ctx, checksum, 'public, max-age=86400');
|
||||
return checksumFileResponse(ctx, checksum, downloadCacheControlForKey(checksum.key));
|
||||
},
|
||||
);
|
||||
routes.on(
|
||||
@@ -316,7 +321,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
if (!key) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=86400');
|
||||
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
|
||||
},
|
||||
);
|
||||
routes.on(
|
||||
@@ -340,7 +345,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
if (!key) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=300');
|
||||
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,35 @@ function desktopBucketPrefix(test?: boolean): string {
|
||||
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
|
||||
}
|
||||
|
||||
const MUTABLE_DOWNLOAD_CACHE_CONTROL = 'public, max-age=300';
|
||||
const VERSIONED_ARTIFACT_CACHE_CONTROL = 'public, max-age=31536000';
|
||||
|
||||
function isDesktopReleaseFeedFilename(filename: string): boolean {
|
||||
return (
|
||||
filename === 'manifest.json' ||
|
||||
filename.endsWith('.yml') ||
|
||||
filename.endsWith('.yaml') ||
|
||||
filename.startsWith('RELEASES') ||
|
||||
(filename.startsWith('releases') && filename.endsWith('.json')) ||
|
||||
(filename.startsWith('assets') && filename.endsWith('.json'))
|
||||
);
|
||||
}
|
||||
|
||||
function isVersionedDesktopArtifactKey(key: string): boolean {
|
||||
if (!key.startsWith(`${DESKTOP_BUCKET_PREFIX}/`)) {
|
||||
return false;
|
||||
}
|
||||
const filename = key.split('/').pop() ?? '';
|
||||
if (filename.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return !isDesktopReleaseFeedFilename(filename);
|
||||
}
|
||||
|
||||
export function downloadCacheControlForKey(key: string): string {
|
||||
return isVersionedDesktopArtifactKey(key) ? VERSIONED_ARTIFACT_CACHE_CONTROL : MUTABLE_DOWNLOAD_CACHE_CONTROL;
|
||||
}
|
||||
|
||||
function desktopArtifactPrefix(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
@@ -108,6 +137,7 @@ type VersionInfo = {
|
||||
files: Record<string, VersionFile>;
|
||||
};
|
||||
export type DesktopChecksumFile = {
|
||||
key: string;
|
||||
filename: string;
|
||||
sha256: string;
|
||||
body: string;
|
||||
@@ -461,7 +491,7 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const filename = this.filenameFromKey(key);
|
||||
return this.buildDesktopChecksumFile(filename, file.sha256);
|
||||
return this.buildDesktopChecksumFile(key, filename, file.sha256);
|
||||
}
|
||||
|
||||
async resolveVersionedDesktopChecksumFile(params: {
|
||||
@@ -479,14 +509,14 @@ export class DownloadService {
|
||||
const filename = this.filenameFromKey(key);
|
||||
const objectSha256 = await this.readDesktopSha256ForArtifactKey(key);
|
||||
if (objectSha256) {
|
||||
return this.buildDesktopChecksumFile(filename, objectSha256);
|
||||
return this.buildDesktopChecksumFile(key, filename, objectSha256);
|
||||
}
|
||||
const latest = await this.getLatestDesktopVersion(params);
|
||||
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
return this.buildDesktopChecksumFile(filename, file.sha256);
|
||||
return this.buildDesktopChecksumFile(key, filename, file.sha256);
|
||||
}
|
||||
|
||||
async resolveDownloadKey(params: {path: string; test?: boolean}): Promise<string | null> {
|
||||
@@ -1151,8 +1181,9 @@ export class DownloadService {
|
||||
return key.split('/').pop() ?? 'download';
|
||||
}
|
||||
|
||||
private buildDesktopChecksumFile(filename: string, sha256: string): DesktopChecksumFile {
|
||||
private buildDesktopChecksumFile(key: string, filename: string, sha256: string): DesktopChecksumFile {
|
||||
return {
|
||||
key,
|
||||
filename,
|
||||
sha256,
|
||||
body: `${sha256} ${filename}\n`,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Logger} from '@fluxer/logger/src/Logger';
|
||||
import type {ResolvedGifEntrySchema} from '@fluxer/schema/src/domains/gif/FavoriteGifSchemas';
|
||||
import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys';
|
||||
import type {GifMediaFormat, GifResponse} from '@fluxer/schema/src/domains/gif/GifSchemas';
|
||||
import type {EmbedMediaResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
import {tryExtractGifProviderSlug} from '../gif/GifProviderUtils';
|
||||
@@ -10,17 +11,6 @@ import type {IGifProvider} from '../gif/IGifProvider';
|
||||
import type {IMediaService, MediaProxyMetadataResponse} from '../infrastructure/IMediaService';
|
||||
import type {IUnfurlerService} from '../infrastructure/IUnfurlerService';
|
||||
|
||||
const PREVIEW_FORMAT_PRIORITY = ['webm', 'mp4', 'tinywebm', 'tinymp4', 'webp', 'gif', 'tinygif', 'nanogif'] as const;
|
||||
const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||
webm: 'video/webm',
|
||||
tinywebm: 'video/webm',
|
||||
mp4: 'video/mp4',
|
||||
tinymp4: 'video/mp4',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
tinygif: 'image/gif',
|
||||
nanogif: 'image/gif',
|
||||
};
|
||||
const logger = new Logger('FavoriteGifResolver');
|
||||
|
||||
function pickFavoriteGifPreviewFormat(
|
||||
@@ -221,10 +211,6 @@ function isUsableGifMediaFormat(format: GifMediaFormat | undefined): format is G
|
||||
return Boolean(format?.src && format.proxy_src && format.width > 0 && format.height > 0);
|
||||
}
|
||||
|
||||
function inferFormatContentType(formatKey: string): string {
|
||||
return FORMAT_CONTENT_TYPES[formatKey] ?? '';
|
||||
}
|
||||
|
||||
function isRenderableMediaType(contentType: string | null | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
return contentType.startsWith('image/') || contentType.startsWith('video/');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {Upload} from '@aws-sdk/lib-storage';
|
||||
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
|
||||
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
|
||||
import {seconds} from 'itty-time';
|
||||
import {temporaryFile} from 'tempy';
|
||||
@@ -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());
|
||||
|
||||
@@ -10588,6 +10588,70 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/harvest-downloads/{harvestId}": {
|
||||
"get": {
|
||||
"operationId": "download_data_harvest_archive",
|
||||
"summary": "Download data harvest archive",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Download data harvest archive"}},
|
||||
"description": "Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "harvestId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "string"},
|
||||
"description": "The harvestId"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invites/{invite_code}": {
|
||||
"get": {
|
||||
"operationId": "get_invite",
|
||||
@@ -26851,7 +26915,7 @@
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "The message that this message is replying to or forwarding"
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -26885,8 +26949,7 @@
|
||||
"value": "4096",
|
||||
"description": "This message will not trigger push or desktop notifications"
|
||||
},
|
||||
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"},
|
||||
{"name": "COMPACT_ATTACHMENTS", "value": "131072", "description": "Display attachments in a compact format"}
|
||||
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"}
|
||||
],
|
||||
"description": "Message flags bitfield"
|
||||
},
|
||||
@@ -35896,7 +35959,7 @@
|
||||
"HarvestDownloadUrlResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"download_url": {"type": "string", "description": "The presigned URL to download the harvest archive"},
|
||||
"download_url": {"type": "string", "description": "The temporary URL to download the harvest archive"},
|
||||
"expires_at": {"type": "string", "description": "ISO 8601 timestamp when the harvest download expires"}
|
||||
},
|
||||
"required": ["download_url", "expires_at"]
|
||||
|
||||
@@ -216,6 +216,10 @@ export const UserRateLimitConfigs = {
|
||||
bucket: 'user:harvest:download',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_HARVEST_DOWNLOAD_FILE: {
|
||||
bucket: 'user:harvest:download_file',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_ENTRANCE_SOUND_LIST: {
|
||||
bucket: 'user:entrance_sound:list',
|
||||
config: {limit: 30, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {HarvestIdParam, MessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {
|
||||
@@ -269,6 +270,50 @@ export function UserContentController(app: HonoApp) {
|
||||
return ctx.json(harvest, 200);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/harvest-downloads/:harvestId',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD_FILE),
|
||||
Validator('param', HarvestIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'download_data_harvest_archive',
|
||||
summary: 'Download data harvest archive',
|
||||
responseSchema: null,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {harvestId} = ctx.req.valid('param');
|
||||
const token = ctx.req.query('token');
|
||||
if (!token) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
const result = await ctx.get('userContentRequestService').streamHarvestDownload({
|
||||
harvestId,
|
||||
token,
|
||||
range: ctx.req.header('range') ?? undefined,
|
||||
storageService: ctx.get('storageService'),
|
||||
});
|
||||
if (!result) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', result.contentType ?? 'application/zip');
|
||||
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.filename)}"`);
|
||||
headers.set('Content-Length', String(result.contentLength));
|
||||
headers.set('Cache-Control', 'private, no-store');
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
if (result.contentRange) {
|
||||
headers.set('Content-Range', result.contentRange);
|
||||
}
|
||||
return new Response(Readable.toWeb(result.body) as ReadableStream, {
|
||||
status: result.contentRange ? 206 : 200,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/users/@me/harvest/:harvestId/download',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac, timingSafeEqual} from 'node:crypto';
|
||||
import {isJsonRecord, parseJsonWithGuard} from '../../utils/JsonBoundaryUtils';
|
||||
|
||||
const KEY_DERIVATION_LABEL = 'fluxer.harvest-download.v1';
|
||||
|
||||
interface HarvestDownloadTokenPayload {
|
||||
userId: string;
|
||||
harvestId: string;
|
||||
storageKey: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function deriveKey(secret: string): Buffer {
|
||||
return createHmac('sha256', secret).update(KEY_DERIVATION_LABEL).digest();
|
||||
}
|
||||
|
||||
function computeSignature(payloadBase64: string, secret: string): Buffer {
|
||||
return createHmac('sha256', deriveKey(secret)).update(payloadBase64).digest();
|
||||
}
|
||||
|
||||
function isHarvestDownloadTokenPayload(value: unknown): value is HarvestDownloadTokenPayload {
|
||||
if (!isJsonRecord(value)) return false;
|
||||
return (
|
||||
typeof value['userId'] === 'string' &&
|
||||
typeof value['harvestId'] === 'string' &&
|
||||
typeof value['storageKey'] === 'string' &&
|
||||
typeof value['expiresAt'] === 'number' &&
|
||||
Number.isFinite(value['expiresAt'])
|
||||
);
|
||||
}
|
||||
|
||||
export function signHarvestDownloadToken(payload: HarvestDownloadTokenPayload, secret: string): string {
|
||||
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = computeSignature(payloadBase64, secret).toString('base64url');
|
||||
return `${payloadBase64}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyHarvestDownloadToken(token: string, secret: string): HarvestDownloadTokenPayload | null {
|
||||
const dotIndex = token.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
const payloadBase64 = token.slice(0, dotIndex);
|
||||
const signatureBase64 = token.slice(dotIndex + 1);
|
||||
const expectedSignature = computeSignature(payloadBase64, secret);
|
||||
let providedSignature: Buffer;
|
||||
try {
|
||||
providedSignature = Buffer.from(signatureBase64, 'base64url');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (expectedSignature.length !== providedSignature.length) {
|
||||
return null;
|
||||
}
|
||||
if (!timingSafeEqual(expectedSignature, providedSignature)) {
|
||||
return null;
|
||||
}
|
||||
let payload: HarvestDownloadTokenPayload | null;
|
||||
try {
|
||||
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf-8');
|
||||
payload = parseJsonWithGuard(payloadJson, isHarvestDownloadTokenPayload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || Date.now() > payload.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../../Config';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {signHarvestDownloadToken} from './HarvestDownloadToken';
|
||||
|
||||
const HARVEST_DOWNLOAD_PATH = '/harvest-downloads';
|
||||
|
||||
export async function buildHarvestDownloadUrl(params: {
|
||||
userId: bigint;
|
||||
harvestId: bigint;
|
||||
storageKey: string;
|
||||
expiresInSeconds: number;
|
||||
storageService: IStorageService;
|
||||
}): Promise<string> {
|
||||
const {userId, harvestId, storageKey, expiresInSeconds, storageService} = params;
|
||||
if (Config.presignedHarvestDownloadsEnabled) {
|
||||
return storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: storageKey,
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
}
|
||||
const token = signHarvestDownloadToken(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
harvestId: harvestId.toString(),
|
||||
storageKey,
|
||||
expiresAt: Date.now() + expiresInSeconds * 1000,
|
||||
},
|
||||
Config.auth.connectionInitiationSecret,
|
||||
);
|
||||
const base = Config.endpoints.apiPublic.replace(/\/+$/u, '');
|
||||
return `${base}${HARVEST_DOWNLOAD_PATH}/${harvestId}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {Config} from '../../Config';
|
||||
import * as AuthPassword from '../../auth/AuthPassword';
|
||||
import * as AuthSession from '../../auth/AuthSession';
|
||||
import type {SudoVerificationResult} from '../../auth/services/SudoVerificationService';
|
||||
import {deriveSudoMethods, userHasMfa} from '../../auth/services/SudoVerificationService';
|
||||
import {Config} from '../../Config';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import type {IDiscriminatorService} from '../../infrastructure/DiscriminatorService';
|
||||
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
|
||||
@@ -23,7 +23,6 @@ import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||
import type {ChannelID, UserID} from '../../BrandedTypes';
|
||||
import {createChannelID, createMessageID, createUserID} from '../../BrandedTypes';
|
||||
import {mapChannelToResponse} from '../../channel/ChannelMappers';
|
||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||
import type {IChannelRepository} from '../../channel/IChannelRepository';
|
||||
import type {ChannelService} from '../../channel/services/ChannelService';
|
||||
import {dispatchMessageCreateBroadcast} from '../../channel/services/message/MessageGatewayDispatch';
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
createMessageResponseDataService,
|
||||
messageResponseAccessForGuild,
|
||||
} from '../../channel/services/message/MessageResponseDataService';
|
||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
|
||||
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
|
||||
@@ -145,6 +145,15 @@ export class UserContentRequestService {
|
||||
return this.userContentService.getHarvestDownloadUrl(params.userId, params.harvestId, params.storageService);
|
||||
}
|
||||
|
||||
async streamHarvestDownload(params: {
|
||||
harvestId: bigint;
|
||||
token: string;
|
||||
range?: string;
|
||||
storageService: IStorageService;
|
||||
}) {
|
||||
return this.userContentService.streamHarvestDownload(params);
|
||||
}
|
||||
|
||||
private async mapSavedMessageEntry(userId: UserID, entry: SavedMessageEntry): Promise<SavedMessageEntryResponse> {
|
||||
return {
|
||||
id: entry.messageId.toString(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type {Readable} from 'node:stream';
|
||||
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
@@ -24,7 +25,7 @@ import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {type ChannelID, createChannelID, type MessageID, type UserID} from '../../BrandedTypes';
|
||||
import {type ChannelID, createChannelID, createUserID, type MessageID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import type {IChannelRepository} from '../../channel/IChannelRepository';
|
||||
import type {ChannelService} from '../../channel/services/ChannelService';
|
||||
@@ -48,6 +49,8 @@ import type {IUserContentRepository} from '../repositories/IUserContentRepositor
|
||||
import {UserHarvest, type UserHarvestResponse} from '../UserHarvestModel';
|
||||
import {UserHarvestRepository} from '../UserHarvestRepository';
|
||||
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
|
||||
import {verifyHarvestDownloadToken} from './HarvestDownloadToken';
|
||||
import {buildHarvestDownloadUrl} from './HarvestDownloadUrl';
|
||||
|
||||
export interface SavedMessageEntry {
|
||||
channelId: ChannelID;
|
||||
@@ -472,10 +475,12 @@ export class UserContentService {
|
||||
throw new HarvestExpiredError();
|
||||
}
|
||||
const ZIP_EXPIRY_MS = ms('7 days');
|
||||
const downloadUrl = await storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: harvest.storageKey,
|
||||
expiresIn: ZIP_EXPIRY_MS / 1000,
|
||||
const downloadUrl = await buildHarvestDownloadUrl({
|
||||
userId,
|
||||
harvestId,
|
||||
storageKey: harvest.storageKey,
|
||||
expiresInSeconds: ZIP_EXPIRY_MS / 1000,
|
||||
storageService,
|
||||
});
|
||||
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_MS);
|
||||
return {
|
||||
@@ -484,6 +489,61 @@ export class UserContentService {
|
||||
};
|
||||
}
|
||||
|
||||
async streamHarvestDownload(params: {
|
||||
harvestId: bigint;
|
||||
token: string;
|
||||
range?: string;
|
||||
storageService: IStorageService;
|
||||
}): Promise<{
|
||||
body: Readable;
|
||||
contentLength: number;
|
||||
contentRange?: string | null;
|
||||
contentType?: string | null;
|
||||
filename: string;
|
||||
} | null> {
|
||||
if (Config.presignedHarvestDownloadsEnabled) {
|
||||
return null;
|
||||
}
|
||||
const payload = verifyHarvestDownloadToken(params.token, Config.auth.connectionInitiationSecret);
|
||||
if (!payload || payload.harvestId !== params.harvestId.toString()) {
|
||||
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: invalid or expired token');
|
||||
return null;
|
||||
}
|
||||
let userId: UserID;
|
||||
try {
|
||||
userId = createUserID(BigInt(payload.userId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findByUserAndHarvestId(userId, params.harvestId);
|
||||
if (!harvest || !harvest.completedAt || !harvest.storageKey || harvest.failedAt) {
|
||||
return null;
|
||||
}
|
||||
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
if (harvest.storageKey !== payload.storageKey) {
|
||||
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: storage key mismatch');
|
||||
return null;
|
||||
}
|
||||
const object = await params.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: harvest.storageKey,
|
||||
range: params.range,
|
||||
});
|
||||
if (!object) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
body: object.body,
|
||||
contentLength: object.contentLength,
|
||||
contentRange: object.contentRange,
|
||||
contentType: object.contentType ?? 'application/zip',
|
||||
filename: `fluxer-data-${params.harvestId}.zip`,
|
||||
};
|
||||
}
|
||||
|
||||
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
|
||||
const {userId, delayMs = ms('1 day')} = params;
|
||||
const scheduledAt = new Date(Date.now() + delayMs);
|
||||
|
||||
@@ -808,6 +808,9 @@ export class UserRelationshipService {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (user.pendingDeletionAt !== null) {
|
||||
return false;
|
||||
}
|
||||
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {signHarvestDownloadToken, verifyHarvestDownloadToken} from '../HarvestDownloadToken';
|
||||
|
||||
const SECRET = 'test-connection-secret';
|
||||
|
||||
function payload(overrides: Partial<Parameters<typeof signHarvestDownloadToken>[0]> = {}) {
|
||||
return {
|
||||
userId: '123',
|
||||
harvestId: '456',
|
||||
storageKey: 'exports/123/456/user-data.zip',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('HarvestDownloadToken', () => {
|
||||
test('round trips a valid token', () => {
|
||||
const original = payload();
|
||||
const verified = verifyHarvestDownloadToken(signHarvestDownloadToken(original, SECRET), SECRET);
|
||||
expect(verified).toEqual(original);
|
||||
});
|
||||
|
||||
test('rejects a token signed with a different secret', () => {
|
||||
const token = signHarvestDownloadToken(payload(), SECRET);
|
||||
expect(verifyHarvestDownloadToken(token, 'other-secret')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects an expired token', () => {
|
||||
const token = signHarvestDownloadToken(payload({expiresAt: Date.now() - 1}), SECRET);
|
||||
expect(verifyHarvestDownloadToken(token, SECRET)).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a tampered payload', () => {
|
||||
const token = signHarvestDownloadToken(payload(), SECRET);
|
||||
const [encoded, signature] = token.split('.');
|
||||
const decoded = JSON.parse(Buffer.from(encoded as string, 'base64url').toString('utf-8'));
|
||||
decoded.userId = '999';
|
||||
const forged = `${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${signature}`;
|
||||
expect(verifyHarvestDownloadToken(forged, SECRET)).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a tampered signature', () => {
|
||||
const token = signHarvestDownloadToken(payload(), SECRET);
|
||||
expect(verifyHarvestDownloadToken(`${token.slice(0, -2)}xy`, SECRET)).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects malformed input', () => {
|
||||
for (const bad of ['', '.', 'nodot', 'a.b', '..', 'a.']) {
|
||||
expect(verifyHarvestDownloadToken(bad, SECRET)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a payload missing required fields', () => {
|
||||
const encoded = Buffer.from(JSON.stringify({userId: '1'})).toString('base64url');
|
||||
const token = signHarvestDownloadToken(payload(), SECRET);
|
||||
const signature = token.split('.')[1];
|
||||
expect(verifyHarvestDownloadToken(`${encoded}.${signature}`, SECRET)).toBeNull();
|
||||
});
|
||||
|
||||
test('is not verifiable as a raw HMAC of the base secret', () => {
|
||||
const {createHmac} = require('node:crypto') as typeof import('node:crypto');
|
||||
const encoded = Buffer.from(JSON.stringify(payload())).toString('base64url');
|
||||
const rawSignature = createHmac('sha256', SECRET).update(encoded).digest('base64url');
|
||||
expect(verifyHarvestDownloadToken(`${encoded}.${rawSignature}`, SECRET)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {signHarvestDownloadToken} from '../services/HarvestDownloadToken';
|
||||
import {fetchHarvestDownload, markHarvestCompleted, requestHarvest} from './HarvestTestUtils';
|
||||
|
||||
const ZIP_BYTES = new TextEncoder().encode('PK pretend zip');
|
||||
|
||||
async function withStreamingHarvestDownloads(callback: () => Promise<void>): Promise<void> {
|
||||
const config = getConfig();
|
||||
const original = config.presignedHarvestDownloadsEnabled;
|
||||
config.presignedHarvestDownloadsEnabled = false;
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
config.presignedHarvestDownloadsEnabled = original;
|
||||
}
|
||||
}
|
||||
|
||||
async function completedHarvest(harness: ApiTestHarness) {
|
||||
const account = await createTestAccount(harness);
|
||||
const {harvest_id} = await requestHarvest(harness, account.token);
|
||||
await markHarvestCompleted(account.userId, harvest_id, new Date(Date.now() + 6 * 24 * 60 * 60 * 1000));
|
||||
const storageKey = `test/${harvest_id}.zip`;
|
||||
await harness.storageService.uploadObject({
|
||||
bucket: getConfig().s3.buckets.harvests,
|
||||
key: storageKey,
|
||||
body: ZIP_BYTES,
|
||||
contentType: 'application/zip',
|
||||
});
|
||||
return {account, harvestId: harvest_id, storageKey};
|
||||
}
|
||||
|
||||
function downloadPath(url: string): string {
|
||||
return url.slice(url.indexOf('/harvest-downloads'));
|
||||
}
|
||||
|
||||
async function get(harness: ApiTestHarness, path: string): Promise<Response> {
|
||||
return harness.app.request(path, {headers: {'x-forwarded-for': '203.0.113.7'}});
|
||||
}
|
||||
|
||||
describe('Harvest download streaming', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('defaults to presigned URLs and leaves the streaming route closed', async () => {
|
||||
const {account, harvestId, storageKey} = await completedHarvest(harness);
|
||||
expect(getConfig().presignedHarvestDownloadsEnabled).toBe(true);
|
||||
|
||||
const download = await fetchHarvestDownload(harness, account.token, harvestId);
|
||||
expect(download.download_url).toBe('https://presigned.url/test');
|
||||
|
||||
const token = signHarvestDownloadToken(
|
||||
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
|
||||
getConfig().auth.connectionInitiationSecret,
|
||||
);
|
||||
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test('serves the archive through the API when presigned downloads are disabled', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const {account, harvestId} = await completedHarvest(harness);
|
||||
const download = await fetchHarvestDownload(harness, account.token, harvestId);
|
||||
|
||||
expect(download.download_url).not.toContain('presigned.url');
|
||||
expect(download.download_url).toContain(`/harvest-downloads/${harvestId}`);
|
||||
expect(download.download_url).toContain('token=');
|
||||
|
||||
const response = await get(harness, downloadPath(download.download_url));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('application/zip');
|
||||
expect(response.headers.get('content-disposition')).toContain('attachment');
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(ZIP_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a tampered token', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const {account, harvestId} = await completedHarvest(harness);
|
||||
const download = await fetchHarvestDownload(harness, account.token, harvestId);
|
||||
const path = downloadPath(download.download_url);
|
||||
const response = await get(harness, `${path.slice(0, -2)}xy`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a token signed with the wrong secret', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const {account, harvestId, storageKey} = await completedHarvest(harness);
|
||||
const token = signHarvestDownloadToken(
|
||||
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
|
||||
'not-the-configured-secret',
|
||||
);
|
||||
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an expired token even while the harvest is still valid', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const {account, harvestId, storageKey} = await completedHarvest(harness);
|
||||
const token = signHarvestDownloadToken(
|
||||
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() - 1},
|
||||
getConfig().auth.connectionInitiationSecret,
|
||||
);
|
||||
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a token replayed against a different harvest id', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const first = await completedHarvest(harness);
|
||||
const second = await completedHarvest(harness);
|
||||
const token = signHarvestDownloadToken(
|
||||
{
|
||||
userId: first.account.userId,
|
||||
harvestId: first.harvestId,
|
||||
storageKey: first.storageKey,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
getConfig().auth.connectionInitiationSecret,
|
||||
);
|
||||
const response = await get(harness, `/harvest-downloads/${second.harvestId}?token=${encodeURIComponent(token)}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a token whose storage key no longer matches the harvest', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const {account, harvestId} = await completedHarvest(harness);
|
||||
const token = signHarvestDownloadToken(
|
||||
{userId: account.userId, harvestId, storageKey: 'test/some-other-key.zip', expiresAt: Date.now() + 60_000},
|
||||
getConfig().auth.connectionInitiationSecret,
|
||||
);
|
||||
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a token minted for a different user', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const {harvestId, storageKey} = await completedHarvest(harness);
|
||||
const other = await createTestAccount(harness);
|
||||
const token = signHarvestDownloadToken(
|
||||
{userId: other.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
|
||||
getConfig().auth.connectionInitiationSecret,
|
||||
);
|
||||
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test('requires a token', async () => {
|
||||
await withStreamingHarvestDownloads(async () => {
|
||||
const {harvestId} = await completedHarvest(harness);
|
||||
const response = await get(harness, `/harvest-downloads/${harvestId}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, unclaimAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createChannelID, createUserID} from '../../BrandedTypes';
|
||||
import {authorizeBot, createTestBotAccount} from '../../bot/tests/BotTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
@@ -19,13 +20,12 @@ import {
|
||||
type MinimalChannelResponse,
|
||||
sendChannelMessage,
|
||||
} from '../../channel/tests/ChannelTestUtils';
|
||||
import {createChannelID, createUserID} from '../../BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
|
||||
interface PrivateChannelsResponse extends Array<MinimalChannelResponse> {}
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ import {
|
||||
} from './RelationshipTestUtils';
|
||||
import {fetchUserMe} from './UserTestUtils';
|
||||
|
||||
async function markUserScheduledForDeletion(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
const pendingDeletionAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${userId}/set-pending-deletion`)
|
||||
.body({pending_deletion_at: pendingDeletionAt, set_self_deleted_flag: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await markUserDeleted(harness, userId);
|
||||
}
|
||||
|
||||
async function markUserDeleted(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.patch(`/test/users/${userId}/flags`)
|
||||
@@ -221,6 +231,16 @@ describe('UserRelationshipStateTransitions', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
|
||||
.execute();
|
||||
});
|
||||
test('can accept a friend request from a user scheduled for deletion', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
await sendFriendRequest(harness, bob.token, alice.userId);
|
||||
await markUserScheduledForDeletion(harness, bob.userId);
|
||||
const {json: friendship} = await acceptFriendRequest(harness, alice.token, bob.userId);
|
||||
assertRelationshipType(friendship, RelationshipTypes.FRIEND);
|
||||
const {json: aliceAfter} = await listRelationships(harness, alice.token);
|
||||
assertRelationshipType(findRelationship(aliceAfter, bob.userId)!, RelationshipTypes.FRIEND);
|
||||
});
|
||||
test('cannot send friend request to user who blocked you', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {candidateTtlSecondsFor} from './VoiceReconciliationWorker';
|
||||
|
||||
const INTERVAL_MS = 15000;
|
||||
const GATEWAY_ONLY_GRACE_MS = 10000;
|
||||
|
||||
function ttlFor(observedSweepSpacingMs: number): number {
|
||||
return candidateTtlSecondsFor({
|
||||
intervalMs: INTERVAL_MS,
|
||||
observedSweepSpacingMs,
|
||||
graceMs: GATEWAY_ONLY_GRACE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
describe('candidateTtlSecondsFor', () => {
|
||||
it('outlives the gap between two consecutive observations of the same key', () => {
|
||||
for (const observedSweepSpacingMs of [0, 45_000, 136_000, 300_000, 596_000, 900_000]) {
|
||||
expect(ttlFor(observedSweepSpacingMs) * 1000).toBeGreaterThan(observedSweepSpacingMs);
|
||||
}
|
||||
});
|
||||
|
||||
it('outlives a sweep gap far longer than the tick interval', () => {
|
||||
expect(ttlFor(596_000) * 1000).toBeGreaterThan(596_000);
|
||||
});
|
||||
|
||||
it('grows with the observed sweep spacing rather than the tick interval', () => {
|
||||
expect(ttlFor(596_000)).toBeGreaterThan(ttlFor(136_000));
|
||||
expect(ttlFor(136_000)).toBeGreaterThan(ttlFor(0));
|
||||
});
|
||||
|
||||
it('keeps a floor that survives a single long sweep before any spacing is observed', () => {
|
||||
expect(ttlFor(0)).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('stays bounded so a stale candidate cannot outlive its connection indefinitely', () => {
|
||||
expect(ttlFor(Number.MAX_SAFE_INTEGER)).toBeLessThanOrEqual(3600);
|
||||
});
|
||||
});
|
||||
@@ -96,9 +96,24 @@ const DEFAULT_STAGGER_DELAY_MS = 25;
|
||||
const DEFAULT_LOCK_TTL_SECONDS = 180;
|
||||
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
|
||||
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
|
||||
const MIN_CANDIDATE_TTL_SECONDS = 300;
|
||||
const MAX_CANDIDATE_TTL_SECONDS = 3600;
|
||||
const CANDIDATE_TTL_SWEEP_MULTIPLIER = 3;
|
||||
const LAST_SWEEP_KEY_TTL_SECONDS = 86400;
|
||||
const ROOM_KEY_PREFIX = 'voice:room:server:';
|
||||
export function candidateTtlSecondsFor(input: {
|
||||
intervalMs: number;
|
||||
observedSweepSpacingMs: number;
|
||||
graceMs: number;
|
||||
}): number {
|
||||
const spacingMs = Math.max(input.intervalMs, input.observedSweepSpacingMs);
|
||||
const ttlSeconds = Math.ceil((spacingMs * CANDIDATE_TTL_SWEEP_MULTIPLIER + input.graceMs * 2) / 1000);
|
||||
return Math.min(MAX_CANDIDATE_TTL_SECONDS, Math.max(MIN_CANDIDATE_TTL_SECONDS, ttlSeconds));
|
||||
}
|
||||
|
||||
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
|
||||
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
|
||||
const VOICE_RECONCILIATION_LAST_SWEEP_KEY = 'voice:reconcile:last-sweep-at';
|
||||
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
|
||||
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
|
||||
|
||||
@@ -115,8 +130,7 @@ export class VoiceReconciliationWorker {
|
||||
private readonly cadenceTtlSeconds: number;
|
||||
private readonly gatewayOnlyGraceMs: number;
|
||||
private readonly liveKitOnlyGraceMs: number;
|
||||
private readonly gatewayOnlyCandidateTtlSeconds: number;
|
||||
private readonly liveKitOnlyCandidateTtlSeconds: number;
|
||||
private observedSweepSpacingMs = 0;
|
||||
private intervalHandle: NodeJS.Timeout | null = null;
|
||||
private reconciling = false;
|
||||
private reconciliationLockLost = false;
|
||||
@@ -136,14 +150,6 @@ export class VoiceReconciliationWorker {
|
||||
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
|
||||
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
|
||||
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
|
||||
this.gatewayOnlyCandidateTtlSeconds = Math.max(
|
||||
60,
|
||||
Math.ceil((this.intervalMs * 4 + this.gatewayOnlyGraceMs * 4) / 1000),
|
||||
);
|
||||
this.liveKitOnlyCandidateTtlSeconds = Math.max(
|
||||
60,
|
||||
Math.ceil((this.intervalMs * 4 + this.liveKitOnlyGraceMs * 4) / 1000),
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -156,6 +162,7 @@ export class VoiceReconciliationWorker {
|
||||
intervalMs: this.intervalMs,
|
||||
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
|
||||
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
|
||||
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||
},
|
||||
'Starting VoiceReconciliationWorker',
|
||||
);
|
||||
@@ -175,6 +182,7 @@ export class VoiceReconciliationWorker {
|
||||
|
||||
async reconcile(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
await this.recordSweepSpacing(startTime);
|
||||
const discovery = await this.discoverActiveRooms();
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -233,6 +241,8 @@ export class VoiceReconciliationWorker {
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.info(
|
||||
{
|
||||
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||
roomsChecked,
|
||||
totalConfirmed,
|
||||
totalRepaired,
|
||||
@@ -300,6 +310,37 @@ export class VoiceReconciliationWorker {
|
||||
}
|
||||
}
|
||||
|
||||
private candidateTtlSeconds(graceMs: number): number {
|
||||
return candidateTtlSecondsFor({
|
||||
intervalMs: this.intervalMs,
|
||||
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||
graceMs,
|
||||
});
|
||||
}
|
||||
|
||||
private async recordSweepSpacing(startedAt: number): Promise<void> {
|
||||
try {
|
||||
const previous = await this.kvClient.get(VOICE_RECONCILIATION_LAST_SWEEP_KEY);
|
||||
const previousAt = previous === null ? Number.NaN : Number(previous);
|
||||
if (Number.isFinite(previousAt) && startedAt > previousAt) {
|
||||
this.observedSweepSpacingMs = Math.max(this.observedSweepSpacingMs, startedAt - previousAt);
|
||||
const gatewayOnlyCandidateTtlSeconds = this.candidateTtlSeconds(this.gatewayOnlyGraceMs);
|
||||
if (this.observedSweepSpacingMs >= gatewayOnlyCandidateTtlSeconds * 1000) {
|
||||
this.logger.warn(
|
||||
{
|
||||
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||
gatewayOnlyCandidateTtlSeconds,
|
||||
},
|
||||
'Reconciliation sweeps are further apart than the candidate TTL; divergent voice states will be deferred forever',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.kvClient.setex(VOICE_RECONCILIATION_LAST_SWEEP_KEY, LAST_SWEEP_KEY_TTL_SECONDS, String(startedAt));
|
||||
} catch (error) {
|
||||
this.logger.warn({error}, 'Failed to record reconciliation sweep spacing');
|
||||
}
|
||||
}
|
||||
|
||||
private async acquireCadenceLease(): Promise<boolean> {
|
||||
try {
|
||||
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
|
||||
@@ -993,7 +1034,7 @@ export class VoiceReconciliationWorker {
|
||||
}
|
||||
await this.kvClient.setex(
|
||||
key,
|
||||
this.gatewayOnlyCandidateTtlSeconds,
|
||||
this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
||||
);
|
||||
return false;
|
||||
@@ -1024,7 +1065,7 @@ export class VoiceReconciliationWorker {
|
||||
}
|
||||
await this.kvClient.setex(
|
||||
key,
|
||||
this.liveKitOnlyCandidateTtlSeconds,
|
||||
this.candidateTtlSeconds(this.liveKitOnlyGraceMs),
|
||||
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
||||
);
|
||||
return false;
|
||||
|
||||
@@ -243,6 +243,12 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
|
||||
voiceRoomStore,
|
||||
kvClient,
|
||||
logger: Logger,
|
||||
intervalMs: Config.worker.voiceReconciliation.intervalMs,
|
||||
staggerDelayMs: Config.worker.voiceReconciliation.staggerDelayMs,
|
||||
lockTtlSeconds: Config.worker.voiceReconciliation.lockTtlSeconds,
|
||||
cadenceTtlSeconds: Config.worker.voiceReconciliation.cadenceTtlSeconds,
|
||||
gatewayOnlyGraceMs: Config.worker.voiceReconciliation.gatewayOnlyGraceMs,
|
||||
liveKitOnlyGraceMs: Config.worker.voiceReconciliation.liveKitOnlyGraceMs,
|
||||
})
|
||||
: null;
|
||||
if (Config.voice.enabled && voiceTopology !== null) {
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {User} from '../../models/User';
|
||||
import type {UserGuildSettings} from '../../models/UserGuildSettings';
|
||||
import type {UserSettings} from '../../models/UserSettings';
|
||||
import type {WebAuthnCredential} from '../../models/WebAuthnCredential';
|
||||
import {buildHarvestDownloadUrl} from '../../user/services/HarvestDownloadUrl';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {createArchiveJsonBuffer} from '../utils/ArchiveJson';
|
||||
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
|
||||
@@ -685,10 +686,12 @@ async function createAndUploadArchive(params: ArchiveParams): Promise<ArchiveRes
|
||||
contentType: 'application/zip',
|
||||
expiresAt: expiresAt,
|
||||
});
|
||||
const downloadUrl = await storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: storageKey,
|
||||
expiresIn: ZIP_EXPIRY_MS / 1000,
|
||||
const downloadUrl = await buildHarvestDownloadUrl({
|
||||
userId,
|
||||
harvestId,
|
||||
storageKey,
|
||||
expiresInSeconds: ZIP_EXPIRY_MS / 1000,
|
||||
storageService,
|
||||
});
|
||||
return {fileSize, storageKey, expiresAt, downloadUrl};
|
||||
} finally {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content, maximum-scale=1, user-scalable=no">
|
||||
<meta name="description" content="Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.">
|
||||
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
||||
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
|
||||
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png">
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"dev": "TOKIO_WORKER_THREADS=4 RAYON_NUM_THREADS=4 cargo run --manifest-path ../tools/ci/Cargo.toml -- app-dev-server",
|
||||
"test": "vitest run",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts src/features/member/state/MemberSidebar.bench.ts",
|
||||
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts",
|
||||
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/TileFlowSolver.bench.ts",
|
||||
"bench:messages": "vitest bench --run src/features/messaging/utils/MessageGroupingUtils.bench.ts src/features/channel/components/ChannelMessageStreamUtils.bench.ts src/features/messaging/components/markdown/MarkdownRendering.bench.ts src/features/channel/components/MessageAttachmentUtils.bench.ts src/features/messaging/utils/MessagePaginationUtils.bench.ts src/features/platform/utils/ScrollPosition.bench.ts src/features/messaging/state/ReactionStateMachine.bench.ts",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && tsgo --noEmit",
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import {existsSync, mkdirSync, readdirSync, writeFileSync} from 'node:fs';
|
||||
import path, {dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {CopyRspackPlugin, DefinePlugin, HtmlRspackPlugin, SwcJsMinimizerRspackPlugin} from '@rspack/core';
|
||||
import {
|
||||
CopyRspackPlugin,
|
||||
DefinePlugin,
|
||||
HtmlRspackPlugin,
|
||||
LightningCssMinimizerRspackPlugin,
|
||||
SwcJsMinimizerRspackPlugin,
|
||||
} from '@rspack/core';
|
||||
import {createPoFileRule, getLinguiSwcPluginConfig} from './scripts/build/rspack/lingui.mjs';
|
||||
import {staticFilesPlugin} from './scripts/build/rspack/static-files.mjs';
|
||||
|
||||
@@ -579,6 +585,7 @@ export default () => {
|
||||
mangle: true,
|
||||
format: {comments: false},
|
||||
}),
|
||||
new LightningCssMinimizerRspackPlugin(),
|
||||
],
|
||||
},
|
||||
devServer: {
|
||||
|
||||
@@ -444,74 +444,74 @@ for (const size of SIZES) {
|
||||
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
output += ` <mask id="flx-mask-avatar-plain-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-avatar-notch-circle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-avatar-notch-phone-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${formatNumber(cutoutPhoneX)}" y="${formatNumber(cutoutPhoneY)}" width="${formatNumber(cutoutPhoneWidth)}" height="${formatNumber(cutoutPhoneHeight)}" rx="${formatNumber(cutoutPhoneRx)}" ry="${formatNumber(cutoutPhoneRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-avatar-notch-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="${formatNumber(typingCutoutRightCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
|
||||
<rect fill="black" x="${formatNumber(typingCutoutLeftCx)}" y="${formatNumber(typingCutoutY)}" width="${formatNumber(typingExtensionPx / size)}" height="${formatNumber(typingCutoutHeight)}" />
|
||||
<circle fill="black" cx="${formatNumber(typingCutoutLeftCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
<circle fill="black" cx="${formatNumber(idleCutoutCx)}" cy="${formatNumber(idleCutoutCy)}" r="${formatNumber(idleCutoutR)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
<rect fill="black" x="${formatNumber(dndBarX)}" y="${formatNumber(dndBarY)}" width="${formatNumber(dndBarWidth)}" height="${formatNumber(dndBarHeight)}" rx="${formatNumber(dndBarRx)}" ry="${formatNumber(dndBarRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(offlineInnerR)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
|
||||
</mask>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
output += ` <mask id="flx-mask-presence-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.25" cy="0.25" r="0.375" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.5" cy="0.5" r="0.25" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-presence-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<mask id="flx-mask-avatar-plain" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
@@ -6,8 +6,8 @@ import sharp from 'sharp';
|
||||
import {convertToCodePoints} from '../src/features/expressions/utils/EmojiCodepointUtils';
|
||||
|
||||
const EMOJI_SPRITES = {
|
||||
nonDiversityPerRow: 42,
|
||||
diversityPerRow: 10,
|
||||
basePerRow: 42,
|
||||
skinTonePerRow: 10,
|
||||
pickerPerRow: 11,
|
||||
pickerCount: 50,
|
||||
} as const;
|
||||
@@ -173,10 +173,10 @@ async function generateMainSpriteSheet(
|
||||
base.push({surrogates: obj.surrogates});
|
||||
}
|
||||
}
|
||||
await renderSpriteSheet(base, EMOJI_SPRITES.nonDiversityPerRow, 'spritesheet-emoji', outputDir);
|
||||
await renderSpriteSheet(base, EMOJI_SPRITES.basePerRow, 'spritesheet-emoji', outputDir);
|
||||
}
|
||||
|
||||
async function generateDiversitySpriteSheets(
|
||||
async function generateSkinToneSpriteSheets(
|
||||
emojiData: Record<string, Array<EmojiObject>>,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
@@ -195,7 +195,7 @@ async function generateDiversitySpriteSheets(
|
||||
if (skinEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir);
|
||||
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.skinTonePerRow, `spritesheet-${skinCodepoint}`, outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,8 +245,8 @@ async function main(): Promise<void> {
|
||||
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
|
||||
console.log('Generating main sprite sheet...');
|
||||
await generateMainSpriteSheet(emojiData, outputDir);
|
||||
console.log('Generating diversity sprite sheets...');
|
||||
await generateDiversitySpriteSheets(emojiData, outputDir);
|
||||
console.log('Generating skin tone sprite sheets...');
|
||||
await generateSkinToneSpriteSheets(emojiData, outputDir);
|
||||
console.log('Generating picker sprite sheet...');
|
||||
await generatePickerSpriteSheet(outputDir);
|
||||
console.log('Emoji sprites generated successfully.');
|
||||
|
||||
@@ -186,6 +186,7 @@ var {
|
||||
--typing-indicator-height: 1rem;
|
||||
--typing-pill-height: 1rem;
|
||||
--slowmode-indicator-height: 1rem;
|
||||
--message-filler-padding-bottom: 0.75rem;
|
||||
--scroller-spacer-height: 1.75rem;
|
||||
--typing-avatar-size: 0.75rem;
|
||||
--typing-indicator-animation-size: 1rem;
|
||||
@@ -681,9 +682,11 @@ img::before {
|
||||
}
|
||||
}
|
||||
|
||||
html.reduced-motion * {
|
||||
animation-duration: 0.01ms;
|
||||
animation-iteration-count: 1;
|
||||
transition-duration: 0.01ms;
|
||||
scroll-behavior: auto;
|
||||
html.reduced-motion *,
|
||||
html.reduced-motion *::before,
|
||||
html.reduced-motion *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
}
|
||||
|
||||
:global(html.reduced-motion) .heart {
|
||||
animation: nekoHeartFade 0.8s ease-out forwards;
|
||||
animation: nekoHeartFade 0.8s ease-out forwards !important;
|
||||
}
|
||||
|
||||
@keyframes nekoHeartFade {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
ChannelTypingIndicatorMode as ProtoChannelTypingIndicatorMode,
|
||||
DmMessagePreviewMode as ProtoDmMessagePreviewMode,
|
||||
HdrDisplayMode as ProtoHdrDisplayMode,
|
||||
MediaDimensionSize as ProtoMediaDimensionSize,
|
||||
} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/accessibility_pb';
|
||||
import {makeAutoObservable, reaction, runInAction} from 'mobx';
|
||||
|
||||
@@ -513,11 +512,6 @@ export enum GuildChannelPresenceIndicatorMode {
|
||||
HIDDEN = 2,
|
||||
}
|
||||
|
||||
export enum MediaDimensionSize {
|
||||
SMALL = 'small',
|
||||
LARGE = 'large',
|
||||
}
|
||||
|
||||
export enum DMMessagePreviewMode {
|
||||
ALL = 0,
|
||||
UNREAD_ONLY = 1,
|
||||
@@ -582,8 +576,6 @@ export interface AccessibilitySettings {
|
||||
showCustomEmojisInExpressionAutocomplete: boolean;
|
||||
showStickersInExpressionAutocomplete: boolean;
|
||||
showMemesInExpressionAutocomplete: boolean;
|
||||
attachmentMediaDimensionSize: MediaDimensionSize;
|
||||
embedMediaDimensionSize: MediaDimensionSize;
|
||||
voiceChannelJoinRequiresDoubleClick: boolean;
|
||||
customThemeCss: string | null;
|
||||
customThemeCssSyncAcrossDevices: boolean;
|
||||
@@ -631,15 +623,6 @@ const DM_PREVIEW_FROM_PROTO: Record<ProtoDmMessagePreviewMode, DMMessagePreviewM
|
||||
[ProtoDmMessagePreviewMode.UNREAD_ONLY]: DMMessagePreviewMode.UNREAD_ONLY,
|
||||
[ProtoDmMessagePreviewMode.NONE]: DMMessagePreviewMode.NONE,
|
||||
};
|
||||
const MEDIA_DIMENSION_TO_PROTO: Record<MediaDimensionSize, ProtoMediaDimensionSize> = {
|
||||
[MediaDimensionSize.SMALL]: ProtoMediaDimensionSize.SMALL,
|
||||
[MediaDimensionSize.LARGE]: ProtoMediaDimensionSize.LARGE,
|
||||
};
|
||||
const MEDIA_DIMENSION_FROM_PROTO: Record<ProtoMediaDimensionSize, MediaDimensionSize> = {
|
||||
[ProtoMediaDimensionSize.UNSPECIFIED]: MediaDimensionSize.SMALL,
|
||||
[ProtoMediaDimensionSize.SMALL]: MediaDimensionSize.SMALL,
|
||||
[ProtoMediaDimensionSize.LARGE]: MediaDimensionSize.LARGE,
|
||||
};
|
||||
const HDR_TO_PROTO: Record<HdrDisplayMode, ProtoHdrDisplayMode> = {
|
||||
[HdrDisplayMode.FULL]: ProtoHdrDisplayMode.FULL,
|
||||
[HdrDisplayMode.STANDARD]: ProtoHdrDisplayMode.STANDARD,
|
||||
@@ -697,8 +680,6 @@ class Accessibility {
|
||||
showCustomEmojisInExpressionAutocomplete = true;
|
||||
showStickersInExpressionAutocomplete = true;
|
||||
showMemesInExpressionAutocomplete = true;
|
||||
attachmentMediaDimensionSize = MediaDimensionSize.LARGE;
|
||||
embedMediaDimensionSize = MediaDimensionSize.SMALL;
|
||||
voiceChannelJoinRequiresDoubleClick = false;
|
||||
systemReducedMotion = false;
|
||||
customThemeCss: string | null = null;
|
||||
@@ -811,8 +792,6 @@ class Accessibility {
|
||||
'showCustomEmojisInExpressionAutocomplete',
|
||||
'showStickersInExpressionAutocomplete',
|
||||
'showMemesInExpressionAutocomplete',
|
||||
'attachmentMediaDimensionSize',
|
||||
'embedMediaDimensionSize',
|
||||
'voiceChannelJoinRequiresDoubleClick',
|
||||
'customThemeCssSyncAcrossDevices',
|
||||
'showFavorites',
|
||||
@@ -871,8 +850,6 @@ class Accessibility {
|
||||
showCustomEmojisInAutocomplete: s.showCustomEmojisInExpressionAutocomplete,
|
||||
showStickersInAutocomplete: s.showStickersInExpressionAutocomplete,
|
||||
showMemesInAutocomplete: s.showMemesInExpressionAutocomplete,
|
||||
attachmentMediaDimensionSize: MEDIA_DIMENSION_TO_PROTO[s.attachmentMediaDimensionSize],
|
||||
embedMediaDimensionSize: MEDIA_DIMENSION_TO_PROTO[s.embedMediaDimensionSize],
|
||||
voiceChannelJoinRequiresDoubleClick: s.voiceChannelJoinRequiresDoubleClick,
|
||||
customThemeCss: s.customThemeCssSyncAcrossDevices ? (s.customThemeCss ?? '') : (s.serverCustomThemeCss ?? ''),
|
||||
showFavorites: s.showFavorites,
|
||||
@@ -948,8 +925,6 @@ class Accessibility {
|
||||
if (m.showStickersInAutocomplete !== undefined)
|
||||
s.showStickersInExpressionAutocomplete = m.showStickersInAutocomplete;
|
||||
if (m.showMemesInAutocomplete !== undefined) s.showMemesInExpressionAutocomplete = m.showMemesInAutocomplete;
|
||||
s.attachmentMediaDimensionSize = MEDIA_DIMENSION_FROM_PROTO[m.attachmentMediaDimensionSize];
|
||||
s.embedMediaDimensionSize = MEDIA_DIMENSION_FROM_PROTO[m.embedMediaDimensionSize];
|
||||
if (m.voiceChannelJoinRequiresDoubleClick !== undefined)
|
||||
s.voiceChannelJoinRequiresDoubleClick = m.voiceChannelJoinRequiresDoubleClick;
|
||||
if (m.customThemeCss !== undefined) {
|
||||
@@ -1278,10 +1253,6 @@ class Accessibility {
|
||||
this.showStickersInExpressionAutocomplete = validated.showStickersInExpressionAutocomplete;
|
||||
if (validated.showMemesInExpressionAutocomplete !== undefined)
|
||||
this.showMemesInExpressionAutocomplete = validated.showMemesInExpressionAutocomplete;
|
||||
if (validated.attachmentMediaDimensionSize !== undefined)
|
||||
this.attachmentMediaDimensionSize = validated.attachmentMediaDimensionSize;
|
||||
if (validated.embedMediaDimensionSize !== undefined)
|
||||
this.embedMediaDimensionSize = validated.embedMediaDimensionSize;
|
||||
if (validated.voiceChannelJoinRequiresDoubleClick !== undefined)
|
||||
this.voiceChannelJoinRequiresDoubleClick = validated.voiceChannelJoinRequiresDoubleClick;
|
||||
if (hasCustomThemeCssUpdate) {
|
||||
@@ -1390,8 +1361,6 @@ class Accessibility {
|
||||
data.showStickersInExpressionAutocomplete ?? this.showStickersInExpressionAutocomplete,
|
||||
showMemesInExpressionAutocomplete:
|
||||
data.showMemesInExpressionAutocomplete ?? this.showMemesInExpressionAutocomplete,
|
||||
attachmentMediaDimensionSize: data.attachmentMediaDimensionSize ?? this.attachmentMediaDimensionSize,
|
||||
embedMediaDimensionSize: data.embedMediaDimensionSize ?? this.embedMediaDimensionSize,
|
||||
voiceChannelJoinRequiresDoubleClick:
|
||||
data.voiceChannelJoinRequiresDoubleClick ?? this.voiceChannelJoinRequiresDoubleClick,
|
||||
customThemeCss: data.customThemeCss !== undefined ? data.customThemeCss : this.customThemeCss,
|
||||
|
||||
@@ -75,8 +75,8 @@ const setBadge = (badge: number): void => {
|
||||
export const AppBadge: React.FC = observer(() => {
|
||||
const relationships = Relationships.getRelationships();
|
||||
const unreadMessageBadgeEnabled = Notification.unreadMessageBadgeEnabled;
|
||||
const mentionCount = GuildReadState.getTotalMentionCount();
|
||||
const hasUnread = GuildReadState.hasAnyUnread;
|
||||
const mentionCount = GuildReadState.mentionCountAcrossGuilds();
|
||||
const hasUnread = GuildReadState.anyGuildUnread;
|
||||
const pendingCount = RuntimeConfig.directMessagesDisabled
|
||||
? 0
|
||||
: relationships.filter((relationship) => relationship.type === RelationshipTypes.INCOMING_REQUEST).length;
|
||||
|
||||
@@ -205,5 +205,5 @@ function findHookOrderHazards(): Array<HookOrderFinding> {
|
||||
describe('React hook order stability', () => {
|
||||
it('keeps guard returns outside hook-bearing component bodies', () => {
|
||||
expect(findHookOrderHazards()).toEqual([]);
|
||||
}, 10_000);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export const FavoritesGuildHeaderBottomSheet: React.FC<FavoritesGuildHeaderBotto
|
||||
({isOpen, onClose}) => {
|
||||
const {i18n} = useLingui();
|
||||
const hideMutedChannels = Favorites.hideMutedChannels;
|
||||
const settings = UserGuildSettings.getSettings(FAVORITES_GUILD_ID);
|
||||
const settings = UserGuildSettings.getSettingsForScope(FAVORITES_GUILD_ID);
|
||||
const isMuted = settings?.muted ?? false;
|
||||
const handleAddChannel = () => {
|
||||
ModalCommands.pushAfterBottomSheetClose(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/dialogs/Modal.module.css';
|
||||
import {resolveModalBackdropMotionSpec, resolveModalMotionSpec} from '@app/features/app/components/dialogs/ModalMotion';
|
||||
import {useModalBackHandler} from '@app/features/app/hooks/useModalBackHandler';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
@@ -103,51 +104,16 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
const mobileFullscreenAnimations = {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
};
|
||||
const profileSlideAnimations = prefersReducedMotion
|
||||
? {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
}
|
||||
: {
|
||||
initial: {opacity: 0, x: 14},
|
||||
animate: {opacity: 1, x: 0},
|
||||
exit: {opacity: 0, x: 14},
|
||||
};
|
||||
const defaultAnimations = prefersReducedMotion
|
||||
? {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
}
|
||||
: {
|
||||
initial: {opacity: 0, scale: 0.95},
|
||||
animate: {opacity: 1, scale: 1},
|
||||
exit: {opacity: 0, scale: 0.95},
|
||||
};
|
||||
const animations = isFullscreenOnMobile
|
||||
? mobileFullscreenAnimations
|
||||
: transitionPreset === 'profile-slide'
|
||||
? profileSlideAnimations
|
||||
: defaultAnimations;
|
||||
const rootTransition: Transition =
|
||||
prefersReducedMotion || transitionPreset === 'instant'
|
||||
? {duration: 0}
|
||||
: isFullscreenOnMobile
|
||||
? {duration: 0.15}
|
||||
: transitionPreset === 'profile-slide'
|
||||
? {duration: 0.14, ease: 'easeOut'}
|
||||
: {
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
mass: 0.8,
|
||||
};
|
||||
const motionSpec = resolveModalMotionSpec({
|
||||
transitionPreset,
|
||||
prefersReducedMotion,
|
||||
isFullscreenOnMobile,
|
||||
});
|
||||
const backdropMotionSpec = resolveModalBackdropMotionSpec({
|
||||
transitionPreset,
|
||||
prefersReducedMotion,
|
||||
isMobile,
|
||||
});
|
||||
const handleBackdropClickEvent = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
@@ -226,23 +192,16 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
};
|
||||
}, [isVisible]);
|
||||
const isCenteredOnMobile = isMobile && !useFullscreenLayer;
|
||||
const shouldInstantBackdrop = isMobile && !prefersReducedMotion;
|
||||
return (
|
||||
<FloatingPortal root={portalHost ?? undefined} data-flx="app.modal.floating-portal">
|
||||
{needsBackdrop && isVisible && (
|
||||
<motion.div
|
||||
className={styles.modalBackdrop}
|
||||
style={{zIndex: backdropZIndex}}
|
||||
initial={{opacity: shouldInstantBackdrop || isInstantTransition ? 0.85 : 0}}
|
||||
animate={{opacity: 0.85}}
|
||||
exit={{opacity: 0}}
|
||||
transition={
|
||||
prefersReducedMotion || isInstantTransition
|
||||
? {duration: 0}
|
||||
: shouldInstantBackdrop
|
||||
? {duration: 0.15}
|
||||
: {duration: 0.2}
|
||||
}
|
||||
initial={backdropMotionSpec.initial}
|
||||
animate={backdropMotionSpec.animate}
|
||||
exit={backdropMotionSpec.exit}
|
||||
transition={backdropMotionSpec.transition as Transition}
|
||||
data-flx="app.modal.modal-backdrop"
|
||||
/>
|
||||
)}
|
||||
@@ -326,8 +285,10 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
className,
|
||||
)}
|
||||
data-flx="app.modal.root"
|
||||
{...animations}
|
||||
transition={rootTransition}
|
||||
initial={motionSpec.initial}
|
||||
animate={motionSpec.animate}
|
||||
exit={motionSpec.exit}
|
||||
transition={motionSpec.transition as Transition}
|
||||
onAnimationStart={handleAnimationStart}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
ref={setMotionElementRef}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ModalTransitionPreset} from '@app/features/ui/utils/ModalUtils';
|
||||
|
||||
export interface ModalMotionInput {
|
||||
transitionPreset: ModalTransitionPreset;
|
||||
prefersReducedMotion: boolean;
|
||||
isFullscreenOnMobile: boolean;
|
||||
}
|
||||
|
||||
export interface ModalBackdropMotionInput {
|
||||
transitionPreset: ModalTransitionPreset;
|
||||
prefersReducedMotion: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const SETTLED_SURFACE = {opacity: 1, scale: 1} as const;
|
||||
|
||||
const BACKDROP_RESTING_OPACITY = 0.85;
|
||||
|
||||
const BACKDROP_DISMISS_SPRING = {type: 'spring', stiffness: 1000, damping: 48, mass: 1} as const;
|
||||
|
||||
const QUICK_TRANSITION = {duration: 0.12, ease: 'easeOut'} as const;
|
||||
|
||||
const QUICK_SURFACE_OFFSET = 0.98;
|
||||
|
||||
export function resolveModalMotionSpec({
|
||||
transitionPreset,
|
||||
prefersReducedMotion,
|
||||
isFullscreenOnMobile,
|
||||
}: ModalMotionInput) {
|
||||
if (transitionPreset === 'instant') {
|
||||
return {
|
||||
initial: SETTLED_SURFACE,
|
||||
animate: SETTLED_SURFACE,
|
||||
exit: SETTLED_SURFACE,
|
||||
transition: {duration: 0},
|
||||
};
|
||||
}
|
||||
if (prefersReducedMotion) {
|
||||
return {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0},
|
||||
};
|
||||
}
|
||||
if (isFullscreenOnMobile) {
|
||||
return {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0.15},
|
||||
};
|
||||
}
|
||||
if (transitionPreset === 'quick') {
|
||||
return {
|
||||
initial: {opacity: 0, scale: QUICK_SURFACE_OFFSET},
|
||||
animate: {opacity: 1, scale: 1},
|
||||
exit: {opacity: 0, scale: QUICK_SURFACE_OFFSET},
|
||||
transition: QUICK_TRANSITION,
|
||||
};
|
||||
}
|
||||
if (transitionPreset === 'profile-slide') {
|
||||
return {
|
||||
initial: {opacity: 0, x: 14},
|
||||
animate: {opacity: 1, x: 0},
|
||||
exit: {opacity: 0, x: 14},
|
||||
transition: {duration: 0.14, ease: 'easeOut'},
|
||||
};
|
||||
}
|
||||
return {
|
||||
initial: {opacity: 0, scale: 0.95},
|
||||
animate: {opacity: 1, scale: 1},
|
||||
exit: {opacity: 0, scale: 0.95},
|
||||
transition: {type: 'spring', stiffness: 400, damping: 30, mass: 0.8},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModalBackdropMotionSpec({
|
||||
transitionPreset,
|
||||
prefersReducedMotion,
|
||||
isMobile,
|
||||
}: ModalBackdropMotionInput) {
|
||||
const mountsAtRest = (isMobile && !prefersReducedMotion) || transitionPreset === 'instant';
|
||||
const surface = {
|
||||
initial: {opacity: mountsAtRest ? BACKDROP_RESTING_OPACITY : 0},
|
||||
animate: {opacity: BACKDROP_RESTING_OPACITY},
|
||||
exit: {opacity: 0},
|
||||
};
|
||||
if (prefersReducedMotion) {
|
||||
return {...surface, transition: {duration: 0}};
|
||||
}
|
||||
if (transitionPreset === 'instant') {
|
||||
return {...surface, transition: BACKDROP_DISMISS_SPRING};
|
||||
}
|
||||
if (transitionPreset === 'quick') {
|
||||
return {...surface, transition: QUICK_TRANSITION};
|
||||
}
|
||||
if (isMobile) {
|
||||
return {...surface, transition: {duration: 0.15}};
|
||||
}
|
||||
return {...surface, transition: {duration: 0.2}};
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR,
|
||||
SIGN_OUT_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -223,7 +223,7 @@ const SidebarSections: React.FC<SidebarSectionsProps> = observer(
|
||||
scrollSpyContext.scrollToSection(sectionId);
|
||||
return;
|
||||
}
|
||||
ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: tabType, section: sectionId});
|
||||
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: tabType, section: sectionId});
|
||||
};
|
||||
return (
|
||||
<SettingsModalSidebarSubItems
|
||||
|
||||
@@ -21,7 +21,7 @@ import {SubscriptionCard} from '@app/features/app/components/dialogs/components/
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import GeoIP from '@app/features/app/state/GeoIP';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {
|
||||
@@ -122,7 +122,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
perksSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'});
|
||||
}, []);
|
||||
const navigateToRedeemGift = useCallback(() => {
|
||||
ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'});
|
||||
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'});
|
||||
}, []);
|
||||
const handleCancelSubscriptionConfirmed = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/GiftInventoryBanner.module.css';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
@@ -37,7 +37,7 @@ export const GiftInventoryBanner: React.FC<GiftInventoryBannerProps> = observer(
|
||||
<Button
|
||||
variant="secondary"
|
||||
small
|
||||
onClick={() => ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'})}
|
||||
onClick={() => ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'})}
|
||||
data-flx="app.plutonium.gift-inventory-banner.button.dispatch"
|
||||
>
|
||||
<Trans>View gifts</Trans>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import {buildMemberSearchRank} from '@app/features/messaging/utils/AutocompleteOptionBuilders';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {matchSorter} from 'match-sorter';
|
||||
|
||||
export interface ParsedOverrideMemberQuery {
|
||||
usernameQuery: string;
|
||||
tagQuery: string | null;
|
||||
hasTagSeparator: boolean;
|
||||
}
|
||||
|
||||
export interface OverrideMemberSelection {
|
||||
cachedMembers: ReadonlyArray<GuildMember>;
|
||||
workerMemberIds: ReadonlyArray<string>;
|
||||
resolveMember: (userId: string) => GuildMember | null;
|
||||
excludedIds: ReadonlySet<string>;
|
||||
guildId: string;
|
||||
query: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function parseOverrideMemberQuery(query: string): ParsedOverrideMemberQuery {
|
||||
const hashIndex = query.indexOf('#');
|
||||
if (hashIndex === -1) {
|
||||
return {usernameQuery: query, tagQuery: null, hasTagSeparator: false};
|
||||
}
|
||||
return {
|
||||
usernameQuery: query.slice(0, hashIndex),
|
||||
tagQuery: query.slice(hashIndex + 1),
|
||||
hasTagSeparator: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOverrideMemberLabel(member: GuildMember, guildId: string): string {
|
||||
return NicknameUtils.getNickname(member.user, guildId);
|
||||
}
|
||||
|
||||
function compareByLabel(a: GuildMember, b: GuildMember, guildId: string): number {
|
||||
return getOverrideMemberLabel(a, guildId)
|
||||
.toLowerCase()
|
||||
.localeCompare(getOverrideMemberLabel(b, guildId).toLowerCase());
|
||||
}
|
||||
|
||||
function matchOverrideMembers(
|
||||
members: ReadonlyArray<GuildMember>,
|
||||
guildId: string,
|
||||
parsed: ParsedOverrideMemberQuery,
|
||||
): Array<GuildMember> {
|
||||
if (parsed.hasTagSeparator) {
|
||||
const usernameLower = parsed.usernameQuery.toLowerCase();
|
||||
const tagLower = parsed.tagQuery?.toLowerCase() ?? '';
|
||||
return members.filter((member) => {
|
||||
const nick = member.nick?.toLowerCase() ?? '';
|
||||
const username = member.user.username.toLowerCase();
|
||||
const matchesUsername =
|
||||
usernameLower.length === 0 || username.startsWith(usernameLower) || nick.startsWith(usernameLower);
|
||||
const matchesTag = tagLower.length === 0 || member.user.discriminator.startsWith(tagLower);
|
||||
return matchesUsername && matchesTag;
|
||||
});
|
||||
}
|
||||
const trimmed = parsed.usernameQuery.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return [...members];
|
||||
}
|
||||
return matchSorter([...members], trimmed, {
|
||||
keys: [(member: GuildMember) => getOverrideMemberLabel(member, guildId), 'nick', 'user.username', 'user.tag'],
|
||||
});
|
||||
}
|
||||
|
||||
export function selectOverrideMembers({
|
||||
cachedMembers,
|
||||
workerMemberIds,
|
||||
resolveMember,
|
||||
excludedIds,
|
||||
guildId,
|
||||
query,
|
||||
limit,
|
||||
}: OverrideMemberSelection): Array<GuildMember> {
|
||||
const candidates = new Map<string, GuildMember>();
|
||||
for (const member of cachedMembers) {
|
||||
if (excludedIds.has(member.user.id)) continue;
|
||||
candidates.set(member.user.id, member);
|
||||
}
|
||||
const workerRanked: Array<GuildMember> = [];
|
||||
for (const userId of workerMemberIds) {
|
||||
if (excludedIds.has(userId)) continue;
|
||||
const cached = candidates.get(userId);
|
||||
if (cached != null) {
|
||||
workerRanked.push(cached);
|
||||
continue;
|
||||
}
|
||||
const member = resolveMember(userId);
|
||||
if (member == null) continue;
|
||||
candidates.set(userId, member);
|
||||
workerRanked.push(member);
|
||||
}
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return [...candidates.values()].sort((a, b) => compareByLabel(a, b, guildId)).slice(0, limit);
|
||||
}
|
||||
const matched = matchOverrideMembers([...candidates.values()], guildId, parseOverrideMemberQuery(trimmed));
|
||||
const searchRank = buildMemberSearchRank(workerRanked);
|
||||
if (searchRank.size === 0) {
|
||||
return [...matched].sort((a, b) => compareByLabel(a, b, guildId)).slice(0, limit);
|
||||
}
|
||||
const noRankSentinel = Number.MAX_SAFE_INTEGER;
|
||||
return [...matched]
|
||||
.sort((a, b) => {
|
||||
const rankA = searchRank.get(a.user.id) ?? noRankSentinel;
|
||||
const rankB = searchRank.get(b.user.id) ?? noRankSentinel;
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
return compareByLabel(a, b, guildId);
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
getOverrideMemberLabel,
|
||||
parseOverrideMemberQuery,
|
||||
selectOverrideMembers,
|
||||
} from '@app/features/app/components/dialogs/shared/AddOverrideMemberSearch';
|
||||
import styles from '@app/features/app/components/dialogs/shared/AddOverridePopout.module.css';
|
||||
import {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {ROLES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
|
||||
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
|
||||
@@ -14,7 +18,6 @@ import {
|
||||
type SearchableListPopoutItem,
|
||||
type SearchableListPopoutSection,
|
||||
} from '@app/features/ui/popover/searchable_list_popout/SearchableListPopout';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {matchSorter} from 'match-sorter';
|
||||
@@ -46,74 +49,6 @@ const MEMBERS_LIMIT = 10;
|
||||
const WORKER_RESULT_LIMIT = 25;
|
||||
const SERVER_DEBOUNCE_MS = 300;
|
||||
|
||||
interface ParsedMemberQuery {
|
||||
usernameQuery: string;
|
||||
tagQuery: string | null;
|
||||
hasTagSeparator: boolean;
|
||||
}
|
||||
|
||||
function parseMemberQuery(query: string): ParsedMemberQuery {
|
||||
const hashIndex = query.indexOf('#');
|
||||
if (hashIndex === -1) {
|
||||
return {usernameQuery: query, tagQuery: null, hasTagSeparator: false};
|
||||
}
|
||||
return {
|
||||
usernameQuery: query.slice(0, hashIndex),
|
||||
tagQuery: query.slice(hashIndex + 1),
|
||||
hasTagSeparator: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getMemberDisplayName(member: GuildMember, guildId: string): string {
|
||||
return NicknameUtils.getNickname(member.user, guildId);
|
||||
}
|
||||
|
||||
function compareByDisplayName(a: GuildMember, b: GuildMember, guildId: string): number {
|
||||
const aName = getMemberDisplayName(a, guildId).toLowerCase();
|
||||
const bName = getMemberDisplayName(b, guildId).toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
}
|
||||
|
||||
function filterMembers(
|
||||
members: Array<GuildMember>,
|
||||
guildId: string,
|
||||
parsed: ParsedMemberQuery,
|
||||
stableOrder?: Map<string, number>,
|
||||
): Array<GuildMember> {
|
||||
let matched: Array<GuildMember>;
|
||||
if (parsed.hasTagSeparator) {
|
||||
const usernameLower = parsed.usernameQuery.toLowerCase();
|
||||
const tagLower = parsed.tagQuery?.toLowerCase() ?? '';
|
||||
matched = members.filter((member) => {
|
||||
const nick = member.nick?.toLowerCase() ?? '';
|
||||
const username = member.user.username.toLowerCase();
|
||||
const matchesUsername =
|
||||
usernameLower.length === 0 || username.startsWith(usernameLower) || nick.startsWith(usernameLower);
|
||||
const matchesTag = tagLower.length === 0 || member.user.discriminator.startsWith(tagLower);
|
||||
return matchesUsername && matchesTag;
|
||||
});
|
||||
} else {
|
||||
const trimmed = parsed.usernameQuery.trim();
|
||||
if (trimmed.length === 0) {
|
||||
matched = [...members];
|
||||
} else {
|
||||
matched = matchSorter(members, trimmed, {
|
||||
keys: [(member) => getMemberDisplayName(member, guildId), 'nick', 'user.username', 'user.tag'],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (stableOrder && stableOrder.size > 0) {
|
||||
const NEW_RANK = Number.MAX_SAFE_INTEGER;
|
||||
return [...matched].sort((a, b) => {
|
||||
const ra = stableOrder.get(a.user.id) ?? NEW_RANK;
|
||||
const rb = stableOrder.get(b.user.id) ?? NEW_RANK;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return compareByDisplayName(a, b, guildId);
|
||||
});
|
||||
}
|
||||
return [...matched].sort((a, b) => compareByDisplayName(a, b, guildId));
|
||||
}
|
||||
|
||||
export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(function AddOverridePopout({
|
||||
guildId,
|
||||
existingOverwriteIds,
|
||||
@@ -126,11 +61,6 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
||||
const [serverMemberIds, setServerMemberIds] = useState<Array<string>>([]);
|
||||
const searchContextRef = useRef<SearchContext | null>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const sessionRef = useRef<{key: string; order: Map<string, number>; nextRank: number}>({
|
||||
key: '',
|
||||
order: new Map(),
|
||||
nextRank: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
const context = MemberSearch.getSearchContext((results) => {
|
||||
setServerMemberIds(results.map((result) => result.id));
|
||||
@@ -147,7 +77,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
const parsed = parseMemberQuery(trimmed);
|
||||
const parsed = parseOverrideMemberQuery(trimmed);
|
||||
const queryForServer = parsed.usernameQuery.trim();
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
@@ -155,11 +85,11 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
||||
}
|
||||
const context = searchContextRef.current;
|
||||
if (queryForServer.length === 0) {
|
||||
context?.clearQuery();
|
||||
context?.cancelSearch();
|
||||
setServerMemberIds([]);
|
||||
return;
|
||||
}
|
||||
context?.setQuery(queryForServer, {guild: guildId});
|
||||
context?.beginSearch(queryForServer, {guild: guildId});
|
||||
if (GuildMembers.isGuildFullyLoaded(guildId)) {
|
||||
return;
|
||||
}
|
||||
@@ -176,40 +106,15 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
||||
}, [guild, existingOverwriteIds]);
|
||||
const members = useMemo(() => {
|
||||
if (!guild) return [];
|
||||
const cached = GuildMembers.getMembers(guildId).filter((member) => !existingOverwriteIds.has(member.user.id));
|
||||
const trimmed = searchQuery.trim();
|
||||
const parsed = parseMemberQuery(trimmed);
|
||||
const hasQuery = trimmed.length > 0;
|
||||
if (!hasQuery) {
|
||||
sessionRef.current = {key: `${guildId}:`, order: new Map(), nextRank: 0};
|
||||
return [...cached].sort((a, b) => compareByDisplayName(a, b, guildId)).slice(0, MEMBERS_LIMIT);
|
||||
}
|
||||
const sessionKey = `${guildId}:${trimmed}`;
|
||||
if (sessionRef.current.key !== sessionKey) {
|
||||
sessionRef.current = {key: sessionKey, order: new Map(), nextRank: 0};
|
||||
}
|
||||
const merged = new Map<string, GuildMember>();
|
||||
for (const member of cached) {
|
||||
merged.set(member.user.id, member);
|
||||
}
|
||||
for (const id of serverMemberIds) {
|
||||
if (existingOverwriteIds.has(id) || merged.has(id)) continue;
|
||||
const member = GuildMembers.getMember(guildId, id);
|
||||
if (member) {
|
||||
merged.set(id, member);
|
||||
}
|
||||
}
|
||||
const result = filterMembers(Array.from(merged.values()), guildId, parsed, sessionRef.current.order).slice(
|
||||
0,
|
||||
MEMBERS_LIMIT,
|
||||
);
|
||||
const session = sessionRef.current;
|
||||
for (const member of result) {
|
||||
if (!session.order.has(member.user.id)) {
|
||||
session.order.set(member.user.id, session.nextRank++);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return selectOverrideMembers({
|
||||
cachedMembers: GuildMembers.getMembers(guildId),
|
||||
workerMemberIds: serverMemberIds,
|
||||
resolveMember: (userId) => GuildMembers.getMember(guildId, userId),
|
||||
excludedIds: existingOverwriteIds,
|
||||
guildId,
|
||||
query: searchQuery,
|
||||
limit: MEMBERS_LIMIT,
|
||||
});
|
||||
}, [guild, guildId, existingOverwriteIds, searchQuery, serverMemberIds]);
|
||||
const filteredRoles = useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
@@ -244,7 +149,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
||||
}, [filteredRoles, onClose, onSelect]);
|
||||
const memberItems = useMemo<Array<SearchableListPopoutItem>>(() => {
|
||||
return members.map((member) => {
|
||||
const displayName = getMemberDisplayName(member, guildId);
|
||||
const displayName = getOverrideMemberLabel(member, guildId);
|
||||
return {
|
||||
id: `member-${member.user.id}`,
|
||||
ariaLabel: displayName,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface GuildComboboxRenderersConfig<T extends GuildComboboxOption> {
|
||||
getNotice?: (option: T, disabled: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
const ROW_INITIALS_MAX_LENGTH = 1;
|
||||
|
||||
const renderRow = <T extends GuildComboboxOption>(
|
||||
option: T,
|
||||
disabled: boolean,
|
||||
@@ -34,9 +36,8 @@ const renderRow = <T extends GuildComboboxOption>(
|
||||
getNotice?: (option: T, disabled: boolean) => React.ReactNode,
|
||||
) => {
|
||||
const isGlobal = !option.value;
|
||||
const iconUrl =
|
||||
option.iconUrl ?? (option.icon ? AvatarUtils.getGuildIconURL({id: option.value, icon: option.icon}) : null);
|
||||
const initial = option.label.charAt(0).toUpperCase();
|
||||
const iconUrl = option.iconUrl ?? null;
|
||||
const initial = truncateInitials(getInitialsFromName(option.label), ROW_INITIALS_MAX_LENGTH).toUpperCase();
|
||||
const notice = getNotice?.(option, disabled);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -392,7 +392,7 @@ export const MobileSectionNav: React.FC<MobileSectionNavProps> = observer(
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
useEffect(() => {
|
||||
if (!activeSectionId) return;
|
||||
const node = scrollerRef.current?.getScrollerNode();
|
||||
const node = scrollerRef.current?.getViewportElement();
|
||||
if (!node) return;
|
||||
const activeButton = node.querySelector(`[data-section-id="${activeSectionId}"]`);
|
||||
if (activeButton instanceof HTMLElement) {
|
||||
|
||||
@@ -125,7 +125,7 @@ export const SettingsModalDesktopScroll: React.FC<SettingsModalDesktopScrollProp
|
||||
if (!scrollerRef) {
|
||||
return;
|
||||
}
|
||||
const node = internalRef.current?.getScrollerNode() ?? null;
|
||||
const node = internalRef.current?.getViewportElement() ?? null;
|
||||
if (typeof scrollerRef === 'function') {
|
||||
scrollerRef(node);
|
||||
} else {
|
||||
|
||||
@@ -1,49 +1,34 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {mergeFrozenUnreadOrder} from './UnreadChannelOrder';
|
||||
import {selectFrozenUnreadChannels} from './UnreadChannelOrder';
|
||||
|
||||
function ids(channels: ReadonlyArray<{id: string}>): Array<string> {
|
||||
return channels.map((channel) => channel.id);
|
||||
}
|
||||
|
||||
describe('mergeFrozenUnreadOrder', () => {
|
||||
describe('selectFrozenUnreadChannels', () => {
|
||||
it('keeps channels in their frozen position while the list is open', () => {
|
||||
const order = new Map([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
['c', 2],
|
||||
]);
|
||||
const merged = mergeFrozenUnreadOrder(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]);
|
||||
expect(ids(merged)).toEqual(['a', 'b', 'c']);
|
||||
expect(ids(selectFrozenUnreadChannels(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]))).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('shows a channel that becomes unread after the order was frozen', () => {
|
||||
it('hides a channel that becomes unread after the order was frozen', () => {
|
||||
const order = new Map([['a', 0]]);
|
||||
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
|
||||
expect(ids(merged)).toEqual(['a', 'late']);
|
||||
expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]))).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('appends late arrivals after existing entries rather than reshuffling', () => {
|
||||
const order = new Map([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
]);
|
||||
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'b'}, {id: 'a'}]);
|
||||
expect(ids(merged)).toEqual(['a', 'b', 'late']);
|
||||
});
|
||||
|
||||
it('remembers a late arrival position once assigned', () => {
|
||||
it('never grows the list when every live channel is new to the order', () => {
|
||||
const order = new Map([['a', 0]]);
|
||||
mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
|
||||
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'a'}]);
|
||||
expect(ids(merged)).toEqual(['a', 'late']);
|
||||
expect(ids(selectFrozenUnreadChannels(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops nothing when every channel is new', () => {
|
||||
const order = new Map<string, number>();
|
||||
const merged = mergeFrozenUnreadOrder(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]);
|
||||
expect(ids(merged)).toEqual(['x', 'y', 'z']);
|
||||
it('yields nothing for an empty order', () => {
|
||||
expect(ids(selectFrozenUnreadChannels(new Map<string, number>(), [{id: 'x'}]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not resurrect channels that are no longer unread', () => {
|
||||
@@ -51,7 +36,12 @@ describe('mergeFrozenUnreadOrder', () => {
|
||||
['a', 0],
|
||||
['gone', 1],
|
||||
]);
|
||||
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}]);
|
||||
expect(ids(merged)).toEqual(['a']);
|
||||
expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}]))).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('does not mutate the order it was given', () => {
|
||||
const order = new Map([['a', 0]]);
|
||||
selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]);
|
||||
expect([...order.keys()]).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
function resolveUnreadOrderIndex(order: Map<string, number>, channelId: string): number {
|
||||
const index = order.get(channelId);
|
||||
if (index == null) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return index;
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
export const UNREAD_REFREEZE_COOLDOWN_MS = 10_000;
|
||||
|
||||
export interface FrozenUnreadOrder {
|
||||
order: Map<string, number>;
|
||||
}
|
||||
|
||||
export function mergeFrozenUnreadOrder<T extends {id: string}>(
|
||||
order: Map<string, number>,
|
||||
current: ReadonlyArray<T>,
|
||||
export function selectFrozenUnreadChannels<T extends {id: string}>(
|
||||
order: ReadonlyMap<string, number>,
|
||||
live: ReadonlyArray<T>,
|
||||
): Array<T> {
|
||||
for (const channel of current) {
|
||||
if (!order.has(channel.id)) {
|
||||
order.set(channel.id, order.size);
|
||||
}
|
||||
const held: Array<{index: number; channel: T}> = [];
|
||||
for (const channel of live) {
|
||||
const index = order.get(channel.id);
|
||||
if (index == null) continue;
|
||||
held.push({index, channel});
|
||||
}
|
||||
return [...current].sort((a, b) => resolveUnreadOrderIndex(order, a.id) - resolveUnreadOrderIndex(order, b.id));
|
||||
held.sort((a, b) => a.index - b.index);
|
||||
return held.map((entry) => entry.channel);
|
||||
}
|
||||
|
||||
export function useFrozenUnreadOrder<S extends FrozenUnreadOrder, T extends {id: string}>(
|
||||
takeSnapshot: () => S,
|
||||
live: ReadonlyArray<T>,
|
||||
): {snapshot: S; channels: Array<T>} {
|
||||
const [snapshot, setSnapshot] = useState(takeSnapshot);
|
||||
const [cooldownAttempt, setCooldownAttempt] = useState(0);
|
||||
const snapshotTakenAtRef = useRef(Date.now());
|
||||
const hasShownFrozenListRef = useRef(false);
|
||||
const channels = useMemo(() => selectFrozenUnreadChannels(snapshot.order, live), [snapshot, live]);
|
||||
useEffect(() => {
|
||||
if (channels.length > 0) {
|
||||
hasShownFrozenListRef.current = true;
|
||||
return undefined;
|
||||
}
|
||||
const next = takeSnapshot();
|
||||
if (next.order.size === 0) {
|
||||
if (snapshot.order.size > 0) {
|
||||
setSnapshot(next);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (hasShownFrozenListRef.current) {
|
||||
const heldFor = Date.now() - snapshotTakenAtRef.current;
|
||||
if (heldFor < UNREAD_REFREEZE_COOLDOWN_MS) {
|
||||
const timer = setTimeout(
|
||||
() => setCooldownAttempt((attempt) => attempt + 1),
|
||||
UNREAD_REFREEZE_COOLDOWN_MS - heldFor,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
snapshotTakenAtRef.current = Date.now();
|
||||
hasShownFrozenListRef.current = false;
|
||||
setSnapshot(next);
|
||||
return undefined;
|
||||
}, [channels, snapshot, cooldownAttempt]);
|
||||
return {snapshot, channels};
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@
|
||||
--inbox-icon-button-radius: 0.375rem;
|
||||
--inbox-collapse-button-size: 1.375rem;
|
||||
--inbox-collapse-button-radius: 0.375rem;
|
||||
--inbox-sticky-header-height: 3.5rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 0 var(--inbox-scroller-padding-x) var(--inbox-scroller-padding-bottom);
|
||||
}
|
||||
|
||||
.stickyChannelHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--form-surface-background);
|
||||
}
|
||||
|
||||
.guildGroup {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -100,6 +108,8 @@
|
||||
|
||||
.messageRow {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
scroll-margin-top: var(--inbox-sticky-header-height);
|
||||
}
|
||||
|
||||
.messageActions {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
|
||||
import {useFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
|
||||
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
@@ -27,6 +27,7 @@ import {ensureMembersForMessages} from '@app/features/messaging/commands/Message
|
||||
import {InboxMessageHeader} from '@app/features/messaging/components/popouts/InboxMessageHeader';
|
||||
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
|
||||
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
|
||||
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
|
||||
import {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
import {createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
@@ -136,6 +137,29 @@ interface ChannelPreviewData {
|
||||
const INITIAL_VISIBLE_CHANNELS = 10;
|
||||
const LOAD_MORE_CHUNK = 10;
|
||||
|
||||
interface FrozenUnreadCounts {
|
||||
mentionCount: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface FrozenUnreadChannels {
|
||||
order: Map<string, number>;
|
||||
counts: Map<string, FrozenUnreadCounts>;
|
||||
}
|
||||
|
||||
function freezeUnreadChannels(): FrozenUnreadChannels {
|
||||
const order = new Map<string, number>();
|
||||
const counts = new Map<string, FrozenUnreadCounts>();
|
||||
for (const [index, channel] of getUnreadChannels().entries()) {
|
||||
order.set(channel.id, index);
|
||||
counts.set(channel.id, {
|
||||
mentionCount: ReadStates.getMentionCount(channel.id),
|
||||
unreadCount: ReadStates.getUnreadCount(channel.id),
|
||||
});
|
||||
}
|
||||
return {order, counts};
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
cacheKey: string;
|
||||
data: ChannelPreviewData;
|
||||
@@ -350,22 +374,24 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
|
||||
channel,
|
||||
headingId,
|
||||
previewData,
|
||||
mentionCount,
|
||||
unreadCount,
|
||||
}: {
|
||||
channel: Channel;
|
||||
headingId: string;
|
||||
previewData: ChannelPreviewData | null;
|
||||
mentionCount: number;
|
||||
unreadCount: number;
|
||||
}) {
|
||||
const {i18n} = useLingui();
|
||||
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
|
||||
const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const isCollapsed = UnreadChannels.isCollapsed(channel.id);
|
||||
const isMuted = UserGuildSettings.isGuildOrCategoryOrChannelMuted(channel.guildId ?? null, channel.id);
|
||||
const isMuted = UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id);
|
||||
const previewMessages = previewData?.messages ?? [];
|
||||
const oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null;
|
||||
const totalUnreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
const hasMoreUnreadThanShown =
|
||||
oldestUnreadMessageId != null && totalUnreadCount > 0 && totalUnreadCount > previewMessages.length;
|
||||
oldestUnreadMessageId != null && unreadCount > 0 && unreadCount > previewMessages.length;
|
||||
const spammerOverrideVersion = LocalUserSpamOverride.version;
|
||||
useEffect(() => {
|
||||
if (previewMessages.length === 0) return;
|
||||
@@ -445,7 +471,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
|
||||
highlightedMessageId: null,
|
||||
messageDisplayCompact,
|
||||
messageGroupSpacing,
|
||||
revealedMessageId: null,
|
||||
unblurredMessageId: null,
|
||||
onMessageEdit: undefined,
|
||||
onReveal: undefined,
|
||||
messageRowClassName: styles.messageRow,
|
||||
@@ -497,6 +523,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
|
||||
</h2>
|
||||
<InboxMessageHeader
|
||||
channel={channel}
|
||||
className={styles.stickyChannelHeader}
|
||||
onClick={handleHeaderClick}
|
||||
mentionCount={mentionCount}
|
||||
leftAdornment={
|
||||
@@ -648,19 +675,14 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
|
||||
export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
|
||||
const {i18n} = useLingui();
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const resolveInboxScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
|
||||
const readStateVersion = ReadStates.version;
|
||||
const settingsVersion = UserGuildSettings.version;
|
||||
const frozenOrderRef = useRef<Map<string, number> | null>(null);
|
||||
if (frozenOrderRef.current === null) {
|
||||
frozenOrderRef.current = new Map(getUnreadChannels().map((channel, index) => [channel.id, index]));
|
||||
}
|
||||
const allUnreadChannels = useMemo(() => {
|
||||
const order = frozenOrderRef.current;
|
||||
if (order == null) {
|
||||
return [];
|
||||
}
|
||||
return mergeFrozenUnreadOrder(order, getUnreadChannels());
|
||||
}, [readStateVersion, settingsVersion]);
|
||||
const liveUnreadChannels = useMemo(() => getUnreadChannels(), [readStateVersion, settingsVersion]);
|
||||
const {snapshot: frozenUnread, channels: allUnreadChannels} = useFrozenUnreadOrder(
|
||||
freezeUnreadChannels,
|
||||
liveUnreadChannels,
|
||||
);
|
||||
const [loadedCount, setLoadedCount] = useState(INITIAL_VISIBLE_CHANNELS);
|
||||
const visibleChannels = useMemo(() => allUnreadChannels.slice(0, loadedCount), [allUnreadChannels, loadedCount]);
|
||||
const channelPreviews = useBulkChannelPreviews(visibleChannels);
|
||||
@@ -697,19 +719,29 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
|
||||
return groups;
|
||||
}, [visibleChannels, channelPreviews, i18n.locale]);
|
||||
const unreadRows = useMemo(() => {
|
||||
const rows: Array<{channel: Channel; endsGroup: boolean; groupLabel: string; key: string}> = [];
|
||||
const rows: Array<{
|
||||
channel: Channel;
|
||||
endsGroup: boolean;
|
||||
groupLabel: string;
|
||||
key: string;
|
||||
mentionCount: number;
|
||||
unreadCount: number;
|
||||
}> = [];
|
||||
for (const group of groupedChannels) {
|
||||
for (const [index, channel] of group.channels.entries()) {
|
||||
const counts = frozenUnread.counts.get(channel.id);
|
||||
rows.push({
|
||||
channel,
|
||||
endsGroup: index === group.channels.length - 1,
|
||||
groupLabel: group.label,
|
||||
key: channel.id,
|
||||
mentionCount: counts?.mentionCount ?? 0,
|
||||
unreadCount: counts?.unreadCount ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [groupedChannels]);
|
||||
}, [groupedChannels, frozenUnread]);
|
||||
const hasMore = loadedCount < allUnreadChannels.length;
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
@@ -734,7 +766,7 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
|
||||
const target = event.target as Element | null;
|
||||
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return;
|
||||
if (target instanceof HTMLElement && target.isContentEditable) return;
|
||||
const scrollerNode = scrollerRef.current?.getScrollerNode() ?? null;
|
||||
const scrollerNode = scrollerRef.current?.getViewportElement() ?? null;
|
||||
if (!scrollerNode) return;
|
||||
if (target && !scrollerNode.contains(target) && document.activeElement !== document.body) return;
|
||||
const sections = Array.from(scrollerNode.querySelectorAll<HTMLElement>('[data-inbox-channel-section]'));
|
||||
@@ -780,40 +812,44 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Scroller
|
||||
key="unread-channels-scroller"
|
||||
className={styles.scroller}
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
onCopy={onCopySelectedMessages}
|
||||
data-message-selection-root="true"
|
||||
data-flx="app.floating.unread-channels-content.scroller"
|
||||
>
|
||||
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
|
||||
{unreadRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
|
||||
role="listitem"
|
||||
aria-posinset={rowIndex + 1}
|
||||
aria-setsize={unreadRows.length}
|
||||
data-flx="app.floating.unread-channels-content.row"
|
||||
>
|
||||
<section
|
||||
className={styles.guildGroup}
|
||||
aria-label={row.groupLabel}
|
||||
data-flx="app.floating.unread-channels-content.guild-group"
|
||||
<NearViewportSurfaceContext.Provider value={resolveInboxScrollSurface}>
|
||||
<Scroller
|
||||
key="unread-channels-scroller"
|
||||
className={styles.scroller}
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
onCopy={onCopySelectedMessages}
|
||||
data-message-selection-root="true"
|
||||
data-flx="app.floating.unread-channels-content.scroller"
|
||||
>
|
||||
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
|
||||
{unreadRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
|
||||
role="listitem"
|
||||
aria-posinset={rowIndex + 1}
|
||||
aria-setsize={unreadRows.length}
|
||||
data-flx="app.floating.unread-channels-content.row"
|
||||
>
|
||||
<UnreadChannelCard
|
||||
channel={row.channel}
|
||||
headingId={`inbox-unread-channel-${row.channel.id}`}
|
||||
previewData={channelPreviews.get(row.channel.id) ?? null}
|
||||
data-flx="app.floating.unread-channels-content.unread-channel-card"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Scroller>
|
||||
<section
|
||||
className={styles.guildGroup}
|
||||
aria-label={row.groupLabel}
|
||||
data-flx="app.floating.unread-channels-content.guild-group"
|
||||
>
|
||||
<UnreadChannelCard
|
||||
channel={row.channel}
|
||||
headingId={`inbox-unread-channel-${row.channel.id}`}
|
||||
previewData={channelPreviews.get(row.channel.id) ?? null}
|
||||
mentionCount={row.mentionCount}
|
||||
unreadCount={row.unreadCount}
|
||||
data-flx="app.floating.unread-channels-content.unread-channel-card"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Scroller>
|
||||
</NearViewportSurfaceContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
const registerFormDraftsRef = useRef<Map<string, AuthRegisterFormDraft>>(new Map());
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const location = useLocation();
|
||||
const {patternReady, splashLoaded, splashDimensions} = useAuthBackground(splashUrl, foodPatternUrl);
|
||||
const {patternReady, splashDimensions} = useAuthBackground(splashUrl, foodPatternUrl);
|
||||
const handleSetSplashUrl = useCallback(
|
||||
(url: string | null) => {
|
||||
if (splashUrlRef.current === url) return;
|
||||
@@ -69,7 +69,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
scrollerRef.current?.scrollToTop();
|
||||
scrollerRef.current?.jumpToStartEdge();
|
||||
}, [location.pathname]);
|
||||
const splashScale = useMemo(() => {
|
||||
if (!splashDimensions) return null;
|
||||
@@ -196,7 +196,6 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
>
|
||||
<AuthBackground
|
||||
splashUrl={splashUrl}
|
||||
splashLoaded={splashLoaded}
|
||||
splashDimensions={splashDimensions}
|
||||
splashScale={splashScale}
|
||||
patternReady={patternReady}
|
||||
|
||||
@@ -238,7 +238,7 @@ export const ChannelItem = observer(
|
||||
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
|
||||
const mobileLayout = MobileLayout;
|
||||
const isMuted = UserGuildSettings.isGuildOrChannelMuted(guild.id, channel.id);
|
||||
const isChannelDirectlyMuted = UserGuildSettings.isChannelMuted(guild.id, channel.id);
|
||||
const isChannelDirectlyMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, channel.id);
|
||||
const currentUserCount =
|
||||
channelIsVoice && channel.userLimit != null && channel.userLimit > 0
|
||||
? Object.keys(MediaEngine.getAllVoiceStatesInChannel(guild.id, channel.id)).length
|
||||
|
||||
@@ -164,7 +164,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
const {i18n} = useLingui();
|
||||
const channels = Channels.getGuildChannels(guild.id);
|
||||
const location = useLocation();
|
||||
const userGuildSettings = UserGuildSettings.getSettings(guild.id);
|
||||
const userGuildSettings = UserGuildSettings.getSettingsForScope(guild.id);
|
||||
const isDraggingAnything = useDragLayer((monitor) => {
|
||||
if (!monitor.isDragging()) return false;
|
||||
const itemType = monitor.getItemType();
|
||||
@@ -242,7 +242,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
() => [channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
|
||||
[channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
|
||||
);
|
||||
const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getScrollerNode() ?? null, [scrollerRef]);
|
||||
const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getViewportElement() ?? null, [scrollerRef]);
|
||||
useDragAutoScroll({active: isDraggingAnything, getScrollElement: getChannelScrollContainer});
|
||||
const handleChannelDrop = useCallback(
|
||||
(item: DragItem, result: DropResult) => {
|
||||
@@ -306,15 +306,15 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
}, [guild.id]);
|
||||
const handleResize = useCallback((_entry: ResizeObserverEntry, _type: 'container' | 'content') => {
|
||||
if (stickToBottomRef.current && scrollerRef.current) {
|
||||
scrollerRef.current.scrollToBottom({animate: false});
|
||||
scrollerRef.current.jumpToEndEdge({animate: false});
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const guildDimensions = Dimension.getGuildDimensions(guild.id);
|
||||
const guildDimensions = Dimension.guildDimensionsFor(guild.id);
|
||||
if (guildDimensions.scrollTo) {
|
||||
const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`);
|
||||
if (element && scrollerRef.current) {
|
||||
scrollerRef.current.scrollIntoViewNode({node: element as HTMLElement, shouldScrollToStart: false});
|
||||
scrollerRef.current.revealElement({node: element as HTMLElement, preferStartEdge: false});
|
||||
}
|
||||
DimensionCommands.clearChannelListScrollTo(guild.id);
|
||||
} else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) {
|
||||
@@ -337,7 +337,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
const unreadCount = ReadStates.getUnreadCount(channelId);
|
||||
const mentionCount = ReadStates.getMentionCount(channelId);
|
||||
const isMuted =
|
||||
UserGuildSettings.isCategoryMuted(guild.id, channelId) || UserGuildSettings.isChannelMuted(guild.id, channelId);
|
||||
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
|
||||
UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
|
||||
const channel = Channels.getChannel(channelId);
|
||||
const unreadBadgesLevel = channel
|
||||
? UserGuildSettings.resolvedUnreadBadgesLevel({
|
||||
@@ -360,13 +361,15 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
for (const group of channelGroups) {
|
||||
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
|
||||
const isNullSpace = !group.category;
|
||||
const isCategoryMuted = group.category ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) : false;
|
||||
const isCategoryMuted = group.category
|
||||
? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id)
|
||||
: false;
|
||||
let filteredTextChannels: Array<Channel>;
|
||||
let filteredVoiceChannels: Array<Channel>;
|
||||
if (hideMutedChannels) {
|
||||
filteredTextChannels = [];
|
||||
for (const ch of group.textChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
@@ -380,7 +383,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
}
|
||||
filteredVoiceChannels = [];
|
||||
for (const ch of group.voiceChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
|
||||
@@ -206,11 +206,11 @@ const FavoriteChannelResolvedItem = observer(
|
||||
const channelDisplayName = channel.isPrivate() ? ChannelUtils.getDMDisplayName(channel) : channel.name;
|
||||
const displayName = favoriteChannel.nickname || channelDisplayName || i18n._(UNKNOWN_CHANNEL_DESCRIPTOR);
|
||||
const isChannelDirectlyMuted = channel.guildId
|
||||
? UserGuildSettings.isChannelMuted(channel.guildId, channel.id)
|
||||
? UserGuildSettings.isChannelDirectlyMuted(channel.guildId, channel.id)
|
||||
: false;
|
||||
const isMuted =
|
||||
isChannelDirectlyMuted ||
|
||||
(channel.guildId ? UserGuildSettings.isCategoryMuted(channel.guildId, channel.id) : false);
|
||||
(channel.guildId ? UserGuildSettings.isParentCategoryMuted(channel.guildId, channel.id) : false);
|
||||
const unreadBadgesLevel = channel.guildId
|
||||
? UserGuildSettings.resolvedUnreadBadgesLevel({
|
||||
id: channel.id,
|
||||
@@ -651,7 +651,7 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
const getScrollElement = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller == null) return null;
|
||||
return scroller.getScrollerNode();
|
||||
return scroller.getViewportElement();
|
||||
}, []);
|
||||
useDragAutoScroll({active: isDraggingFavorite, getScrollElement});
|
||||
const favorites = Favorites.sortedChannels;
|
||||
|
||||
@@ -164,7 +164,7 @@ function resolveScrollerNode(scroller: ScrollerHandle | null): HTMLElement | nul
|
||||
if (scroller == null) {
|
||||
return null;
|
||||
}
|
||||
return scroller.getScrollerNode();
|
||||
return scroller.getViewportElement();
|
||||
}
|
||||
|
||||
function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis {
|
||||
@@ -221,7 +221,7 @@ function cancelOwnedSettleTimer(timer: OwnedSettleTimer | null): void {
|
||||
}
|
||||
|
||||
function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean {
|
||||
const container = scroller.getScrollerNode();
|
||||
const container = scroller.getViewportElement();
|
||||
if (container == null) return false;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = targetNode.getBoundingClientRect();
|
||||
@@ -451,7 +451,7 @@ export const FloatingUnreadIndicators = observer(
|
||||
if (scrollTarget(edge.id, direction)) {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
if (scroller == null) return;
|
||||
const scrollNode = scroller.getScrollerNode();
|
||||
const scrollNode = scroller.getViewportElement();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = resolveOwnerWindow(scrollNode);
|
||||
cancelOwnedAnimationFrame(targetAlignmentFrameRef.current);
|
||||
@@ -467,7 +467,7 @@ export const FloatingUnreadIndicators = observer(
|
||||
}
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.SCROLL);
|
||||
if (scroller == null) return;
|
||||
const scrollNode = scroller.getScrollerNode();
|
||||
const scrollNode = scroller.getViewportElement();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = resolveOwnerWindow(scrollNode);
|
||||
cancelOwnedSettleTimer(settleTimerRef.current);
|
||||
|
||||
@@ -20,21 +20,22 @@ export const GuildDetachedBanner = observer(function GuildDetachedBanner({guild}
|
||||
: undefined,
|
||||
[guild.bannerHeight, guild.bannerWidth],
|
||||
);
|
||||
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
const staticBannerURL = useMemo(
|
||||
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
|
||||
[guild.banner, guild.id],
|
||||
() =>
|
||||
isDetachedBanner ? AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null : null,
|
||||
[guild.banner, guild.id, isDetachedBanner],
|
||||
);
|
||||
const animatedBannerURL = useMemo(
|
||||
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null,
|
||||
[guild.banner, guild.id],
|
||||
() => (isDetachedBanner ? AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null : null),
|
||||
[guild.banner, guild.id, isDetachedBanner],
|
||||
);
|
||||
const {hoverRef: bannerHoverRef, imageUrl: bannerURL} = useAnimatedImageUrl({
|
||||
staticUrl: staticBannerURL,
|
||||
animatedUrl: animatedBannerURL,
|
||||
kind: 'gif',
|
||||
});
|
||||
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
if (!bannerURL || !isDetachedBanner) return null;
|
||||
if (!bannerURL) return null;
|
||||
const maxHeight = `${MAX_VIEWPORT_HEIGHT_FRACTION * 100}vh`;
|
||||
const bannerHeight = guild.bannerHeight ?? DEFAULT_BANNER_HEIGHT;
|
||||
return (
|
||||
|
||||
@@ -66,20 +66,20 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
const {popouts} = Popout;
|
||||
const isOpen = 'guild-header' in popouts;
|
||||
const isMobile = MobileLayout.isMobileLayout();
|
||||
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
const staticBannerURL = useMemo(
|
||||
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
|
||||
[guild.banner, guild.id],
|
||||
);
|
||||
const animatedBannerURL = useMemo(
|
||||
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null,
|
||||
[guild.banner, guild.id],
|
||||
() => (isDetachedBanner ? null : AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null),
|
||||
[guild.banner, guild.id, isDetachedBanner],
|
||||
);
|
||||
const {hoverRef: bannerHoverRef, imageUrl: bannerURL} = useAnimatedImageUrl({
|
||||
staticUrl: staticBannerURL,
|
||||
animatedUrl: animatedBannerURL,
|
||||
kind: 'gif',
|
||||
});
|
||||
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner);
|
||||
const bannerAspectRatio =
|
||||
guild.bannerWidth && guild.bannerHeight
|
||||
|
||||
@@ -27,7 +27,7 @@ import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {useParams} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
@@ -433,7 +433,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
useEffect(() => {
|
||||
if (prevNagbarCount.current !== nagbarCount) {
|
||||
prevNagbarCount.current = nagbarCount;
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED');
|
||||
ComponentBus.dispatch('LAYOUT_RESIZED');
|
||||
}
|
||||
}, [nagbarCount]);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -306,14 +306,9 @@
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.guildBadgeActive::before,
|
||||
.guildBadge:not(:empty)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.1875rem;
|
||||
z-index: -1;
|
||||
border-radius: calc(var(--radius-md) + 0.1875rem);
|
||||
background-color: var(--guild-badge-surface);
|
||||
.guildBadgeActive,
|
||||
.guildBadge:not(:empty) {
|
||||
--mention-badge-cutout-surface: var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.guildVoiceBadge {
|
||||
|
||||
@@ -90,7 +90,7 @@ import {openMacPermissionsModal} from '@app/features/permissions/system/commands
|
||||
import MacPermissions from '@app/features/permissions/system/state/MacPermissions';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
|
||||
import {AxisOrientation, type VerticalEdge} from '@app/features/ui/AxisOrientation';
|
||||
@@ -184,7 +184,7 @@ function resolveGuildFolderId(folderId: number | null): number {
|
||||
|
||||
function resolveGuildListScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null {
|
||||
if (scroller == null) return null;
|
||||
return scroller.getScrollerNode();
|
||||
return scroller.getViewportElement();
|
||||
}
|
||||
|
||||
type GuildNavigationRow =
|
||||
@@ -665,7 +665,7 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
|
||||
const unreadState = getChannelUnreadState({
|
||||
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
|
||||
mentionCount,
|
||||
isMuted: UserGuildSettings.isChannelMuted(null, channelId),
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
|
||||
});
|
||||
if (mentionCount > 0) return 'mention';
|
||||
@@ -674,8 +674,8 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
|
||||
}
|
||||
|
||||
function getGuildScrollIndicatorSeverity(guildId: string): ScrollIndicatorSeverity | null {
|
||||
if (GuildReadState.getMentionCount(guildId) > 0) return 'mention';
|
||||
if (GuildReadState.hasUnread(guildId)) return 'unread';
|
||||
if (GuildReadState.mentionCountForGuild(guildId) > 0) return 'mention';
|
||||
if (GuildReadState.guildIsUnread(guildId)) return 'unread';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1327,10 +1327,10 @@ const GUILD_RAIL_INDICATORS_BY_TOKEN: Record<string, SkeletonGuildRailItemIndica
|
||||
function resolveGuildRailIndicator(guildIds: ReadonlyArray<string>): SkeletonGuildRailItemIndicator {
|
||||
let hasUnread = false;
|
||||
for (const guildId of guildIds) {
|
||||
if (GuildReadState.getMentionCount(guildId) > 0) {
|
||||
if (GuildReadState.mentionCountForGuild(guildId) > 0) {
|
||||
return SkeletonGuildRailItemIndicator.MENTION;
|
||||
}
|
||||
if (GuildReadState.hasUnread(guildId)) {
|
||||
if (GuildReadState.guildIsUnread(guildId)) {
|
||||
hasUnread = true;
|
||||
}
|
||||
}
|
||||
@@ -1583,7 +1583,7 @@ const GuildList = observer(() => {
|
||||
const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows);
|
||||
const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0;
|
||||
const initialGuildListScrollTop = useMemo(() => {
|
||||
const liveScrollTop = untracked(() => Dimension.getGuildListDimensions().scrollTop);
|
||||
const liveScrollTop = untracked(() => Dimension.currentGuildListDimensions().scrollTop);
|
||||
if (liveScrollTop > 0) {
|
||||
return liveScrollTop;
|
||||
}
|
||||
@@ -1859,7 +1859,7 @@ const GuildList = observer(() => {
|
||||
const node = scrollTargetRegistry.getTargetNode(targetId);
|
||||
const scroller = scrollRef.current;
|
||||
if (node == null || scroller == null) return false;
|
||||
scroller.scrollIntoViewNode({
|
||||
scroller.revealElement({
|
||||
node,
|
||||
alignment,
|
||||
animate: Accessibility.useSmoothScrolling,
|
||||
@@ -2166,7 +2166,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
||||
useEffect(() => {
|
||||
if (prevNagbarCount.current !== activeNagbars.length) {
|
||||
prevNagbarCount.current = activeNagbars.length;
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED');
|
||||
ComponentBus.dispatch('LAYOUT_RESIZED');
|
||||
}
|
||||
}, [activeNagbars.length]);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {
|
||||
@@ -52,7 +52,7 @@ export const KeyboardModeListener = observer(() => {
|
||||
useEffect(() => {
|
||||
let lastWindowFocusTime = document.hasFocus() ? 0 : -Infinity;
|
||||
const REFOCUS_THRESHOLD_MS = 100;
|
||||
const handleWindowFocus = () => {
|
||||
const handleWindowFocused = () => {
|
||||
lastWindowFocusTime = performance.now();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -61,7 +61,7 @@ export const KeyboardModeListener = observer(() => {
|
||||
const composer = document.querySelector<HTMLTextAreaElement>(CHANNEL_TEXTAREA_SELECTOR);
|
||||
if (canRedirectTabToComposer(composer)) {
|
||||
event.preventDefault();
|
||||
ComponentDispatch.dispatch('FOCUS_TEXTAREA', {enterKeyboardMode: true});
|
||||
ComponentBus.dispatch('FOCUS_TEXTAREA', {enterKeyboardMode: true});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -75,12 +75,12 @@ export const KeyboardModeListener = observer(() => {
|
||||
KeyboardMode.exitKeyboardMode();
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('focus', handleWindowFocused);
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('mousedown', handlePointer, true);
|
||||
window.addEventListener('pointerdown', handlePointer, true);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
window.removeEventListener('focus', handleWindowFocused);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('mousedown', handlePointer, true);
|
||||
window.removeEventListener('pointerdown', handlePointer, true);
|
||||
|
||||
+3
-8
@@ -145,14 +145,9 @@
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.folderBadgeActive::before,
|
||||
.folderBadge:not(:empty)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.1875rem;
|
||||
z-index: -1;
|
||||
border-radius: calc(var(--radius-md) + 0.1875rem);
|
||||
background-color: var(--guild-badge-surface);
|
||||
.folderBadgeActive,
|
||||
.folderBadge:not(:empty) {
|
||||
--mention-badge-cutout-surface: var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.expandedFolderBackground {
|
||||
|
||||
@@ -316,8 +316,8 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
}) as CSSProperties,
|
||||
[folderColor],
|
||||
);
|
||||
const hasUnreadMessages = guilds.some((guild) => GuildReadState.hasUnread(guild.id));
|
||||
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.getMentionCount(guild.id), 0);
|
||||
const hasUnreadMessages = guilds.some((guild) => GuildReadState.guildIsUnread(guild.id));
|
||||
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.mentionCountForGuild(guild.id), 0);
|
||||
let folderScrollTargetRef: React.RefCallback<HTMLElement> | null = null;
|
||||
if (registerScrollTarget != null) {
|
||||
folderScrollTargetRef = registerScrollTarget(folderId);
|
||||
@@ -759,14 +759,14 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
const MINI_GUILD_INITIALS_MAX_LENGTH = 2;
|
||||
|
||||
interface MiniGuildIconProps {
|
||||
readonly guild: Guild;
|
||||
}
|
||||
|
||||
const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
|
||||
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
|
||||
const initials = getInitialsFromName(guild.name);
|
||||
const displayInitials = truncateInitials(initials, 2);
|
||||
if (iconURL !== '') {
|
||||
return (
|
||||
<flx-app-mini-guild-icon
|
||||
@@ -785,7 +785,7 @@ const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
|
||||
className={styles.miniGuildInitials}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-initials"
|
||||
>
|
||||
{displayInitials}
|
||||
{truncateInitials(getInitialsFromName(guild.name), MINI_GUILD_INITIALS_MAX_LENGTH)}
|
||||
</span>
|
||||
</flx-app-mini-guild-icon>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadSt
|
||||
return getChannelUnreadState({
|
||||
unreadCount: ReadStates.getUnreadCount(channelId),
|
||||
mentionCount: ReadStates.getMentionCount(channelId),
|
||||
isMuted: UserGuildSettings.isChannelMuted(null, channelId),
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
const contextMenuOpen = useContextMenuHoverState(buttonRef, !isMobileExperience);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const {keyboardModeEnabled} = KeyboardMode;
|
||||
const isMuted = UserGuildSettings.isChannelMuted(null, channel.id);
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const unreadState = resolveDMListItemUnreadState(channel.id);
|
||||
const directMessageName = recipient ? NicknameUtils.getNickname(recipient, null, channel.id) : null;
|
||||
|
||||
+3
-2
@@ -313,6 +313,7 @@ export const GuildListItemPresentation = forwardRef<
|
||||
[backgroundImage, draggingCursor],
|
||||
);
|
||||
const guildIconTarget = useMemo(() => ({borderRadius: iconBorderRadius}), [iconBorderRadius]);
|
||||
const hasPaintedIcon = backgroundImage != null;
|
||||
return (
|
||||
<LongPressable
|
||||
{...domProps}
|
||||
@@ -364,7 +365,7 @@ export const GuildListItemPresentation = forwardRef<
|
||||
tabIndex={-1}
|
||||
className={clsx(
|
||||
styles.guildIcon,
|
||||
guild.icon == null && styles.guildIconNoImage,
|
||||
!hasPaintedIcon && styles.guildIconNoImage,
|
||||
isSelected && styles.guildIconSelected,
|
||||
)}
|
||||
to={guildIconTarget}
|
||||
@@ -374,7 +375,7 @@ export const GuildListItemPresentation = forwardRef<
|
||||
style={guildIconStyle}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon"
|
||||
>
|
||||
{guild.icon == null && (
|
||||
{!hasPaintedIcon && (
|
||||
<span
|
||||
className={styles.guildIconInitials}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-initials"
|
||||
|
||||
+5
-12
@@ -55,13 +55,6 @@ function resolveGuildIconBackgroundImage(iconURL: string, displayedIconURL: stri
|
||||
return `url(${displayedIconURL})`;
|
||||
}
|
||||
|
||||
function resolveInitiallyLoadedImageURL(imageURL: string): string | null {
|
||||
if (ImageCacheUtils.hasImage(imageURL)) {
|
||||
return imageURL;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useGuildListItemInteraction({
|
||||
guild,
|
||||
isDesktopLayout,
|
||||
@@ -79,16 +72,16 @@ export function useGuildListItemInteraction({
|
||||
});
|
||||
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
|
||||
const hoverIconURL = AvatarSourceUtils.getGuildIconURL(guild, true);
|
||||
const [loadedAnimatedURL, setLoadedAnimatedURL] = useState<string | null>(() =>
|
||||
resolveInitiallyLoadedImageURL(hoverIconURL),
|
||||
);
|
||||
const isAnimatableIcon = hoverIconURL !== iconURL;
|
||||
const [loadedAnimatedURL, setLoadedAnimatedURL] = useState<string | null>(null);
|
||||
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
|
||||
useEffect(() => ImageCacheUtils.pinImage(iconURL), [iconURL]);
|
||||
useEffect(() => {
|
||||
if (!isAnimatableIcon) return;
|
||||
if (!isHovering && !contextMenuOpen) return;
|
||||
if (loadedAnimatedURL === hoverIconURL) return;
|
||||
return ImageCacheUtils.loadImage(hoverIconURL, () => setLoadedAnimatedURL(hoverIconURL));
|
||||
}, [contextMenuOpen, hoverIconURL, isHovering, loadedAnimatedURL]);
|
||||
}, [contextMenuOpen, hoverIconURL, isAnimatableIcon, isHovering, loadedAnimatedURL]);
|
||||
const handleSelect = useCallback(() => {
|
||||
markDirectSelection(DirectSelectionSurface.GUILD_RAIL);
|
||||
preloadChannelNow();
|
||||
@@ -126,7 +119,7 @@ export function useGuildListItemInteraction({
|
||||
if (!isSortingList && isMobileExperience) setBottomSheetOpen(true);
|
||||
}, [isMobileExperience, isSortingList]);
|
||||
const handleCloseBottomSheet = useCallback(() => setBottomSheetOpen(false), []);
|
||||
const shouldPlayAnimated = (isHovering || contextMenuOpen) && loadedAnimatedURL === hoverIconURL;
|
||||
const shouldPlayAnimated = isAnimatableIcon && (isHovering || contextMenuOpen) && loadedAnimatedURL === hoverIconURL;
|
||||
const displayedIconURL = resolveDisplayedGuildIconURL({hoverIconURL, iconURL, shouldPlayAnimated});
|
||||
const backgroundImage = resolveGuildIconBackgroundImage(iconURL, displayedIconURL);
|
||||
return {
|
||||
|
||||
@@ -107,9 +107,9 @@ export function useGuildListItemState({
|
||||
isSelected,
|
||||
selectedGuildIndex,
|
||||
}: UseGuildListItemStateOptions): GuildListItemState {
|
||||
const hasUnreadMessages = GuildReadState.hasUnread(guild.id);
|
||||
const mentionCount = GuildReadState.getMentionCount(guild.id);
|
||||
const settings = UserGuildSettings.getSettings(guild.id);
|
||||
const hasUnreadMessages = GuildReadState.guildIsUnread(guild.id);
|
||||
const mentionCount = GuildReadState.mentionCountForGuild(guild.id);
|
||||
const settings = UserGuildSettings.getSettingsForScope(guild.id);
|
||||
const isMuted = settings.muted;
|
||||
const muteConfig = settings.mute_config;
|
||||
let muteEndTime: string | null = null;
|
||||
|
||||
@@ -55,10 +55,16 @@ export const YouPage = observer(({onAvatarClick}: YouPageProps) => {
|
||||
return normalizeCustomStatus(Presence.getCustomStatus(user.id));
|
||||
}, [user]);
|
||||
const hasCustomStatus = Boolean(normalizedCustomStatus);
|
||||
const staticBannerUrl = user?.banner ? AvatarUtils.getUserBannerURL({id: user.id, banner: user.banner}, false) : null;
|
||||
const animatedBannerUrl = user?.banner
|
||||
? AvatarUtils.getUserBannerURL({id: user.id, banner: user.banner}, true)
|
||||
: null;
|
||||
const userId = user?.id;
|
||||
const userBanner = user?.banner;
|
||||
const staticBannerUrl = useMemo(
|
||||
() => (userId && userBanner ? AvatarUtils.getUserBannerURL({id: userId, banner: userBanner}, false) : null),
|
||||
[userId, userBanner],
|
||||
);
|
||||
const animatedBannerUrl = useMemo(
|
||||
() => (userId && userBanner ? AvatarUtils.getUserBannerURL({id: userId, banner: userBanner}, true) : null),
|
||||
[userId, userBanner],
|
||||
);
|
||||
const {hoverRef: bannerHoverRef, imageUrl: bannerUrl} = useAnimatedImageUrl({
|
||||
staticUrl: staticBannerUrl,
|
||||
animatedUrl: animatedBannerUrl,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands';
|
||||
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
|
||||
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
|
||||
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils';
|
||||
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
|
||||
@@ -79,6 +80,7 @@ export const ChannelPinsContent = observer(({channel, onJump}: ChannelPinsConten
|
||||
const canUnpin = isDMChannel || Permission.can(Permissions.MANAGE_MESSAGES, channel);
|
||||
const mobileLayout = MobileLayout;
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const resolvePinnedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const pinMessagesPermissionLabel = formatPermissionLabel(i18n, Permissions.PIN_MESSAGES);
|
||||
@@ -187,107 +189,114 @@ export const ChannelPinsContent = observer(({channel, onJump}: ChannelPinsConten
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Scroller
|
||||
className={clsx(previewStyles.scroller, mobileLayout.enabled && previewStyles.scrollerMobile)}
|
||||
key="channel-pins-scroller"
|
||||
onScroll={handleScroll}
|
||||
ref={scrollerRef}
|
||||
onCopy={onCopySelectedMessages}
|
||||
data-message-selection-root="true"
|
||||
data-flx="app.channel-pins-content.scroller"
|
||||
>
|
||||
{mobileLayout.enabled && <div className={previewStyles.topSpacer} data-flx="app.channel-pins-content.div--5" />}
|
||||
{pinnedPins.slice().map(({message}) => {
|
||||
const cardClasses = clsx(previewStyles.previewCard, mobileLayout.enabled && previewStyles.previewCardMobile);
|
||||
const messageChannel =
|
||||
Channels.getChannel(message.channelId) ?? (message.channelId === channel.id ? channel : null);
|
||||
if (!messageChannel) return null;
|
||||
if (mobileLayout.enabled) {
|
||||
<NearViewportSurfaceContext.Provider value={resolvePinnedScrollSurface}>
|
||||
<Scroller
|
||||
className={clsx(previewStyles.scroller, mobileLayout.enabled && previewStyles.scrollerMobile)}
|
||||
key="channel-pins-scroller"
|
||||
onScroll={handleScroll}
|
||||
ref={scrollerRef}
|
||||
onCopy={onCopySelectedMessages}
|
||||
data-message-selection-root="true"
|
||||
data-flx="app.channel-pins-content.scroller"
|
||||
>
|
||||
{mobileLayout.enabled && (
|
||||
<div className={previewStyles.topSpacer} data-flx="app.channel-pins-content.div--5" />
|
||||
)}
|
||||
{pinnedPins.slice().map(({message}) => {
|
||||
const cardClasses = clsx(
|
||||
previewStyles.previewCard,
|
||||
mobileLayout.enabled && previewStyles.previewCardMobile,
|
||||
);
|
||||
const messageChannel =
|
||||
Channels.getChannel(message.channelId) ?? (message.channelId === channel.id ? channel : null);
|
||||
if (!messageChannel) return null;
|
||||
if (mobileLayout.enabled) {
|
||||
return (
|
||||
<LongPressable
|
||||
key={message.id}
|
||||
className={cardClasses}
|
||||
data-message-id={message.id}
|
||||
data-is-group-start="true"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleTap(message)}
|
||||
onKeyDown={(e) => {
|
||||
if (isKeyboardActivationKey(e.key)) {
|
||||
e.preventDefault();
|
||||
handleTap(message);
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
if (!canUnpin) return;
|
||||
setSelectedMessage(message);
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
data-flx="app.channel-pins-content.button.tap"
|
||||
>
|
||||
<MessageComponent
|
||||
message={message}
|
||||
channel={messageChannel}
|
||||
previewContext={MessagePreviewContext.LIST_POPOUT}
|
||||
data-flx="app.channel-pins-content.message-component"
|
||||
/>
|
||||
</LongPressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LongPressable
|
||||
<div
|
||||
key={message.id}
|
||||
className={cardClasses}
|
||||
data-message-id={message.id}
|
||||
data-is-group-start="true"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleTap(message)}
|
||||
onKeyDown={(e) => {
|
||||
if (isKeyboardActivationKey(e.key)) {
|
||||
e.preventDefault();
|
||||
handleTap(message);
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
if (!canUnpin) return;
|
||||
setSelectedMessage(message);
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
data-flx="app.channel-pins-content.button.tap"
|
||||
data-flx="app.channel-pins-content.div--6"
|
||||
>
|
||||
<MessageComponent
|
||||
message={message}
|
||||
channel={messageChannel}
|
||||
previewContext={MessagePreviewContext.LIST_POPOUT}
|
||||
data-flx="app.channel-pins-content.message-component"
|
||||
data-flx="app.channel-pins-content.message-component--2"
|
||||
/>
|
||||
</LongPressable>
|
||||
<div className={previewStyles.actionButtons} data-flx="app.channel-pins-content.div--7">
|
||||
<FocusRing offset={-2} data-flx="app.channel-pins-content.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={previewStyles.actionButton}
|
||||
onClick={() => handleJump(message.channelId, message.id)}
|
||||
data-flx="app.channel-pins-content.button.jump"
|
||||
>
|
||||
{i18n._(JUMP_DESCRIPTOR)}
|
||||
</button>
|
||||
</FocusRing>
|
||||
{renderUnpinButton(message)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cardClasses}
|
||||
data-message-id={message.id}
|
||||
data-is-group-start="true"
|
||||
data-flx="app.channel-pins-content.div--6"
|
||||
>
|
||||
<MessageComponent
|
||||
message={message}
|
||||
channel={messageChannel}
|
||||
previewContext={MessagePreviewContext.LIST_POPOUT}
|
||||
data-flx="app.channel-pins-content.message-component--2"
|
||||
/>
|
||||
<div className={previewStyles.actionButtons} data-flx="app.channel-pins-content.div--7">
|
||||
<FocusRing offset={-2} data-flx="app.channel-pins-content.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={previewStyles.actionButton}
|
||||
onClick={() => handleJump(message.channelId, message.id)}
|
||||
data-flx="app.channel-pins-content.button.jump"
|
||||
>
|
||||
{i18n._(JUMP_DESCRIPTOR)}
|
||||
</button>
|
||||
</FocusRing>
|
||||
{renderUnpinButton(message)}
|
||||
})}
|
||||
{isLoading && (
|
||||
<div className={previewStyles.loadingState} data-flx="app.channel-pins-content.div--8">
|
||||
<Spinner data-flx="app.channel-pins-content.spinner--2" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && (
|
||||
<div className={previewStyles.endState} data-flx="app.channel-pins-content.div--9">
|
||||
<div className={previewStyles.endStateContent} data-flx="app.channel-pins-content.div--10">
|
||||
<FlagCheckeredIcon
|
||||
className={previewStyles.endStateIcon}
|
||||
data-flx="app.channel-pins-content.flag-checkered-icon"
|
||||
/>
|
||||
<div className={previewStyles.endStateTextContainer} data-flx="app.channel-pins-content.div--11">
|
||||
<h3 className={previewStyles.endStateTitle} data-flx="app.channel-pins-content.h3--2">
|
||||
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
|
||||
</h3>
|
||||
<p className={previewStyles.endStateDescription} data-flx="app.channel-pins-content.p--2">
|
||||
{endStateDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<div className={previewStyles.loadingState} data-flx="app.channel-pins-content.div--8">
|
||||
<Spinner data-flx="app.channel-pins-content.spinner--2" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && (
|
||||
<div className={previewStyles.endState} data-flx="app.channel-pins-content.div--9">
|
||||
<div className={previewStyles.endStateContent} data-flx="app.channel-pins-content.div--10">
|
||||
<FlagCheckeredIcon
|
||||
className={previewStyles.endStateIcon}
|
||||
data-flx="app.channel-pins-content.flag-checkered-icon"
|
||||
/>
|
||||
<div className={previewStyles.endStateTextContainer} data-flx="app.channel-pins-content.div--11">
|
||||
<h3 className={previewStyles.endStateTitle} data-flx="app.channel-pins-content.h3--2">
|
||||
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
|
||||
</h3>
|
||||
<p className={previewStyles.endStateDescription} data-flx="app.channel-pins-content.p--2">
|
||||
{endStateDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Scroller>
|
||||
)}
|
||||
</Scroller>
|
||||
</NearViewportSurfaceContext.Provider>
|
||||
{mobileLayout.enabled && selectedMessage && (
|
||||
<MenuBottomSheet
|
||||
isOpen={menuOpen}
|
||||
|
||||
@@ -5,7 +5,6 @@ import styles from '@app/features/app/components/shared/GroupDMAvatar.module.css
|
||||
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {getGroupDMAccentColor} from '@app/features/channel/utils/GroupDMColorUtils';
|
||||
import {cdnUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import TransientPresence from '@app/features/presence/state/TransientPresence';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
@@ -147,7 +146,7 @@ function renderGroupStatusDot(status: StatusType, size: number, isTyping?: boole
|
||||
width={1}
|
||||
height={1}
|
||||
fill={statusColor}
|
||||
mask={`url(#svg-mask-status-${renderableStatus})`}
|
||||
mask={`url(#flx-mask-presence-${renderableStatus})`}
|
||||
data-flx="app.group-dm-avatar.render-group-status-dot.rect"
|
||||
/>
|
||||
</svg>
|
||||
@@ -446,13 +445,9 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
|
||||
const user = Users.getUser(userId);
|
||||
const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize);
|
||||
const avatarMaskId = `${groupMaskId}-avatar-${index}`;
|
||||
let avatarUrl: string;
|
||||
if (user) {
|
||||
avatarUrl = AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar});
|
||||
} else {
|
||||
const avatarIndex = index % 6;
|
||||
avatarUrl = cdnUrl(`avatars/${avatarIndex}.png`);
|
||||
}
|
||||
const avatarUrl = user
|
||||
? AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar})
|
||||
: AvatarUtils.getDefaultAvatarURL(userId);
|
||||
return (
|
||||
<image
|
||||
key={userId}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {LongPressable} from '@app/features/app/components/LongPressable';
|
||||
import styles from '@app/features/app/components/shared/MessageReactionsContent.module.css';
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import reactionStyles from '@app/features/channel/components/MessageReactions.module.css';
|
||||
import {ReactionImage} from '@app/features/messaging/components/ReactionImage';
|
||||
import {
|
||||
emojiEquals,
|
||||
getEmojiName,
|
||||
@@ -49,6 +50,7 @@ const ReactionFilterButton = observer(
|
||||
({reaction, isSelected, onSelect, onContextMenu, showTooltip}: ReactionFilterButtonProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const [hoverRef, isHovering] = useHover();
|
||||
const emojiHasAnimatedVariant = reaction.emoji.animated === true;
|
||||
const emojiName = getEmojiName(reaction.emoji);
|
||||
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
|
||||
const reactionCountText = plural(
|
||||
@@ -67,7 +69,7 @@ const ReactionFilterButton = observer(
|
||||
aria-pressed={isSelected}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
ref={hoverRef}
|
||||
ref={emojiHasAnimatedVariant ? hoverRef : undefined}
|
||||
className={clsx(
|
||||
reactionStyles.reactionButton,
|
||||
styles.filterButton,
|
||||
@@ -80,10 +82,11 @@ const ReactionFilterButton = observer(
|
||||
data-flx="app.message-reactions-content.reaction-filter-button.div"
|
||||
>
|
||||
{emojiUrl ? (
|
||||
<img
|
||||
<ReactionImage
|
||||
className={clsx('emoji', reactionStyles.emoji)}
|
||||
src={emojiUrl}
|
||||
alt={emojiName}
|
||||
aria-hidden={true}
|
||||
draggable={false}
|
||||
data-flx="app.message-reactions-content.reaction-filter-button.emoji"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import type {FC, ReactNode} from 'react';
|
||||
import {useCallback, useLayoutEffect, useRef} from 'react';
|
||||
|
||||
const REVEAL_SPOILER_DESCRIPTOR = msg({
|
||||
message: 'Reveal spoiler',
|
||||
@@ -27,9 +28,26 @@ interface SpoilerOverlayProps {
|
||||
|
||||
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, children, label, inline, className}) => {
|
||||
const {i18n} = useLingui();
|
||||
const wrapperElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const pendingFocusHandoffRef = useRef(false);
|
||||
const ariaLabel = label ?? i18n._(REVEAL_SPOILER_DESCRIPTOR);
|
||||
const handleRevealRequest = useCallback(() => {
|
||||
pendingFocusHandoffRef.current = true;
|
||||
onReveal();
|
||||
}, [onReveal]);
|
||||
useLayoutEffect(() => {
|
||||
if (hidden) {
|
||||
pendingFocusHandoffRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!pendingFocusHandoffRef.current) return;
|
||||
pendingFocusHandoffRef.current = false;
|
||||
wrapperElementRef.current?.focus({preventScroll: true});
|
||||
}, [hidden]);
|
||||
return (
|
||||
<div
|
||||
ref={wrapperElementRef}
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.container, inline && styles.inline, hidden && styles.hidden, className)}
|
||||
data-flx="app.spoiler-overlay.container"
|
||||
>
|
||||
@@ -41,7 +59,7 @@ export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, child
|
||||
<button
|
||||
type="button"
|
||||
className={styles.overlayButton}
|
||||
onClick={onReveal}
|
||||
onClick={handleRevealRequest}
|
||||
aria-label={ariaLabel}
|
||||
data-flx="app.spoiler-overlay.overlay-button.reveal"
|
||||
>
|
||||
|
||||
+33
-31
@@ -7,10 +7,10 @@ import {
|
||||
getEmojiAttribution,
|
||||
} from '@app/features/emoji/components/emojis/EmojiAttributionSubtext';
|
||||
import Emoji from '@app/features/emoji/state/Emoji';
|
||||
import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl';
|
||||
import {getEmojiURL} from '@app/features/expressions/utils/EmojiUtils';
|
||||
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {setUrlQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import {usePresenceCustomStatus} from '@app/features/presence/hooks/usePresenceCustomStatus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {EmojiTooltipContent} from '@app/features/ui/emoji_tooltip_content/EmojiTooltipContent';
|
||||
@@ -22,7 +22,6 @@ import {HoverFloatingTooltipTrigger} from '@app/features/ui/tooltip/HoverFloatin
|
||||
import {Tooltip, type TooltipPosition} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {useHoverFloatingTooltip} from '@app/features/ui/tooltip/useHoverFloatingTooltip';
|
||||
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
@@ -62,14 +61,17 @@ interface ClampedStyle extends React.CSSProperties {
|
||||
const sanitizeText = (text: string): string => {
|
||||
return text.replace(/[\r\n]+/g, ' ').trim();
|
||||
};
|
||||
const getStatusEmojiAnimatable = (status: CustomStatus): boolean => {
|
||||
if (!status.emojiId) {
|
||||
return false;
|
||||
}
|
||||
return Emoji.getEmojiById(status.emojiId)?.animated ?? status.emojiAnimated ?? false;
|
||||
};
|
||||
|
||||
const getTooltipEmojiUrl = (status: CustomStatus, animationAllowed: boolean): string | null => {
|
||||
if (status.emojiId) {
|
||||
const emoji = Emoji.getEmojiById(status.emojiId);
|
||||
const isAnimated = (emoji?.animated ?? status.emojiAnimated ?? false) && animationAllowed;
|
||||
return setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: isAnimated}), {
|
||||
size: 96,
|
||||
quality: 'lossless',
|
||||
});
|
||||
const isAnimatable = getStatusEmojiAnimatable(status);
|
||||
return buildCustomEmojiURL({id: status.emojiId, animated: animationAllowed && isAnimatable});
|
||||
}
|
||||
if (status.emojiName) {
|
||||
return getEmojiURL(status.emojiName);
|
||||
@@ -99,12 +101,17 @@ const StatusEmojiWithTooltip = observer(
|
||||
return `:${status.emojiName}:`;
|
||||
}
|
||||
if (status.emojiName) {
|
||||
return UnicodeEmojis.convertSurrogateToName(status.emojiName, true, status.emojiName);
|
||||
return UnicodeEmojis.nameForSurrogate(status.emojiName, true, status.emojiName);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const emojiName = getEmojiDisplayName();
|
||||
const animationAllowed = useShouldAnimate({kind: 'custom_status_emoji', isHovering: true});
|
||||
const isAnimatable = getStatusEmojiAnimatable(status);
|
||||
const animationAllowed = useShouldAnimate({
|
||||
kind: 'custom_status_emoji',
|
||||
isAnimated: isAnimatable,
|
||||
isHovering: isAnimatable,
|
||||
});
|
||||
const tooltipEmojiUrl = getTooltipEmojiUrl(status, animationAllowed);
|
||||
const TriggerComponent = isButton ? 'button' : 'span';
|
||||
const triggerProps = isButton
|
||||
@@ -167,24 +174,15 @@ const renderStatusEmoji = (
|
||||
animationAllowed: boolean = true,
|
||||
): EmojiRenderResult | null => {
|
||||
if (status.emojiId) {
|
||||
const emoji = Emoji.getEmojiById(status.emojiId);
|
||||
const altText = `:${status.emojiName}:`;
|
||||
const isAnimated = (emoji?.animated ?? status.emojiAnimated ?? false) && animationAllowed;
|
||||
const staticUrl = setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: false}), {
|
||||
size: 96,
|
||||
quality: 'lossless',
|
||||
});
|
||||
const animatedUrl = isAnimated
|
||||
? setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: true}), {
|
||||
size: 96,
|
||||
quality: 'lossless',
|
||||
})
|
||||
: null;
|
||||
const isAnimatable = getStatusEmojiAnimatable(status);
|
||||
const staticUrl = buildCustomEmojiURL({id: status.emojiId, animated: false});
|
||||
const animatedUrl = isAnimatable ? buildCustomEmojiURL({id: status.emojiId, animated: true}) : null;
|
||||
if (alwaysAnimate && animatedUrl) {
|
||||
return {
|
||||
node: (
|
||||
<img
|
||||
src={animatedUrl}
|
||||
src={animationAllowed ? animatedUrl : staticUrl}
|
||||
alt={status.emojiName ?? undefined}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, emojiClassName)}
|
||||
@@ -208,13 +206,15 @@ const renderStatusEmoji = (
|
||||
className={clsx(styles.statusEmoji, styles.staticEmoji, emojiClassName)}
|
||||
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--2"
|
||||
/>
|
||||
<img
|
||||
src={animatedUrl}
|
||||
alt={status.emojiName ?? undefined}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
|
||||
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--3"
|
||||
/>
|
||||
{animationAllowed && (
|
||||
<img
|
||||
src={animatedUrl}
|
||||
alt={status.emojiName ?? undefined}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
|
||||
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--3"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
altText,
|
||||
@@ -285,9 +285,11 @@ export const CustomStatusDisplay = observer(
|
||||
checkVertical: maxLines > 1,
|
||||
measureTextRange: true,
|
||||
});
|
||||
const emojiAnimatable = normalized ? getStatusEmojiAnimatable(normalized) : false;
|
||||
const animationAllowed = useShouldAnimate({
|
||||
kind: 'custom_status_emoji',
|
||||
isHovering: Boolean(animateOnParentHover) || Boolean(alwaysAnimate),
|
||||
isAnimated: emojiAnimatable,
|
||||
isHovering: animateOnParentHover || alwaysAnimate,
|
||||
});
|
||||
if (!normalized) {
|
||||
if (showPlaceholder && isEditable && onEdit) {
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
overflow: hidden;
|
||||
padding-left: var(--chat-mobile-horizontal-padding);
|
||||
padding-right: var(--chat-mobile-horizontal-padding);
|
||||
padding-bottom: var(--messages-bottom-clearance);
|
||||
padding-bottom: calc(var(--messages-bottom-clearance) - var(--message-filler-padding-bottom));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/skeleton/ChatSkeleton.module.css';
|
||||
import {usePlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs';
|
||||
import {useMessageListPlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs';
|
||||
import {
|
||||
ChatSkeletonChannelKind,
|
||||
type ChatSkeletonPresentation,
|
||||
@@ -34,8 +34,6 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {Fragment, useState} from 'react';
|
||||
|
||||
const MESSAGE_LIST_SEED_KEY = 'chat-skeleton-message-list';
|
||||
|
||||
const HEADER_BACK_ICON_SIZE = skeletonSurfaceVar('--channel-header-back-icon-size');
|
||||
const HEADER_ICON_SIZE = skeletonSurfaceVar('--channel-header-icon-size');
|
||||
const HEADER_DM_AVATAR_SIZE = remFromPx(CHANNEL_HEADER_DM_AVATAR_SIZE_PX);
|
||||
@@ -176,12 +174,11 @@ export const ChatSkeleton = observer(function ChatSkeleton({presentation}: ChatS
|
||||
composerActionCount = composerLayout.mobileActionCount;
|
||||
composerDividerVisible = false;
|
||||
}
|
||||
const messageSpecs = usePlaceholderSpecs({
|
||||
const messageSpecs = useMessageListPlaceholderSpecs({
|
||||
channelId: presentation.channelId,
|
||||
compact: messagePresentation.compact,
|
||||
compactAvatarsVisible: messagePresentation.compactAvatarsVisible,
|
||||
groupSpacing: messagePresentation.groupSpacingPx,
|
||||
viewportHeightPx: messagePresentation.viewportHeightPx,
|
||||
seedKey: MESSAGE_LIST_SEED_KEY,
|
||||
});
|
||||
const rootStyle: ChatSkeletonRootStyle = {
|
||||
'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
}
|
||||
|
||||
.itemSlot {
|
||||
--guild-rail-skeleton-badge-size: var(--mention-badge-small-size);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -82,18 +81,6 @@
|
||||
min-height: var(--guild-list-item-box-size);
|
||||
}
|
||||
|
||||
.mentionBadge {
|
||||
position: absolute;
|
||||
right: calc(50% - var(--guild-icon-size) / 2 - 0.25rem);
|
||||
bottom: calc(50% - var(--guild-icon-size) / 2 - 0.25rem);
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.folderSlot .mentionBadge {
|
||||
right: -0.25rem;
|
||||
bottom: -0.25rem;
|
||||
}
|
||||
|
||||
.itemsTrailingGapCancel {
|
||||
margin-bottom: calc(-1 * var(--guild-list-item-gap));
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import {useState} from 'react';
|
||||
|
||||
const GUILD_ICON_SIZE = skeletonSurfaceVar('--guild-icon-size');
|
||||
const FOLDER_ICON_SIZE = skeletonSurfaceVar('--guild-list-item-box-size');
|
||||
const MENTION_BADGE_SIZE = 'var(--guild-rail-skeleton-badge-size)';
|
||||
const GUILD_PLACEHOLDER_COUNT = 6;
|
||||
const SHOWS_DOWNLOAD_ACTION = !Platform.isElectron && !Platform.isPWA;
|
||||
const FALLBACK_ORGANIZED_ITEMS: ReadonlyArray<RememberedSkeletonGuildRailItem> = Object.freeze(
|
||||
@@ -190,17 +189,6 @@ function showsSkeletonIndicator({indicator, selected}: SkeletonIndicatorVisibili
|
||||
return indicator !== SkeletonGuildRailItemIndicator.NONE;
|
||||
}
|
||||
|
||||
const MentionBadgePlaceholder = () => (
|
||||
<SkeletonBlock
|
||||
width={MENTION_BADGE_SIZE}
|
||||
height={MENTION_BADGE_SIZE}
|
||||
radius={SkeletonRadius.MEDIUM}
|
||||
emphasis={SkeletonEmphasis.MUTED}
|
||||
className={styles.mentionBadge}
|
||||
data-flx="app.skeleton.guild-rail-skeleton.mention-badge-placeholder.mention-badge"
|
||||
/>
|
||||
);
|
||||
|
||||
const GuildPlaceholder = ({
|
||||
indicator,
|
||||
selected,
|
||||
@@ -223,9 +211,6 @@ const GuildPlaceholder = ({
|
||||
size={GUILD_ICON_SIZE}
|
||||
data-flx="app.skeleton.guild-rail-skeleton.guild-placeholder.skeleton-circle"
|
||||
/>
|
||||
{indicator === SkeletonGuildRailItemIndicator.MENTION && (
|
||||
<MentionBadgePlaceholder data-flx="app.skeleton.guild-rail-skeleton.guild-placeholder.mention-badge-placeholder" />
|
||||
)}
|
||||
</flx-app-guild-rail-skeleton-item-slot>
|
||||
);
|
||||
|
||||
@@ -326,9 +311,6 @@ const CollapsedFolderPlaceholder = ({
|
||||
</flx-app-guild-rail-skeleton-collapsed-folder-grid>
|
||||
)}
|
||||
</flx-app-guild-rail-skeleton-collapsed-folder>
|
||||
{indicator === SkeletonGuildRailItemIndicator.MENTION && (
|
||||
<MentionBadgePlaceholder data-flx="app.skeleton.guild-rail-skeleton.collapsed-folder-placeholder.mention-badge-placeholder" />
|
||||
)}
|
||||
</flx-app-guild-rail-skeleton-item-slot>
|
||||
);
|
||||
|
||||
@@ -354,9 +336,6 @@ const ExpandedFolderGuildPlaceholder = ({
|
||||
size={GUILD_ICON_SIZE}
|
||||
data-flx="app.skeleton.guild-rail-skeleton.expanded-folder-guild-placeholder.skeleton-circle"
|
||||
/>
|
||||
{indicator === SkeletonGuildRailItemIndicator.MENTION && (
|
||||
<MentionBadgePlaceholder data-flx="app.skeleton.guild-rail-skeleton.expanded-folder-guild-placeholder.mention-badge-placeholder" />
|
||||
)}
|
||||
</flx-app-guild-rail-skeleton-expanded-folder-guild>
|
||||
);
|
||||
|
||||
@@ -467,7 +446,7 @@ export const GuildRailSkeleton: React.FC<GuildRailSkeletonProps> = ({
|
||||
const [mountState] = useState(() => {
|
||||
const rememberedLayout = getRememberedSkeletonGuildRailLayout();
|
||||
const remScale = getRemScaleForDocument(document);
|
||||
const liveScrollTop = Math.max(0, Dimension.getGuildListDimensions().scrollTop);
|
||||
const liveScrollTop = Math.max(0, Dimension.currentGuildListDimensions().scrollTop);
|
||||
let scrollTop = liveScrollTop;
|
||||
if (liveScrollTop === 0 && rememberedLayout != null) {
|
||||
scrollTop = rememberedLayout.scrollTopPx * remScale;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
getRememberedSkeletonMessagePresentation,
|
||||
resolveDefaultSkeletonChatViewportHeightPx,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed';
|
||||
import {MESSAGE_LAYOUT_SPEC} from '@app/features/theme/layout/MessageLayoutSpec';
|
||||
import {REM_BASE_PX} from '@app/features/theme/layout/RemFromPx';
|
||||
import {useMemo} from 'react';
|
||||
import {useMemo, useState} from 'react';
|
||||
|
||||
function pxFromRemLength(value: `${number}rem`): number {
|
||||
return Math.round(Number.parseFloat(value) * REM_BASE_PX);
|
||||
@@ -35,29 +39,35 @@ export interface PlaceholderSpecs {
|
||||
readonly groupSpacing: number;
|
||||
}
|
||||
|
||||
export const FILLER_WRAPPER_PADDING_TOP = '1rem' as const;
|
||||
export const FILLER_WRAPPER_PADDING_BOTTOM = '0.75rem' as const;
|
||||
export const FILLER_WRAPPER_VERTICAL_PADDING =
|
||||
pxFromRemLength(FILLER_WRAPPER_PADDING_TOP) + pxFromRemLength(FILLER_WRAPPER_PADDING_BOTTOM);
|
||||
const MESSAGE_LINE_HEIGHT = pxFromRemLength(MESSAGE_LAYOUT_SPEC.lineHeight);
|
||||
const MESSAGE_ROW_VERTICAL_PADDING = pxFromRemLength(MESSAGE_LAYOUT_SPEC.spacingY) * 2;
|
||||
const MESSAGE_HEIGHT_COMPACT = MESSAGE_LINE_HEIGHT + MESSAGE_ROW_VERTICAL_PADDING;
|
||||
const COZY_LEAD_MESSAGE_HEIGHT = MESSAGE_LINE_HEIGHT * 2 + MESSAGE_ROW_VERTICAL_PADDING;
|
||||
const COZY_GROUPED_MESSAGE_HEIGHT = MESSAGE_LINE_HEIGHT + MESSAGE_ROW_VERTICAL_PADDING;
|
||||
const SKELETON_PADDING_TOP = pxFromRemLength('1rem');
|
||||
const SKELETON_PADDING_BOTTOM = pxFromRemLength('0.75rem');
|
||||
const COZY_SKELETON_VERTICAL_PADDING = SKELETON_PADDING_TOP + SKELETON_PADDING_BOTTOM;
|
||||
const ATTACHMENT_MARGIN = 8;
|
||||
const ATTACHMENT_WIDTH_MIN = 140;
|
||||
const ATTACHMENT_WIDTH_MAX = 400;
|
||||
const ATTACHMENT_HEIGHT_MIN = 100;
|
||||
const ATTACHMENT_HEIGHT_MAX = 250;
|
||||
const ATTACHMENT_HEIGHT_MAX = 320;
|
||||
const USERNAME_WIDTH_MIN = 48;
|
||||
const USERNAME_WIDTH_RANGE = 36;
|
||||
const TIMESTAMP_WIDTH_MIN = 8;
|
||||
const TIMESTAMP_WIDTH_RANGE = 12;
|
||||
const LINE_WIDTH_MIN = 75;
|
||||
const LINE_WIDTH_RANGE = 18;
|
||||
const MIN_MESSAGE_GROUPS = 8;
|
||||
const MAX_MESSAGE_GROUPS = 64;
|
||||
const MIN_MESSAGE_GROUPS = 10;
|
||||
const MAX_MESSAGE_GROUPS = 30;
|
||||
const GROUP_LINE_RANGE = 4;
|
||||
const ATTACHMENT_GROUPS = 8;
|
||||
const GROUP_COUNT_OVERSHOOT = 1.05;
|
||||
const COZY_GROUP_COUNT_FACTOR = 0.87;
|
||||
const MIN_ESTIMATED_VIEWPORT_HEIGHT = 400;
|
||||
const MEAN_GROUP_LINE_COUNT = (1 + GROUP_LINE_RANGE) / 2;
|
||||
const MESSAGE_LIST_FALLBACK_SEED_KEY = 'message-list-placeholder';
|
||||
|
||||
interface PlaceholderGenerationOptions {
|
||||
readonly compact: boolean;
|
||||
@@ -72,17 +82,22 @@ function randomInRange(random: () => number, min: number, max: number): number {
|
||||
return Math.floor(random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function resolveShortestGroupHeight(compact: boolean): number {
|
||||
export function resolveAverageGroupHeight(compact: boolean): number {
|
||||
if (compact) {
|
||||
return MESSAGE_HEIGHT_COMPACT;
|
||||
return MESSAGE_HEIGHT_COMPACT * MEAN_GROUP_LINE_COUNT;
|
||||
}
|
||||
return COZY_LEAD_MESSAGE_HEIGHT;
|
||||
return COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (MEAN_GROUP_LINE_COUNT - 1);
|
||||
}
|
||||
|
||||
function resolvePlaceholderMessageGroups(compact: boolean, viewportHeightPx: number): number {
|
||||
const shortestGroupHeight = resolveShortestGroupHeight(compact);
|
||||
const groups = Math.ceil(Math.max(0, viewportHeightPx) / shortestGroupHeight);
|
||||
return Math.min(MAX_MESSAGE_GROUPS, Math.max(MIN_MESSAGE_GROUPS, groups));
|
||||
export function resolvePlaceholderMessageGroups(compact: boolean, viewportHeightPx: number): number {
|
||||
const usableHeight = Math.max(viewportHeightPx, MIN_ESTIMATED_VIEWPORT_HEIGHT);
|
||||
const rows = Math.ceil(usableHeight / resolveAverageGroupHeight(compact));
|
||||
const clamped = Math.min(MAX_MESSAGE_GROUPS, Math.max(MIN_MESSAGE_GROUPS, Math.ceil(GROUP_COUNT_OVERSHOOT * rows)));
|
||||
return compact ? clamped : Math.round(COZY_GROUP_COUNT_FACTOR * clamped);
|
||||
}
|
||||
|
||||
export function resolvePlaceholderAttachmentCount(messageGroups: number): number {
|
||||
return Math.max(1, Math.round((messageGroups / MAX_MESSAGE_GROUPS) * ATTACHMENT_GROUPS));
|
||||
}
|
||||
|
||||
function resolvePlaceholderDensityKey(compact: boolean): string {
|
||||
@@ -95,7 +110,7 @@ function resolvePlaceholderDensityKey(compact: boolean): string {
|
||||
function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): PlaceholderSpecs {
|
||||
const {compact, compactAvatarsVisible, messageGroups, attachments, groupSpacing, random} = options;
|
||||
const groups: Array<MutablePlaceholderMessageGroup> = [];
|
||||
let totalHeight = compact ? 0 : COZY_SKELETON_VERTICAL_PADDING;
|
||||
let totalHeight = FILLER_WRAPPER_VERTICAL_PADDING;
|
||||
for (let index = 0; index < messageGroups; index++) {
|
||||
const lineCount = Math.floor(random() * GROUP_LINE_RANGE) + 1;
|
||||
const lineWidths: Array<number> = [];
|
||||
@@ -131,7 +146,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
|
||||
return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing};
|
||||
}
|
||||
|
||||
export interface PlaceholderSpecsOptions {
|
||||
interface PlaceholderSpecsOptions {
|
||||
readonly compact: boolean;
|
||||
readonly compactAvatarsVisible: boolean;
|
||||
readonly groupSpacing: number;
|
||||
@@ -139,7 +154,7 @@ export interface PlaceholderSpecsOptions {
|
||||
readonly seedKey: string;
|
||||
}
|
||||
|
||||
export function usePlaceholderSpecs({
|
||||
function usePlaceholderSpecs({
|
||||
compact,
|
||||
compactAvatarsVisible,
|
||||
groupSpacing,
|
||||
@@ -153,10 +168,35 @@ export function usePlaceholderSpecs({
|
||||
compact,
|
||||
compactAvatarsVisible,
|
||||
messageGroups,
|
||||
attachments: Math.min(ATTACHMENT_GROUPS, messageGroups),
|
||||
attachments: Math.min(messageGroups, resolvePlaceholderAttachmentCount(messageGroups)),
|
||||
groupSpacing,
|
||||
random: createSkeletonRandomFromKey([seedKey, resolvePlaceholderDensityKey(compact), groupSpacing].join('|')),
|
||||
}),
|
||||
[compact, compactAvatarsVisible, groupSpacing, messageGroups, seedKey],
|
||||
);
|
||||
}
|
||||
|
||||
export interface MessageListPlaceholderSpecsOptions {
|
||||
readonly channelId: string | null;
|
||||
readonly compact: boolean;
|
||||
readonly compactAvatarsVisible: boolean;
|
||||
readonly groupSpacing: number;
|
||||
}
|
||||
|
||||
export function useMessageListPlaceholderSpecs({
|
||||
channelId,
|
||||
compact,
|
||||
compactAvatarsVisible,
|
||||
groupSpacing,
|
||||
}: MessageListPlaceholderSpecsOptions): PlaceholderSpecs {
|
||||
const [viewportHeightPx] = useState(
|
||||
() => getRememberedSkeletonMessagePresentation()?.viewportHeightPx ?? resolveDefaultSkeletonChatViewportHeightPx(),
|
||||
);
|
||||
return usePlaceholderSpecs({
|
||||
compact,
|
||||
compactAvatarsVisible,
|
||||
groupSpacing,
|
||||
viewportHeightPx,
|
||||
seedKey: channelId ?? MESSAGE_LIST_FALLBACK_SEED_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ChatSkeletonChannelKind = SkeletonChannelProjectionKind;
|
||||
export type ChatSkeletonChannelKind = SkeletonChannelProjectionKind;
|
||||
|
||||
export interface ChatSkeletonPresentation {
|
||||
readonly channelId: string | null;
|
||||
readonly channelKind: ChatSkeletonChannelKind;
|
||||
readonly showMemberList: boolean;
|
||||
readonly showTopic: boolean;
|
||||
@@ -114,6 +115,7 @@ export function resolveChatSkeletonPresentation(pathname: string, canFitMemberLi
|
||||
headerTopicWidthPx = rememberedProjection.topicWidthPx;
|
||||
}
|
||||
return {
|
||||
channelId,
|
||||
channelKind,
|
||||
showMemberList: resolveMemberListVisibility(
|
||||
channelId,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.wrapper {
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
contain: strict;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-bottom: var(--message-filler-padding-bottom);
|
||||
margin-left: calc(-1 * var(--message-mobile-margin));
|
||||
margin-right: calc(-1 * var(--message-mobile-margin));
|
||||
padding-left: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default));
|
||||
@@ -44,7 +47,6 @@
|
||||
.messageGroup {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
|
||||
@@ -52,10 +52,7 @@ function CozyScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-group-row
|
||||
@@ -173,10 +170,7 @@ function CompactScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-compact-messages
|
||||
@@ -242,7 +236,7 @@ export const ScrollFillerSkeleton = ({
|
||||
}: PlaceholderSpecs) => (
|
||||
<flx-message-list-skeleton
|
||||
className={flxElementClassName(styles.wrapper, compact && styles.wrapperCompact)}
|
||||
style={{minHeight: remFromPx(totalHeight)}}
|
||||
style={{height: remFromPx(totalHeight)}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.wrapper"
|
||||
>
|
||||
{groups.map((group, groupIndex) => {
|
||||
|
||||
@@ -72,7 +72,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) {
|
||||
ModalCommands.pop();
|
||||
}, [entry.id]);
|
||||
const updateScrollHintVisibility = useCallback(() => {
|
||||
const scrollerNode = scrollerRef.current?.getScrollerNode();
|
||||
const scrollerNode = scrollerRef.current?.getViewportElement();
|
||||
if (!scrollerNode) {
|
||||
setIsScrollHintVisible(false);
|
||||
return;
|
||||
@@ -89,7 +89,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) {
|
||||
updateScrollHintVisibility();
|
||||
}, [updateScrollHintVisibility]);
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollerRef.current?.scrollToBottom({animate: true});
|
||||
scrollerRef.current?.jumpToEndEdge({animate: true});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
updateScrollHintVisibility();
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getNextAnimatedImageFrame} from './useAnimatedImageDecoder';
|
||||
|
||||
describe('getNextAnimatedImageFrame', () => {
|
||||
it('stops on the final frame when the image has no repetitions', () => {
|
||||
let state = {frameIndex: 0, frameCount: 3, repetitionCount: 0, completedRepetitions: 0};
|
||||
const secondFrame = getNextAnimatedImageFrame(state);
|
||||
expect(secondFrame).toEqual({frameIndex: 1, completedRepetitions: 0});
|
||||
state = {...state, ...secondFrame!};
|
||||
const finalFrame = getNextAnimatedImageFrame(state);
|
||||
expect(finalFrame).toEqual({frameIndex: 2, completedRepetitions: 0});
|
||||
state = {...state, ...finalFrame!};
|
||||
expect(getNextAnimatedImageFrame(state)).toBeNull();
|
||||
});
|
||||
it('allows the configured number of finite repetitions before stopping', () => {
|
||||
let state = {frameIndex: 0, frameCount: 2, repetitionCount: 1, completedRepetitions: 0};
|
||||
const finalFrame = getNextAnimatedImageFrame(state);
|
||||
expect(finalFrame).toEqual({frameIndex: 1, completedRepetitions: 0});
|
||||
state = {...state, ...finalFrame!};
|
||||
const repeatedFirstFrame = getNextAnimatedImageFrame(state);
|
||||
expect(repeatedFirstFrame).toEqual({frameIndex: 0, completedRepetitions: 1});
|
||||
state = {...state, ...repeatedFirstFrame!};
|
||||
const repeatedFinalFrame = getNextAnimatedImageFrame(state);
|
||||
expect(repeatedFinalFrame).toEqual({frameIndex: 1, completedRepetitions: 1});
|
||||
state = {...state, ...repeatedFinalFrame!};
|
||||
expect(getNextAnimatedImageFrame(state)).toBeNull();
|
||||
});
|
||||
it('keeps advancing indefinitely for infinite repetitions', () => {
|
||||
let state = {frameIndex: 1, frameCount: 2, repetitionCount: Infinity, completedRepetitions: 99};
|
||||
const firstFrame = getNextAnimatedImageFrame(state);
|
||||
expect(firstFrame).toEqual({frameIndex: 0, completedRepetitions: 100});
|
||||
state = {...state, ...firstFrame!};
|
||||
expect(getNextAnimatedImageFrame(state)).toEqual({frameIndex: 1, completedRepetitions: 100});
|
||||
});
|
||||
it('does not loop single-frame images or invalid repetition counts', () => {
|
||||
expect(
|
||||
getNextAnimatedImageFrame({
|
||||
frameIndex: 0,
|
||||
frameCount: 1,
|
||||
repetitionCount: Infinity,
|
||||
completedRepetitions: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getNextAnimatedImageFrame({
|
||||
frameIndex: 1,
|
||||
frameCount: 2,
|
||||
repetitionCount: Number.NaN,
|
||||
completedRepetitions: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,730 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
drawVideoFrameToCanvas,
|
||||
type FluxerImageDecoderConstructor,
|
||||
type FluxerImageDecoderInstance,
|
||||
getImageDecoderConstructor,
|
||||
} from '@app/features/platform/utils/ImageDecoderInterop';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface UseAnimatedImageDecoderOptions {
|
||||
src: string | null;
|
||||
playing: boolean;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
maxCachedFrames?: number;
|
||||
}
|
||||
|
||||
export interface AnimatedImageDecoderState {
|
||||
supported: boolean;
|
||||
loaded: boolean;
|
||||
error: boolean;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
}
|
||||
|
||||
interface DecoderSourceIdentity {
|
||||
src: string | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
maxCachedFrames: number | undefined;
|
||||
}
|
||||
|
||||
export interface AnimatedImageFrameAdvanceState {
|
||||
frameIndex: number;
|
||||
frameCount: number;
|
||||
repetitionCount: number;
|
||||
completedRepetitions: number;
|
||||
}
|
||||
|
||||
export interface AnimatedImageFrameAdvanceResult {
|
||||
frameIndex: number;
|
||||
completedRepetitions: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FRAME_DURATION_MS = 100;
|
||||
const DEFAULT_MAX_CACHED_FRAMES = 24;
|
||||
const MAX_CACHED_FRAME_BYTES = 64 * 1024 * 1024;
|
||||
const MAXIMUM_FRAME_TIMER_DELAY_MS = 2_147_000_000;
|
||||
const MAX_ANIMATED_IMAGE_ENCODED_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS = 4096;
|
||||
const ANIMATED_IMAGE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function createIdleDecoderState(): AnimatedImageDecoderState {
|
||||
return {
|
||||
supported: getImageDecoderConstructor() !== null,
|
||||
loaded: false,
|
||||
error: false,
|
||||
naturalWidth: 0,
|
||||
naturalHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function sameDecoderSourceIdentity(current: DecoderSourceIdentity, next: DecoderSourceIdentity): boolean {
|
||||
return current.src === next.src && current.canvas === next.canvas && current.maxCachedFrames === next.maxCachedFrames;
|
||||
}
|
||||
|
||||
interface CachedAnimatedImageFrame {
|
||||
image: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
byteSize: number;
|
||||
durationMs: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface AnimatedImageResponseData {
|
||||
type: string;
|
||||
encodedBytes: Uint8Array;
|
||||
}
|
||||
|
||||
const guessMimeFromUrl = (url: string): string => {
|
||||
const path = url.split('?')[0];
|
||||
const lower = path === undefined ? '' : path.toLowerCase();
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.apng') || lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.avif')) return 'image/avif';
|
||||
return 'image/webp';
|
||||
};
|
||||
|
||||
function resolveMimeFromResponse(contentType: string | null, source: string): string {
|
||||
if (contentType === null) return guessMimeFromUrl(source);
|
||||
const separator = contentType.indexOf(';');
|
||||
const mediaType = separator < 0 ? contentType : contentType.slice(0, separator);
|
||||
const normalized = mediaType.trim().toLowerCase();
|
||||
return normalized.length === 0 ? guessMimeFromUrl(source) : normalized;
|
||||
}
|
||||
|
||||
function decodedFrameByteSize(width: number, height: number): number | null {
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) return null;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
const byteSize = width * height * 4;
|
||||
if (!Number.isSafeInteger(byteSize)) return null;
|
||||
return byteSize;
|
||||
}
|
||||
|
||||
function frameDurationMs(durationMicroseconds: number | null | undefined): number {
|
||||
if (durationMicroseconds === null || durationMicroseconds === undefined) return DEFAULT_FRAME_DURATION_MS;
|
||||
if (!Number.isFinite(durationMicroseconds) || durationMicroseconds <= 0) return DEFAULT_FRAME_DURATION_MS;
|
||||
return Math.min(MAXIMUM_FRAME_TIMER_DELAY_MS, Math.max(16, durationMicroseconds / 1000));
|
||||
}
|
||||
|
||||
function cancelResponseBody(response: Response): void {
|
||||
const body = response.body;
|
||||
if (body == null) return;
|
||||
try {
|
||||
void body.cancel().catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function closeVideoFrame(image: VideoFrame): void {
|
||||
try {
|
||||
image.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function readBoundedResponseBytes(response: Response): Promise<Uint8Array> {
|
||||
const body = response.body;
|
||||
if (body == null) throw new Error('Animated image response did not provide a body');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength !== null) {
|
||||
const maximumContentLengthDigits = Number.MAX_SAFE_INTEGER.toString().length;
|
||||
if (
|
||||
contentLength.length === 0 ||
|
||||
contentLength.length > maximumContentLengthDigits ||
|
||||
!/^[0-9]+$/.test(contentLength)
|
||||
) {
|
||||
cancelResponseBody(response);
|
||||
throw new Error('Animated image response has an invalid Content-Length header');
|
||||
}
|
||||
const declaredBytes = Number(contentLength);
|
||||
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
|
||||
cancelResponseBody(response);
|
||||
throw new Error('Animated image response exceeded its byte limit');
|
||||
}
|
||||
}
|
||||
const reader = body.getReader();
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
const chunk = result.value;
|
||||
if (chunk == null) continue;
|
||||
if (chunks.length >= MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS) {
|
||||
throw new Error('Animated image response exceeded its chunk limit');
|
||||
}
|
||||
totalBytes += chunk.byteLength;
|
||||
if (totalBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
|
||||
throw new Error('Animated image response exceeded its byte limit');
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function normalizeRepetitionCount(repetitionCount: number): number {
|
||||
if (repetitionCount === Infinity) return Infinity;
|
||||
if (!Number.isFinite(repetitionCount)) return 0;
|
||||
return Math.max(0, Math.floor(repetitionCount));
|
||||
}
|
||||
|
||||
async function createCachedAnimatedImageFrame(
|
||||
image: VideoFrame,
|
||||
byteSize: number,
|
||||
durationMs: number,
|
||||
ownerWindow: Window,
|
||||
): Promise<CachedAnimatedImageFrame> {
|
||||
const width = image.displayWidth;
|
||||
const height = image.displayHeight;
|
||||
if (typeof ownerWindow.createImageBitmap === 'function') {
|
||||
try {
|
||||
const bitmap = await ownerWindow.createImageBitmap(image as unknown as ImageBitmapSource);
|
||||
closeVideoFrame(image);
|
||||
return {
|
||||
image: bitmap,
|
||||
width,
|
||||
height,
|
||||
byteSize,
|
||||
durationMs,
|
||||
close: () => bitmap.close(),
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
image: image as unknown as CanvasImageSource,
|
||||
width,
|
||||
height,
|
||||
byteSize,
|
||||
durationMs,
|
||||
close: () => image.close(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextAnimatedImageFrame({
|
||||
frameIndex,
|
||||
frameCount,
|
||||
repetitionCount,
|
||||
completedRepetitions,
|
||||
}: AnimatedImageFrameAdvanceState): AnimatedImageFrameAdvanceResult | null {
|
||||
const normalizedFrameCount = Math.max(0, Math.floor(frameCount));
|
||||
if (normalizedFrameCount <= 1) return null;
|
||||
const currentFrameIndex = Math.max(0, Math.min(normalizedFrameCount - 1, Math.floor(frameIndex)));
|
||||
const normalizedCompletedRepetitions = Math.max(0, Math.floor(completedRepetitions));
|
||||
if (currentFrameIndex < normalizedFrameCount - 1) {
|
||||
return {frameIndex: currentFrameIndex + 1, completedRepetitions: normalizedCompletedRepetitions};
|
||||
}
|
||||
const normalizedRepetitionCount = normalizeRepetitionCount(repetitionCount);
|
||||
if (normalizedCompletedRepetitions >= normalizedRepetitionCount) return null;
|
||||
return {frameIndex: 0, completedRepetitions: normalizedCompletedRepetitions + 1};
|
||||
}
|
||||
|
||||
export function useAnimatedImageDecoder({
|
||||
src,
|
||||
playing,
|
||||
canvas,
|
||||
maxCachedFrames = DEFAULT_MAX_CACHED_FRAMES,
|
||||
}: UseAnimatedImageDecoderOptions): AnimatedImageDecoderState {
|
||||
const sourceIdentity: DecoderSourceIdentity = {src, canvas, maxCachedFrames};
|
||||
const [state, setState] = useState<AnimatedImageDecoderState>(createIdleDecoderState);
|
||||
const [stateIdentity, setStateIdentity] = useState<DecoderSourceIdentity>(sourceIdentity);
|
||||
const runnerRef = useRef<{
|
||||
kick: () => void;
|
||||
pause: () => void;
|
||||
cancelled: boolean;
|
||||
} | null>(null);
|
||||
const playingRef = useRef(playing);
|
||||
playingRef.current = playing;
|
||||
const stateIsCurrent = sameDecoderSourceIdentity(stateIdentity, sourceIdentity);
|
||||
const visibleState = stateIsCurrent ? state : createIdleDecoderState();
|
||||
useEffect(() => {
|
||||
setStateIdentity(sourceIdentity);
|
||||
const Ctor = getImageDecoderConstructor();
|
||||
setState(createIdleDecoderState);
|
||||
if (!Ctor || !src || !canvas) {
|
||||
if (!Ctor) setState((prev) => ({...prev, supported: false}));
|
||||
return;
|
||||
}
|
||||
const ownerWindow = canvas.ownerDocument.defaultView;
|
||||
if (ownerWindow == null) {
|
||||
setState((prev) => ({...prev, error: true, supported: false}));
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d', {alpha: true});
|
||||
if (!ctx) {
|
||||
setState((prev) => ({...prev, error: true}));
|
||||
return;
|
||||
}
|
||||
const normalizedMaxCachedFrames = Number.isFinite(maxCachedFrames)
|
||||
? Math.max(2, Math.floor(maxCachedFrames))
|
||||
: DEFAULT_MAX_CACHED_FRAMES;
|
||||
const runner = {cancelled: false, kick: () => {}, pause: () => {}};
|
||||
runnerRef.current = runner;
|
||||
const fetchController = new ownerWindow.AbortController();
|
||||
let decoder: FluxerImageDecoderInstance | null = null;
|
||||
let frameCount = Number.POSITIVE_INFINITY;
|
||||
let repetitionCount = 0;
|
||||
let completedRepetitions = 0;
|
||||
let frameIndex = 0;
|
||||
let timer: number | null = null;
|
||||
let resolveTimer: (() => void) | null = null;
|
||||
let advancing = false;
|
||||
let cachedFrameBytes = 0;
|
||||
const frameCache = new Map<number, CachedAnimatedImageFrame>();
|
||||
const isCurrentRunner = () => !runner.cancelled && runnerRef.current === runner;
|
||||
const publishState = (
|
||||
nextState: AnimatedImageDecoderState | ((previous: AnimatedImageDecoderState) => AnimatedImageDecoderState),
|
||||
) => {
|
||||
if (!isCurrentRunner()) return;
|
||||
setState(nextState);
|
||||
};
|
||||
const closeFrameCache = () => {
|
||||
for (const frame of frameCache.values()) {
|
||||
try {
|
||||
frame.close();
|
||||
} catch {}
|
||||
}
|
||||
frameCache.clear();
|
||||
cachedFrameBytes = 0;
|
||||
};
|
||||
const closeDecoder = () => {
|
||||
if (decoder == null) return;
|
||||
try {
|
||||
decoder.close();
|
||||
} catch {}
|
||||
decoder = null;
|
||||
};
|
||||
const failRunner = () => {
|
||||
if (runner.cancelled) return;
|
||||
publishState((prev) => ({...prev, error: true}));
|
||||
runner.cancelled = true;
|
||||
try {
|
||||
fetchController.abort();
|
||||
} catch {}
|
||||
clearTimer();
|
||||
closeFrameCache();
|
||||
closeDecoder();
|
||||
};
|
||||
publishState((prev) => ({...prev, loaded: false, error: false, supported: true}));
|
||||
const clearTimer = () => {
|
||||
if (timer != null) {
|
||||
ownerWindow.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
const resolve = resolveTimer;
|
||||
resolveTimer = null;
|
||||
if (resolve != null) resolve();
|
||||
};
|
||||
const draw = (frame: CachedAnimatedImageFrame): boolean => {
|
||||
if (!isCurrentRunner()) return false;
|
||||
const w = frame.width;
|
||||
const h = frame.height;
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
try {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(frame.image, 0, 0, w, h);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const getFrame = async (index: number): Promise<CachedAnimatedImageFrame | null> => {
|
||||
const cached = frameCache.get(index);
|
||||
if (cached) {
|
||||
frameCache.delete(index);
|
||||
frameCache.set(index, cached);
|
||||
return cached;
|
||||
}
|
||||
const activeDecoder = decoder;
|
||||
if (activeDecoder == null) return null;
|
||||
let decodedImage: VideoFrame | null = null;
|
||||
try {
|
||||
const result = await activeDecoder.decode({frameIndex: index, completeFramesOnly: true});
|
||||
decodedImage = result.image;
|
||||
if (!isCurrentRunner()) {
|
||||
closeVideoFrame(decodedImage);
|
||||
decodedImage = null;
|
||||
return null;
|
||||
}
|
||||
const byteSize = decodedFrameByteSize(decodedImage.displayWidth, decodedImage.displayHeight);
|
||||
if (byteSize == null || byteSize > MAX_CACHED_FRAME_BYTES) {
|
||||
closeVideoFrame(decodedImage);
|
||||
decodedImage = null;
|
||||
failRunner();
|
||||
return null;
|
||||
}
|
||||
const durationMs = frameDurationMs(decodedImage.duration);
|
||||
const entry = await createCachedAnimatedImageFrame(decodedImage, byteSize, durationMs, ownerWindow);
|
||||
decodedImage = null;
|
||||
if (!isCurrentRunner()) {
|
||||
entry.close();
|
||||
return null;
|
||||
}
|
||||
frameCache.set(index, entry);
|
||||
cachedFrameBytes += entry.byteSize;
|
||||
while (frameCache.size > normalizedMaxCachedFrames || cachedFrameBytes > MAX_CACHED_FRAME_BYTES) {
|
||||
const oldestIndex = frameCache.keys().next().value;
|
||||
if (oldestIndex === undefined) break;
|
||||
const oldest = frameCache.get(oldestIndex);
|
||||
frameCache.delete(oldestIndex);
|
||||
if (oldest != null) {
|
||||
cachedFrameBytes -= oldest.byteSize;
|
||||
try {
|
||||
oldest.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
} catch {
|
||||
if (decodedImage != null) {
|
||||
closeVideoFrame(decodedImage);
|
||||
}
|
||||
failRunner();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const advance = async () => {
|
||||
if (advancing || runner.cancelled) return;
|
||||
advancing = true;
|
||||
try {
|
||||
while (!runner.cancelled && playingRef.current) {
|
||||
const next = getNextAnimatedImageFrame({
|
||||
frameIndex,
|
||||
frameCount,
|
||||
repetitionCount,
|
||||
completedRepetitions,
|
||||
});
|
||||
if (!next) return;
|
||||
const frame = await getFrame(next.frameIndex);
|
||||
if (!frame || runner.cancelled || !playingRef.current) return;
|
||||
frameIndex = next.frameIndex;
|
||||
completedRepetitions = next.completedRepetitions;
|
||||
if (!draw(frame)) {
|
||||
failRunner();
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
clearTimer();
|
||||
resolveTimer = resolve;
|
||||
timer = ownerWindow.setTimeout(
|
||||
() => {
|
||||
timer = null;
|
||||
resolveTimer = null;
|
||||
resolve();
|
||||
},
|
||||
Math.max(16, frame.durationMs),
|
||||
);
|
||||
});
|
||||
if (runner.cancelled || !playingRef.current) return;
|
||||
}
|
||||
} finally {
|
||||
advancing = false;
|
||||
}
|
||||
};
|
||||
runner.kick = () => {
|
||||
if (runner.cancelled) return;
|
||||
void advance();
|
||||
};
|
||||
runner.pause = () => {
|
||||
clearTimer();
|
||||
};
|
||||
const loadAnimatedImage = async (): Promise<AnimatedImageResponseData | null> => {
|
||||
let response: Response | null = null;
|
||||
let timeout: number | null = null;
|
||||
const operation = (async (): Promise<AnimatedImageResponseData | null> => {
|
||||
const fetchedResponse = await ownerWindow.fetch(src, {
|
||||
cache: 'force-cache',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
signal: fetchController.signal,
|
||||
});
|
||||
response = fetchedResponse;
|
||||
if (!fetchedResponse.ok || fetchedResponse.body == null) {
|
||||
cancelResponseBody(fetchedResponse);
|
||||
throw new Error('Animated image request failed');
|
||||
}
|
||||
const type = resolveMimeFromResponse(fetchedResponse.headers.get('content-type'), src);
|
||||
const isSupported = await Ctor.isTypeSupported(type).catch(() => false);
|
||||
if (!isCurrentRunner()) return null;
|
||||
if (!isSupported) {
|
||||
cancelResponseBody(fetchedResponse);
|
||||
return null;
|
||||
}
|
||||
const encodedBytes = await readBoundedResponseBytes(fetchedResponse);
|
||||
return {type, encodedBytes};
|
||||
})();
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = ownerWindow.setTimeout(() => {
|
||||
try {
|
||||
fetchController.abort();
|
||||
} catch {}
|
||||
if (response != null) cancelResponseBody(response);
|
||||
reject(new Error('Animated image request timed out'));
|
||||
}, ANIMATED_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout != null) ownerWindow.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
const start = async () => {
|
||||
try {
|
||||
const animatedImage = await loadAnimatedImage();
|
||||
if (!isCurrentRunner()) return;
|
||||
if (animatedImage == null) {
|
||||
publishState((prev) => ({...prev, supported: false}));
|
||||
runner.cancelled = true;
|
||||
return;
|
||||
}
|
||||
const {type, encodedBytes} = animatedImage;
|
||||
const decoderBuffer = new ArrayBuffer(encodedBytes.byteLength);
|
||||
new Uint8Array(decoderBuffer).set(encodedBytes);
|
||||
const decoderBody = new ownerWindow.Response(decoderBuffer).body;
|
||||
if (decoderBody == null) throw new Error('Animated image decoder body was unavailable');
|
||||
const createdDecoder = new Ctor({data: decoderBody, type, preferAnimation: true});
|
||||
decoder = createdDecoder;
|
||||
await createdDecoder.completed;
|
||||
if (!isCurrentRunner()) return;
|
||||
const activeDecoder = decoder;
|
||||
if (activeDecoder == null) throw new Error('Animated image decoder was closed before completion');
|
||||
const track = activeDecoder.tracks.selectedTrack;
|
||||
if (track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1) {
|
||||
throw new Error('Animated image decoder returned an invalid frame count');
|
||||
}
|
||||
frameCount = track.frameCount;
|
||||
if (track.repetitionCount === undefined) {
|
||||
repetitionCount = 0;
|
||||
} else if (
|
||||
track.repetitionCount !== Infinity &&
|
||||
(!Number.isSafeInteger(track.repetitionCount) || track.repetitionCount < 0)
|
||||
) {
|
||||
throw new Error('Animated image decoder returned an invalid repetition count');
|
||||
} else {
|
||||
repetitionCount = track.repetitionCount;
|
||||
}
|
||||
const first = await getFrame(0);
|
||||
if (!first || !isCurrentRunner()) return;
|
||||
if (!draw(first)) {
|
||||
failRunner();
|
||||
return;
|
||||
}
|
||||
publishState({
|
||||
supported: true,
|
||||
loaded: true,
|
||||
error: false,
|
||||
naturalWidth: first.width,
|
||||
naturalHeight: first.height,
|
||||
});
|
||||
if (playingRef.current && frameCount > 1) {
|
||||
void advance();
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrentRunner()) return;
|
||||
failRunner();
|
||||
}
|
||||
};
|
||||
void start();
|
||||
return () => {
|
||||
runner.cancelled = true;
|
||||
try {
|
||||
fetchController.abort();
|
||||
} catch {}
|
||||
clearTimer();
|
||||
closeFrameCache();
|
||||
closeDecoder();
|
||||
if (runnerRef.current === runner) runnerRef.current = null;
|
||||
};
|
||||
}, [canvas, maxCachedFrames, src]);
|
||||
useEffect(() => {
|
||||
const runner = runnerRef.current;
|
||||
if (runner == null) return;
|
||||
if (playing) {
|
||||
runner.kick();
|
||||
return;
|
||||
}
|
||||
runner.pause();
|
||||
}, [playing]);
|
||||
return visibleState;
|
||||
}
|
||||
|
||||
export interface DecodedImageFrames {
|
||||
frames: Array<ImageData>;
|
||||
delays: Array<number>;
|
||||
width: number;
|
||||
height: number;
|
||||
ready: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface UseDecodedImageFramesOptions {
|
||||
bytes: Uint8Array | null;
|
||||
mime: string | null;
|
||||
}
|
||||
|
||||
const HEIC_MIMES = new Set(['image/heic', 'image/heif']);
|
||||
|
||||
export function useDecodedImageFrames({bytes, mime}: UseDecodedImageFramesOptions): DecodedImageFrames {
|
||||
const [state, setState] = useState<DecodedImageFrames>(() => ({
|
||||
frames: [],
|
||||
delays: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
ready: false,
|
||||
error: null,
|
||||
}));
|
||||
useEffect(() => {
|
||||
if (!bytes || !mime) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await decodeAllFrames(bytes, mime);
|
||||
if (cancelled) return;
|
||||
setState({...result, ready: true, error: null});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
frames: [],
|
||||
delays: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
ready: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bytes, mime]);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function decodeAllFrames(
|
||||
bytes: Uint8Array,
|
||||
mime: string,
|
||||
): Promise<{
|
||||
frames: Array<ImageData>;
|
||||
delays: Array<number>;
|
||||
width: number;
|
||||
height: number;
|
||||
}> {
|
||||
const Cls = getImageDecoderConstructor();
|
||||
const lower = mime.toLowerCase();
|
||||
if (HEIC_MIMES.has(lower)) {
|
||||
const frame = await decodeStaticViaImage(bytes, lower);
|
||||
return {frames: [frame.image], delays: [0], width: frame.width, height: frame.height};
|
||||
}
|
||||
if (Cls && (await Cls.isTypeSupported(lower).catch(() => false))) {
|
||||
return decodeAllFramesViaImageDecoder(bytes, lower, Cls);
|
||||
}
|
||||
const frame = await decodeStaticViaImage(bytes, lower);
|
||||
return {frames: [frame.image], delays: [0], width: frame.width, height: frame.height};
|
||||
}
|
||||
|
||||
async function decodeAllFramesViaImageDecoder(
|
||||
bytes: Uint8Array,
|
||||
type: string,
|
||||
Cls: FluxerImageDecoderConstructor,
|
||||
): Promise<{
|
||||
frames: Array<ImageData>;
|
||||
delays: Array<number>;
|
||||
width: number;
|
||||
height: number;
|
||||
}> {
|
||||
const decoder = new Cls({data: bytes, type, preferAnimation: true});
|
||||
try {
|
||||
await decoder.completed;
|
||||
const track = decoder.tracks.selectedTrack;
|
||||
const trackFrameCount =
|
||||
track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1 ? 1 : track.frameCount;
|
||||
const count = Math.max(1, trackFrameCount);
|
||||
const frames: Array<ImageData> = [];
|
||||
const delays: Array<number> = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const {image} = await decoder.decode({frameIndex: i, completeFramesOnly: true});
|
||||
try {
|
||||
const w = image.displayWidth;
|
||||
const h = image.displayHeight;
|
||||
width = w;
|
||||
height = h;
|
||||
const canvas = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : null;
|
||||
if (!canvas) throw new Error('OffscreenCanvas unavailable for ImageDecoder fallback');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('failed to acquire 2d context');
|
||||
drawVideoFrameToCanvas(ctx, image);
|
||||
const data = ctx.getImageData(0, 0, w, h);
|
||||
frames.push(new ImageData(new Uint8ClampedArray(data.data), w, h));
|
||||
const duration = image.duration;
|
||||
delays.push(duration === null || duration === undefined ? 0 : duration / 1000);
|
||||
} finally {
|
||||
image.close();
|
||||
}
|
||||
}
|
||||
return {frames, delays, width, height};
|
||||
} finally {
|
||||
decoder.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeStaticViaImage(
|
||||
bytes: Uint8Array,
|
||||
mime: string,
|
||||
): Promise<{
|
||||
image: ImageData;
|
||||
width: number;
|
||||
height: number;
|
||||
}> {
|
||||
if (typeof Image === 'undefined' || typeof URL === 'undefined') {
|
||||
throw new Error('static decode unavailable: no Image/URL globals');
|
||||
}
|
||||
const blob = new Blob([new Uint8Array(bytes)], {type: mime});
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
if (typeof img.decode === 'function') {
|
||||
await img.decode();
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(`failed to decode ${mime}`));
|
||||
});
|
||||
}
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
if (w === 0 || h === 0) throw new Error(`failed to decode ${mime}: zero dimensions`);
|
||||
const canvas =
|
||||
typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : document.createElement('canvas');
|
||||
if (canvas instanceof HTMLCanvasElement) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
const ctx = (canvas as OffscreenCanvas | HTMLCanvasElement).getContext('2d');
|
||||
if (!ctx) throw new Error('failed to acquire 2d context for static decode');
|
||||
(ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D).drawImage(img, 0, 0);
|
||||
const data = (ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D).getImageData(0, 0, w, h);
|
||||
return {image: new ImageData(new Uint8ClampedArray(data.data), w, h), width: w, height: h};
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface UseAnimatedImageUrlOptions {
|
||||
staticUrl: string | null;
|
||||
animatedUrl?: string | null;
|
||||
kind: ShouldAnimateKind;
|
||||
isAnimated?: boolean;
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
@@ -23,6 +24,8 @@ interface AnimatedImageUrlState {
|
||||
|
||||
type AnimatedImageLoadStatus = 'idle' | 'loading' | 'loaded' | 'failed';
|
||||
|
||||
const NOOP_HOVER_REF: React.RefCallback<HTMLElement> = () => {};
|
||||
|
||||
interface AnimatedImageLoadState {
|
||||
url: string | null;
|
||||
status: AnimatedImageLoadStatus;
|
||||
@@ -32,13 +35,21 @@ export function useAnimatedImageUrl({
|
||||
staticUrl,
|
||||
animatedUrl: providedAnimatedUrl,
|
||||
kind,
|
||||
isAnimated,
|
||||
isFocused,
|
||||
}: UseAnimatedImageUrlOptions): AnimatedImageUrlState {
|
||||
const animatedUrl: string | null = providedAnimatedUrl == null ? null : providedAnimatedUrl;
|
||||
const [hoverRef, isHovering] = useHover();
|
||||
const shouldAnimate = useShouldAnimate({kind, isHovering, isFocused});
|
||||
const gifAutoPlayEnabled = kind === 'gif' && UserSettings.getGifAutoPlay();
|
||||
const hasAnimatedUrl = Boolean(animatedUrl && animatedUrl !== staticUrl);
|
||||
const isAnimatable = isAnimated ?? hasAnimatedUrl;
|
||||
const shouldAnimate = useShouldAnimate({
|
||||
kind,
|
||||
isAnimated: isAnimatable,
|
||||
isHovering: isAnimatable && isHovering,
|
||||
isFocused: isAnimatable && isFocused,
|
||||
});
|
||||
const autoplaysWithoutInteraction = useShouldAnimate({kind, isAnimated: isAnimatable});
|
||||
const gifAutoPlayEnabled = kind === 'gif' && UserSettings.getGifAutoPlay();
|
||||
const initialAnimatedLoadState: AnimatedImageLoadState = {
|
||||
url: animatedUrl,
|
||||
status: animatedUrl != null && ImageCacheUtils.hasImage(animatedUrl) ? 'loaded' : 'idle',
|
||||
@@ -104,7 +115,9 @@ export function useAnimatedImageUrl({
|
||||
animatedUrl != null &&
|
||||
animatedLoadState.url === animatedUrl &&
|
||||
animatedLoadState.status === 'loaded';
|
||||
const imageUrl = animatedSourceReady ? animatedUrl : staticUrl;
|
||||
const showGifIndicator = kind === 'gif' && hasAnimatedUrl && !gifAutoPlayEnabled && !shouldAnimate;
|
||||
return {hoverRef, imageUrl, shouldAnimate, showGifIndicator};
|
||||
const animatedSourceFailed = animatedLoadState.url === animatedUrl && animatedLoadState.status === 'failed';
|
||||
const skipsStaticFirstPaint = autoplaysWithoutInteraction && !animatedSourceFailed;
|
||||
const imageUrl = animatedUrl != null && (animatedSourceReady || skipsStaticFirstPaint) ? animatedUrl : staticUrl;
|
||||
const showGifIndicator = kind === 'gif' && isAnimatable && !gifAutoPlayEnabled && !shouldAnimate;
|
||||
return {hoverRef: isAnimatable ? hoverRef : NOOP_HOVER_REF, imageUrl, shouldAnimate, showGifIndicator};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user