mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
233
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3afad20e36 | ||
|
|
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 | ||
|
|
631bc2307a | ||
|
|
8f4f9a8601 | ||
|
|
1c920f966e | ||
|
|
2b1de38949 | ||
|
|
d5daf61dbd | ||
|
|
06c42ce02f | ||
|
|
4f21430880 | ||
|
|
9731bac40f | ||
|
|
b144e650f2 | ||
|
|
ef6536644c | ||
|
|
17eab43245 | ||
|
|
fbd7f1e3b8 | ||
|
|
25af7516a4 | ||
|
|
c020eded31 | ||
|
|
5331c0216a | ||
|
|
528777926c | ||
|
|
47b5c3d4f0 | ||
|
|
d9bfca66d6 | ||
|
|
ded51b65d3 | ||
|
|
ddc8837d4f | ||
|
|
df16957c95 | ||
|
|
f38b19d4bd | ||
|
|
f7cd4f2c74 | ||
|
|
a078392888 | ||
|
|
3060f23f8c | ||
|
|
6a5f0d4d29 | ||
|
|
91d989dc7c | ||
|
|
7c82804df6 | ||
|
|
b7de83f7fc | ||
|
|
97bd022bee | ||
|
|
62324df039 | ||
|
|
9f78b3d9a6 | ||
|
|
362a89f2b2 | ||
|
|
ce41960fcd | ||
|
|
2083eaddd6 | ||
|
|
d398ebc44b | ||
|
|
59887ad404 | ||
|
|
5aa283e8e0 | ||
|
|
d9ed256a4b | ||
|
|
a297f89b83 | ||
|
|
4a16921242 | ||
|
|
a6f83c4fb1 | ||
|
|
7b5c82c6cf | ||
|
|
30a61ce90f | ||
|
|
22bc2cab74 | ||
|
|
53df9a0d6d | ||
|
|
f9b7ec4d0b | ||
|
|
569abf57b7 | ||
|
|
ae8e32d809 | ||
|
|
a81b1abec7 | ||
|
|
8836565c32 | ||
|
|
a1b595d52f | ||
|
|
abb71ed558 | ||
|
|
c006d413ac | ||
|
|
fa11acae15 | ||
|
|
300f467ad0 |
@@ -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
+10
@@ -1777,6 +1777,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
@@ -1800,6 +1801,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
@@ -1836,6 +1838,7 @@ dependencies = [
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"fluxer_common",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.4.2",
|
||||
@@ -1875,6 +1878,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
@@ -1943,6 +1947,7 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"moka",
|
||||
@@ -2038,12 +2043,17 @@ dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"axum",
|
||||
"base64",
|
||||
"hmac 0.13.0",
|
||||
"maxminddb",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"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> {
|
||||
|
||||
@@ -29,12 +29,16 @@
|
||||
"main": "./src/MediaProxyUtils.ts",
|
||||
"types": "./src/MediaProxyUtils.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
buildExternalMediaProxyPath,
|
||||
buildOpaqueExternalMediaProxyPath,
|
||||
reconstructOriginalUrl,
|
||||
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const ROUND_TRIP_URLS = [
|
||||
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
|
||||
'https://example.com:8443/a.png',
|
||||
'https://avatars.githubusercontent.com/u/241303489?v=4',
|
||||
'http://example.com/plain.gif',
|
||||
'https://example.com/deep/nested/path/to/file.jpeg',
|
||||
'https://example.com/file.png?a=1&b=2&c=3',
|
||||
'https://example.com/spaced%20name.png',
|
||||
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
|
||||
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
|
||||
'https://sub.domain.example.co.uk/a/b.webp',
|
||||
];
|
||||
|
||||
describe('buildExternalMediaProxyPath', () => {
|
||||
it('emits the plain path shape with the extension last', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
|
||||
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
|
||||
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non default port on the host segment', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
|
||||
});
|
||||
|
||||
it('preserves the http scheme', () => {
|
||||
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
|
||||
});
|
||||
|
||||
it('handles a root url with no path', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
|
||||
});
|
||||
|
||||
it('never emits the v2 prefix any more', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('ends in the source file extension so extension based cdn caching applies', () => {
|
||||
for (const [url, ext] of [
|
||||
['https://example.com/a.webp', '.webp'],
|
||||
['https://example.com/a.png?x=1', '.png'],
|
||||
['https://example.com/a/b/c.jpeg', '.jpeg'],
|
||||
] as const) {
|
||||
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a url it cannot parse', () => {
|
||||
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconstructOriginalUrl', () => {
|
||||
it('round trips every supported shape', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes an externally produced path verbatim', () => {
|
||||
expect(
|
||||
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
|
||||
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
|
||||
});
|
||||
|
||||
it('does not double the question mark when the query segment carries one', () => {
|
||||
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
|
||||
expect(decoded).toBe('https://example.com/x.png?a=1');
|
||||
expect(decoded).not.toContain('??');
|
||||
});
|
||||
|
||||
it('still accepts a query segment without a leading question mark', () => {
|
||||
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
|
||||
});
|
||||
|
||||
it('still decodes v2 paths so links already sent keep working', () => {
|
||||
expect(reconstructOriginalUrl(buildOpaqueExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
|
||||
'https://example.com/a.png?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a path that has no host after the protocol', () => {
|
||||
expect(() => reconstructOriginalUrl('https')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty v2 payload', () => {
|
||||
expect(() => reconstructOriginalUrl('v2/')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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,29 +71,47 @@ 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);
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
|
||||
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 buildOpaqueExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
return `${OPAQUE_PATH_PREFIX}${encodeOpaquePathComponent(parsedUrl.toString())}`;
|
||||
}
|
||||
|
||||
export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
|
||||
const protocol = parsedUrl.protocol.replace(/:$/u, '');
|
||||
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
|
||||
const path = parsedUrl.pathname
|
||||
.replace(/^\//u, '')
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
|
||||
segments.push(protocol, host);
|
||||
if (path) {
|
||||
segments.push(path);
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
export function reconstructOriginalUrl(proxyUrlPath: string): string {
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
|
||||
? reconstructV2OriginalUrl(proxyUrlPath)
|
||||
: reconstructLegacyOriginalUrl(proxyUrlPath);
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(OPAQUE_PATH_PREFIX)
|
||||
? reconstructOpaqueOriginalUrl(proxyUrlPath)
|
||||
: reconstructSegmentedOriginalUrl(proxyUrlPath);
|
||||
new URL(reconstructedUrl);
|
||||
return reconstructedUrl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
|
||||
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
|
||||
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
|
||||
},
|
||||
s3Downloads: resolveDownloadsProvider(master),
|
||||
s3: {
|
||||
endpoint: s3Config.endpoint,
|
||||
presignedUrlBase: s3Config.presigned_url_base,
|
||||
@@ -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,
|
||||
|
||||
@@ -245,7 +245,6 @@ import {
|
||||
VISIONARY_SLOT_COLUMNS,
|
||||
type VisionarySlotRow,
|
||||
} from './database/types/PaymentTypes';
|
||||
import {PNEUMATIC_POST_DELIVERY_COLUMNS, type PneumaticPostDeliveryRow} from './database/types/PneumaticPostTypes';
|
||||
import {
|
||||
DSA_REPORT_EMAIL_VERIFICATION_COLUMNS,
|
||||
DSA_REPORT_TICKET_COLUMNS,
|
||||
@@ -433,11 +432,6 @@ export const UserContactChangeLogs = defineTable<UserContactChangeLogRow, 'user_
|
||||
columns: USER_CONTACT_CHANGE_LOG_COLUMNS,
|
||||
primaryKey: ['user_id', 'event_id'],
|
||||
});
|
||||
export const PneumaticPostDeliveries = defineTable<PneumaticPostDeliveryRow, 'user_id' | 'dispatch_key'>({
|
||||
name: 'pneumatic_post_deliveries',
|
||||
columns: PNEUMATIC_POST_DELIVERY_COLUMNS,
|
||||
primaryKey: ['user_id', 'dispatch_key'],
|
||||
});
|
||||
export const UserConnections = defineTable<UserConnectionRow, 'user_id' | 'connection_type' | 'connection_id'>({
|
||||
name: 'user_connections',
|
||||
columns: USER_CONNECTION_COLUMNS,
|
||||
|
||||
@@ -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' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,19 +93,7 @@ export class MessageEditService {
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
if (data.message_snapshots !== undefined) {
|
||||
const isAuthor = message.authorId === userId;
|
||||
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!isAuthor && !canManage) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
|
||||
this.deps.persistenceService.updateSnapshotAttachments({
|
||||
message,
|
||||
snapshotEdits: data.message_snapshots ?? [],
|
||||
}),
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
return updatedMessage;
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
this.deps.validationService.validateMessageEditable(message);
|
||||
|
||||
@@ -929,9 +929,8 @@ export class MessageSendService {
|
||||
algorithm: 'leaky_bucket',
|
||||
});
|
||||
if (!slowmodeResult.allowed) {
|
||||
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
|
||||
throw new SlowmodeRateLimitError({
|
||||
retryAfter,
|
||||
retryAfter: slowmodeResult.retryAfter,
|
||||
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.id).toBe(firstMessage.id);
|
||||
});
|
||||
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
|
||||
const rateLimitPerUser = 5;
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
|
||||
await updateChannel(harness, owner.token, channel.id, {
|
||||
rate_limit_per_user: rateLimitPerUser,
|
||||
});
|
||||
await sendChannelMessage(harness, member.token, channel.id, 'first message');
|
||||
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'second message'})
|
||||
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
|
||||
.executeWithResponse();
|
||||
const headerRetryAfter = Number(response.headers.get('Retry-After'));
|
||||
expect(Number.isInteger(headerRetryAfter)).toBe(true);
|
||||
expect(headerRetryAfter).toBeGreaterThan(0);
|
||||
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
|
||||
expect(json.retry_after).toBeGreaterThan(0);
|
||||
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
|
||||
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
|
||||
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
|
||||
@@ -150,6 +151,7 @@ export interface APIConfig {
|
||||
static: string;
|
||||
};
|
||||
};
|
||||
s3Downloads: ResolvedDownloadsProvider;
|
||||
email: {
|
||||
enabled: boolean;
|
||||
provider: 'smtp' | 'none';
|
||||
@@ -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;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export type PneumaticPostDeliveryStatus = 'claimed' | 'failed' | 'sent';
|
||||
|
||||
export interface PneumaticPostDeliveryRow {
|
||||
dispatch_key: string;
|
||||
user_id: bigint;
|
||||
status: PneumaticPostDeliveryStatus;
|
||||
claimed_at: Date;
|
||||
sent_at: Date | null;
|
||||
channel_id: bigint | null;
|
||||
message_id: bigint | null;
|
||||
locale: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export const PNEUMATIC_POST_DELIVERY_COLUMNS = [
|
||||
'user_id',
|
||||
'dispatch_key',
|
||||
'status',
|
||||
'claimed_at',
|
||||
'sent_at',
|
||||
'channel_id',
|
||||
'message_id',
|
||||
'locale',
|
||||
'error_message',
|
||||
] as const satisfies ReadonlyArray<keyof PneumaticPostDeliveryRow>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -683,8 +683,6 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
webhookRepository,
|
||||
storageService,
|
||||
avatarService,
|
||||
channelService,
|
||||
userService.channelService,
|
||||
rateLimitService,
|
||||
limitConfigService,
|
||||
kvClient,
|
||||
|
||||
@@ -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')},
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
import {Config} from '../Config';
|
||||
import {mapChannelToResponse} from '../channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '../channel/IChannelRepository';
|
||||
import type {ChannelService} from '../channel/services/ChannelService';
|
||||
import {buildBroadcastMessageData} from '../channel/services/message/MessageGatewayDispatch';
|
||||
import {ensurePersonalNotesChannelExists} from '../channel/services/PersonalNotesChannelRepair';
|
||||
import {mapFavoriteMemeToResponse} from '../favorite_meme/FavoriteMemeModel';
|
||||
@@ -83,12 +82,9 @@ import type {BotAuthService} from '../oauth/BotAuthService';
|
||||
import {sendApnsPush} from '../push/ApnsPushService';
|
||||
import {encodeReadStatesResponseProto, mapReadStateResponse} from '../read_state/ReadStateResponseMapper';
|
||||
import type {ReadStateService} from '../read_state/ReadStateService';
|
||||
import {PneumaticPostRepository} from '../system/PneumaticPostRepository';
|
||||
import {PneumaticPostService} from '../system/PneumaticPostService';
|
||||
import type {IUserRepository} from '../user/IUserRepository';
|
||||
import {PaymentRepository} from '../user/repositories/PaymentRepository';
|
||||
import {CustomStatusValidator} from '../user/services/CustomStatusValidator';
|
||||
import type {UserChannelService} from '../user/services/UserChannelService';
|
||||
import {getCachedUserPartialResponse} from '../user/UserCacheHelpers';
|
||||
import {
|
||||
mapRelationshipToResponse,
|
||||
@@ -247,8 +243,6 @@ export class RpcService {
|
||||
private webhookRepository: IWebhookRepository,
|
||||
private storageService: IStorageService,
|
||||
private avatarService: AvatarService,
|
||||
private channelService: ChannelService,
|
||||
private userChannelService: UserChannelService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly kvClient: IKVProvider,
|
||||
@@ -270,13 +264,6 @@ export class RpcService {
|
||||
gatewayService: this.gatewayService,
|
||||
discriminatorService: this.discriminatorService,
|
||||
paymentRepository: new PaymentRepository(),
|
||||
pneumaticPostService: new PneumaticPostService({
|
||||
repository: new PneumaticPostRepository(),
|
||||
userRepository: this.userRepository,
|
||||
userChannelService: this.userChannelService,
|
||||
channelService: this.channelService,
|
||||
userCacheService: this.userCacheService,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -50,10 +50,6 @@ interface SessionStartDiscriminatorService extends Pick<IDiscriminatorService, '
|
||||
|
||||
interface SessionStartPaymentRepository extends Pick<PaymentRepository, 'hasEverPaidSuccessfully'> {}
|
||||
|
||||
interface SessionStartPneumaticPostService {
|
||||
considerPlutoniumMobileBetaDispatch(user: User, settings: UserData['settings']): Promise<void>;
|
||||
}
|
||||
|
||||
interface SessionStartDeps {
|
||||
userRepository: SessionStartUserRepository;
|
||||
guildRepository: SessionStartGuildRepository;
|
||||
@@ -61,7 +57,6 @@ interface SessionStartDeps {
|
||||
gatewayService: SessionStartGatewayService;
|
||||
discriminatorService: SessionStartDiscriminatorService;
|
||||
paymentRepository: SessionStartPaymentRepository;
|
||||
pneumaticPostService: SessionStartPneumaticPostService;
|
||||
}
|
||||
|
||||
interface ProcessSessionStartParams {
|
||||
@@ -284,9 +279,6 @@ export class RpcSessionStartService {
|
||||
}
|
||||
});
|
||||
}
|
||||
await timings.time('consider_pneumatic_post_dispatches', async () => {
|
||||
await this.deps.pneumaticPostService.considerPlutoniumMobileBetaDispatch(user, userData.settings);
|
||||
});
|
||||
return {user, flagsToUpdate, timings: timings.finalize()};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AllLocales, Locales} from '@fluxer/constants/src/Locales';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH,
|
||||
resolvePlutoniumMobileBetaCorrectionBody,
|
||||
resolvePlutoniumMobileBetaDispatchBody,
|
||||
} from './PneumaticPostNotices';
|
||||
|
||||
const TEST_USER_ID = '123456789012345678';
|
||||
|
||||
const FIXED_LATIN_PRODUCT_NAMES = [
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.productName,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.premiumProductName,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.androidProductName,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.iosProductName,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.githubProductName,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.testFlightProductName,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.appleProductName,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH.appStoreProductName,
|
||||
];
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
describe('PneumaticPostNotices', () => {
|
||||
test.each(AllLocales)('renders the Plutonium mobile beta dispatch for %s', (locale) => {
|
||||
const body = resolvePlutoniumMobileBetaDispatchBody(locale, TEST_USER_ID);
|
||||
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.productName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.premiumProductName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.androidProductName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.iosProductName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.githubUrl);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.formUrl);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.testFlightProductName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.appleProductName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.appStoreProductName);
|
||||
expect(body).toContain(TEST_USER_ID);
|
||||
expect(body).not.toMatch(/\{\{[a-z0-9_]+}}/);
|
||||
expect(body.split('\n\n')).toHaveLength(8);
|
||||
expect(body).not.toMatch(/\]\([^)]*\)[A-Za-zÀ-ž]/u);
|
||||
|
||||
for (const productName of FIXED_LATIN_PRODUCT_NAMES) {
|
||||
expect(body).not.toMatch(new RegExp(`${escapeRegExp(productName)}[A-Za-zÀ-ž]`, 'u'));
|
||||
}
|
||||
});
|
||||
|
||||
test.each(AllLocales)('renders the Plutonium mobile beta correction for %s', (locale) => {
|
||||
const body = resolvePlutoniumMobileBetaCorrectionBody(locale, TEST_USER_ID);
|
||||
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.productName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.androidProductName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.iosProductName);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.formUrl);
|
||||
expect(body).toContain(PLUTONIUM_MOBILE_BETA_DISPATCH.appStoreProductName);
|
||||
expect(body).toContain(TEST_USER_ID);
|
||||
expect(body).not.toMatch(/\{\{[a-z0-9_]+}}/);
|
||||
expect(body.split('\n\n')).toHaveLength(5);
|
||||
expect(body).not.toMatch(/\]\([^)]*\)[A-Za-zÀ-ž]/u);
|
||||
});
|
||||
|
||||
test('uses the English template for English locales and unsupported locales', () => {
|
||||
const english = resolvePlutoniumMobileBetaDispatchBody(Locales.EN_US, TEST_USER_ID);
|
||||
|
||||
expect(resolvePlutoniumMobileBetaDispatchBody(Locales.EN_GB, TEST_USER_ID)).toBe(english);
|
||||
expect(resolvePlutoniumMobileBetaDispatchBody('not-a-locale', TEST_USER_ID)).toBe(english);
|
||||
});
|
||||
|
||||
test.each(
|
||||
AllLocales.filter((locale) => locale !== Locales.EN_US && locale !== Locales.EN_GB),
|
||||
)('uses a localized template for %s', (locale) => {
|
||||
expect(resolvePlutoniumMobileBetaDispatchBody(locale, TEST_USER_ID)).not.toBe(
|
||||
resolvePlutoniumMobileBetaDispatchBody(Locales.EN_US, TEST_USER_ID),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,879 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type LocaleCode, Locales} from '@fluxer/constants/src/Locales';
|
||||
|
||||
export const PNEUMATIC_POST_SYSTEM_NAME = 'Pneumatic Post';
|
||||
|
||||
export const PLUTONIUM_MOBILE_BETA_DISPATCH = {
|
||||
key: 'plutonium_mobile_beta_2026_06',
|
||||
productName: 'Fluxer',
|
||||
premiumProductName: 'Plutonium',
|
||||
androidProductName: 'Android',
|
||||
iosProductName: 'iOS',
|
||||
githubProductName: 'GitHub',
|
||||
githubUrl: 'https://github.com/fluxerapp/flutter_client',
|
||||
formUrl: 'https://forms.gle/xDZSdJ3LWuYm2wde6',
|
||||
testFlightProductName: 'TestFlight',
|
||||
appleProductName: 'Apple',
|
||||
appStoreProductName: 'Apple App Store',
|
||||
} as const;
|
||||
|
||||
export const PLUTONIUM_MOBILE_BETA_CORRECTION_DISPATCH = {
|
||||
key: 'plutonium_mobile_beta_2026_06_mobile_community_correction',
|
||||
} as const;
|
||||
|
||||
const FORM_LINK_LABELS: Partial<Record<LocaleCode, string>> = {
|
||||
[Locales.AR]: 'هنا',
|
||||
[Locales.BG]: 'тук',
|
||||
[Locales.CS]: 'zde',
|
||||
[Locales.DA]: 'her',
|
||||
[Locales.DE]: 'hier',
|
||||
[Locales.EL]: 'εδώ',
|
||||
[Locales.EN_GB]: 'here',
|
||||
[Locales.EN_US]: 'here',
|
||||
[Locales.ES_ES]: 'aquí',
|
||||
[Locales.ES_419]: 'aquí',
|
||||
[Locales.FI]: 'täällä',
|
||||
[Locales.FR]: 'ici',
|
||||
[Locales.HE]: 'כאן',
|
||||
[Locales.HI]: 'यहाँ',
|
||||
[Locales.HR]: 'ovdje',
|
||||
[Locales.HU]: 'itt',
|
||||
[Locales.ID]: 'di sini',
|
||||
[Locales.IT]: 'qui',
|
||||
[Locales.JA]: 'こちら',
|
||||
[Locales.KO]: '여기',
|
||||
[Locales.LT]: 'čia',
|
||||
[Locales.NL]: 'hier',
|
||||
[Locales.NO]: 'her',
|
||||
[Locales.PL]: 'tutaj',
|
||||
[Locales.PT_BR]: 'aqui',
|
||||
[Locales.RO]: 'aici',
|
||||
[Locales.RU]: 'здесь',
|
||||
[Locales.SV_SE]: 'här',
|
||||
[Locales.TH]: 'ที่นี่',
|
||||
[Locales.TR]: 'buradan',
|
||||
[Locales.UK]: 'тут',
|
||||
[Locales.VI]: 'tại đây',
|
||||
[Locales.ZH_CN]: '这里',
|
||||
[Locales.ZH_TW]: '此處',
|
||||
};
|
||||
|
||||
function markdownFormLink(label: string): string {
|
||||
return `[${label}](${PLUTONIUM_MOBILE_BETA_DISPATCH.formUrl})`;
|
||||
}
|
||||
|
||||
function getFormLink(locale: LocaleCode | string | null | undefined): string {
|
||||
return markdownFormLink(FORM_LINK_LABELS[locale as LocaleCode] ?? 'here');
|
||||
}
|
||||
|
||||
function buildTokenValues(locale: LocaleCode | string | null | undefined, userId: string): Record<string, string> {
|
||||
return {
|
||||
product_name: PLUTONIUM_MOBILE_BETA_DISPATCH.productName,
|
||||
premium_name: PLUTONIUM_MOBILE_BETA_DISPATCH.premiumProductName,
|
||||
android_name: PLUTONIUM_MOBILE_BETA_DISPATCH.androidProductName,
|
||||
ios_name: PLUTONIUM_MOBILE_BETA_DISPATCH.iosProductName,
|
||||
github_name: PLUTONIUM_MOBILE_BETA_DISPATCH.githubProductName,
|
||||
github_link: `[${PLUTONIUM_MOBILE_BETA_DISPATCH.githubProductName}](${PLUTONIUM_MOBILE_BETA_DISPATCH.githubUrl})`,
|
||||
form_link: getFormLink(locale),
|
||||
form_link_here: markdownFormLink('here'),
|
||||
form_link_ar: markdownFormLink('هنا'),
|
||||
form_link_bg: markdownFormLink('тук'),
|
||||
form_link_cs: markdownFormLink('zde'),
|
||||
form_link_da: markdownFormLink('her'),
|
||||
form_link_de: markdownFormLink('hier'),
|
||||
form_link_el: markdownFormLink('εδώ'),
|
||||
form_link_es: markdownFormLink('aquí'),
|
||||
form_link_fi: markdownFormLink('täällä'),
|
||||
form_link_fr: markdownFormLink('ici'),
|
||||
form_link_he: markdownFormLink('כאן'),
|
||||
form_link_hi: markdownFormLink('यहाँ'),
|
||||
form_link_hr: markdownFormLink('ovdje'),
|
||||
form_link_hu: markdownFormLink('itt'),
|
||||
form_link_id: markdownFormLink('di sini'),
|
||||
form_link_it: markdownFormLink('qui'),
|
||||
form_link_ja: markdownFormLink('こちら'),
|
||||
form_link_ko: markdownFormLink('여기'),
|
||||
form_link_lt: markdownFormLink('čia'),
|
||||
form_link_nl: markdownFormLink('hier'),
|
||||
form_link_no: markdownFormLink('her'),
|
||||
form_link_pl: markdownFormLink('tutaj'),
|
||||
form_link_pt_br: markdownFormLink('aqui'),
|
||||
form_link_ro: markdownFormLink('aici'),
|
||||
form_link_ru: markdownFormLink('здесь'),
|
||||
form_link_sv: markdownFormLink('här'),
|
||||
form_link_th: markdownFormLink('ที่นี่'),
|
||||
form_link_tr: markdownFormLink('buradan'),
|
||||
form_link_uk: markdownFormLink('тут'),
|
||||
form_link_vi: markdownFormLink('tại đây'),
|
||||
form_link_zh_cn: markdownFormLink('这里'),
|
||||
form_link_zh_tw: markdownFormLink('此處'),
|
||||
testflight_name: PLUTONIUM_MOBILE_BETA_DISPATCH.testFlightProductName,
|
||||
apple_name: PLUTONIUM_MOBILE_BETA_DISPATCH.appleProductName,
|
||||
app_store_name: PLUTONIUM_MOBILE_BETA_DISPATCH.appStoreProductName,
|
||||
user_id: userId,
|
||||
};
|
||||
}
|
||||
|
||||
const ENGLISH_TEMPLATE = `Calling all {{premium_name}} subscribers!
|
||||
|
||||
Thank you for helping make {{product_name}} happen. We are excited to announce the launch of our mobile app beta. The beta is open to all {{android_name}} users and to {{premium_name}} members on {{ios_name}}.
|
||||
|
||||
Our mobile app will become available on 15 June 2026. When we launch, {{android_name}} users will be able to download the APK from {{github_link}}, and {{ios_name}} users will be able to access it through {{apple_name}}'s {{testflight_name}}.
|
||||
|
||||
If you are an {{ios_name}} user, please submit your {{app_store_name}} email address {{form_link_here}}. The app will only work with your account during the beta.
|
||||
|
||||
{{premium_name}} members will also be able to join our mobile community. During the beta, this will be our hub for bugs, feature requests, UX changes, and everything in between. If you would like to be added, please let us know in the form above.
|
||||
|
||||
As a reminder, this is beta software. Many features will not be present yet, and you should expect bugs. Your feedback and help in finding bugs will help us improve quickly.
|
||||
|
||||
Thank you once again,
|
||||
The {{product_name}} Team`;
|
||||
|
||||
const LOCALIZED_TEMPLATES: Partial<Record<LocaleCode, string>> = {
|
||||
[Locales.AR]: `نداء إلى جميع مشتركي {{premium_name}}!
|
||||
|
||||
شكرًا لكم على المساعدة في جعل {{product_name}} واقعًا. يسعدنا الإعلان عن إطلاق النسخة التجريبية من تطبيقنا للهواتف المحمولة. النسخة التجريبية متاحة لجميع مستخدمي {{android_name}} ولمشتركي {{premium_name}} على {{ios_name}}.
|
||||
|
||||
سيصبح تطبيقنا للهواتف المحمولة متاحًا في 15 يونيو 2026. عند الإطلاق، سيتمكن مستخدمو {{android_name}} من تنزيل ملف APK من {{github_link}}، وسيتمكن مستخدمو {{ios_name}} من الوصول إليه عبر {{testflight_name}} من {{apple_name}}.
|
||||
|
||||
إذا كنت من مستخدمي {{ios_name}}، يُرجى إرسال عنوان بريدك الإلكتروني المستخدم في {{app_store_name}} {{form_link_ar}}. سيعمل التطبيق فقط مع حسابك خلال فترة النسخة التجريبية.
|
||||
|
||||
سيتمكن أعضاء {{premium_name}} أيضًا من الانضمام إلى مجتمعنا الخاص بتطبيق الهاتف المحمول. خلال فترة النسخة التجريبية، سيكون هذا المجتمع مركزنا للإبلاغ عن الأخطاء وطلبات الميزات وتغييرات تجربة المستخدم وكل ما يتعلق بذلك. إذا كنت ترغب في إضافتك، فيُرجى إعلامنا عبر النموذج أعلاه.
|
||||
|
||||
نذكّركم بأن هذا البرنامج لا يزال في مرحلة تجريبية. لن تتوفر العديد من الميزات بعد، ويجب أن تتوقعوا وجود أخطاء. ستساعدنا ملاحظاتكم ومساعدتكم في العثور على الأخطاء على التحسين بسرعة.
|
||||
|
||||
شكرًا لكم مرة أخرى،
|
||||
فريق {{product_name}}`,
|
||||
[Locales.BG]: `До всички абонати на {{premium_name}}!
|
||||
|
||||
Благодарим ви, че помогнахте {{product_name}} да стане реалност. Радваме се да обявим старта на бета версията на нашето мобилно приложение. Бета версията е отворена за всички потребители на {{android_name}} и за членовете на {{premium_name}} на {{ios_name}}.
|
||||
|
||||
Мобилното ни приложение ще бъде достъпно на 15 юни 2026 г. При старта потребителите на {{android_name}} ще могат да изтеглят APK файла от {{github_link}}, а потребителите на {{ios_name}} ще имат достъп до приложението чрез {{testflight_name}} на {{apple_name}}.
|
||||
|
||||
Ако използвате {{ios_name}}, моля, изпратете имейл адреса, който използвате за {{app_store_name}}, {{form_link_bg}}. По време на бетата приложението ще работи само с вашия акаунт.
|
||||
|
||||
Членовете на {{premium_name}} също ще могат да се присъединят към нашата мобилна общност. По време на бетата тя ще бъде мястото за съобщаване на бъгове, заявки за нови функции, промени в потребителското изживяване и всичко между тях. Ако искате да бъдете добавени, моля, уведомете ни чрез формуляра по-горе.
|
||||
|
||||
Напомняме, че това е бета софтуер. Много функции все още няма да са налични и трябва да очаквате бъгове. Вашата обратна връзка и помощта ви при намирането на бъгове ще ни помогнат да подобряваме приложението бързо.
|
||||
|
||||
Още веднъж ви благодарим,
|
||||
Екипът на {{product_name}}`,
|
||||
[Locales.CS]: `Voláme všechny předplatitele {{premium_name}}!
|
||||
|
||||
Děkujeme, že pomáháte uskutečnit {{product_name}}. S radostí oznamujeme spuštění beta verze naší mobilní aplikace. Beta je otevřená všem uživatelům systému {{android_name}} a členům {{premium_name}} na {{ios_name}}.
|
||||
|
||||
Naše mobilní aplikace bude dostupná 15. června 2026. Po spuštění si uživatelé systému {{android_name}} budou moci stáhnout APK z {{github_link}} a uživatelé {{ios_name}} k ní získají přístup přes {{testflight_name}} od {{apple_name}}.
|
||||
|
||||
Pokud používáte {{ios_name}}, odešlete prosím svou e-mailovou adresu používanou pro {{app_store_name}} {{form_link_cs}}. Během bety bude aplikace fungovat pouze s vaším účtem.
|
||||
|
||||
Členové {{premium_name}} se také budou moci připojit k naší mobilní komunitě. Během bety bude sloužit jako naše centrum pro hlášení chyb, požadavky na funkce, změny v uživatelském prostředí a vše ostatní. Pokud chcete být přidáni, dejte nám prosím vědět ve výše uvedeném formuláři.
|
||||
|
||||
Připomínáme, že jde o beta software. Mnoho funkcí zatím nebude k dispozici a je třeba počítat s chybami. Vaše zpětná vazba a pomoc s jejich hledáním nám pomohou rychle se zlepšovat.
|
||||
|
||||
Ještě jednou děkujeme,
|
||||
Tým {{product_name}}`,
|
||||
[Locales.DA]: `Til alle {{premium_name}}-abonnenter!
|
||||
|
||||
Tak, fordi I hjælper med at gøre {{product_name}} til virkelighed. Vi er glade for at kunne annoncere lanceringen af betaen til vores mobilapp. Betaen er åben for alle {{android_name}}-brugere og for {{premium_name}}-medlemmer på {{ios_name}}.
|
||||
|
||||
Vores mobilapp bliver tilgængelig den 15. juni 2026. Ved lanceringen vil {{android_name}}-brugere kunne downloade APK'en fra {{github_link}}, og {{ios_name}}-brugere vil kunne få adgang til den via {{testflight_name}} fra {{apple_name}}.
|
||||
|
||||
Hvis du bruger {{ios_name}}, bedes du indsende den e-mailadresse, du bruger til {{app_store_name}}, {{form_link_da}}. Appen vil kun fungere med din konto under betaen.
|
||||
|
||||
{{premium_name}}-medlemmer vil også kunne deltage i vores mobilfællesskab. Under betaen bliver det vores samlingspunkt for fejl, funktionsønsker, ændringer til brugeroplevelsen og alt derimellem. Hvis du gerne vil tilføjes, så giv os besked i formularen ovenfor.
|
||||
|
||||
Som en påmindelse er dette betasoftware. Mange funktioner vil endnu ikke være tilgængelige, og du skal forvente fejl. Din feedback og hjælp med at finde fejl vil hjælpe os med at forbedre appen hurtigt.
|
||||
|
||||
Endnu en gang tak,
|
||||
{{product_name}}-teamet`,
|
||||
[Locales.DE]: `Aufruf an alle {{premium_name}}-Abonnenten!
|
||||
|
||||
Vielen Dank, dass ihr dabei helft, {{product_name}} möglich zu machen. Wir freuen uns, den Start der Beta unserer mobilen App anzukündigen. Die Beta steht allen {{android_name}}-Nutzern sowie {{premium_name}}-Mitgliedern auf {{ios_name}} offen.
|
||||
|
||||
Unsere mobile App wird ab dem 15. Juni 2026 verfügbar sein. Zum Start können {{android_name}}-Nutzer die APK über {{github_link}} herunterladen, während {{ios_name}}-Nutzer über {{testflight_name}} von {{apple_name}} Zugriff erhalten.
|
||||
|
||||
Wenn du {{ios_name}} nutzt, reiche bitte {{form_link_de}} die E-Mail-Adresse ein, die du für den {{app_store_name}} verwendest. Während der Beta funktioniert die App nur mit deinem Konto.
|
||||
|
||||
{{premium_name}}-Mitglieder können außerdem unserer mobilen Community beitreten. Während der Beta ist sie unsere zentrale Anlaufstelle für Bugs, Feature-Wünsche, Änderungen an der Benutzererfahrung und alles dazwischen. Wenn du hinzugefügt werden möchtest, lass es uns bitte im oben genannten Formular wissen.
|
||||
|
||||
Zur Erinnerung: Es handelt sich um Beta-Software. Viele Funktionen werden noch nicht verfügbar sein, und du solltest mit Fehlern rechnen. Dein Feedback und deine Hilfe beim Finden von Bugs werden uns dabei helfen, uns schnell zu verbessern.
|
||||
|
||||
Nochmals vielen Dank,
|
||||
Das {{product_name}}-Team`,
|
||||
[Locales.EL]: `Κάλεσμα σε όλους τους συνδρομητές {{premium_name}}!
|
||||
|
||||
Σας ευχαριστούμε που βοηθήσατε να γίνει το {{product_name}} πραγματικότητα. Με χαρά ανακοινώνουμε την κυκλοφορία της beta έκδοσης της εφαρμογής μας για κινητά. Η beta είναι διαθέσιμη σε όλους τους χρήστες {{android_name}} και στα μέλη {{premium_name}} στο {{ios_name}}.
|
||||
|
||||
Η εφαρμογή μας για κινητά θα γίνει διαθέσιμη στις 15 Ιουνίου 2026. Με την κυκλοφορία της, οι χρήστες {{android_name}} θα μπορούν να κατεβάσουν το APK από το {{github_link}}, ενώ οι χρήστες {{ios_name}} θα μπορούν να αποκτήσουν πρόσβαση μέσω του {{testflight_name}} της {{apple_name}}.
|
||||
|
||||
Αν είστε χρήστης {{ios_name}}, παρακαλούμε υποβάλετε τη διεύθυνση email που χρησιμοποιείτε για το {{app_store_name}} {{form_link_el}}. Κατά τη διάρκεια της beta, η εφαρμογή θα λειτουργεί μόνο με τον λογαριασμό σας.
|
||||
|
||||
Τα μέλη {{premium_name}} θα μπορούν επίσης να συμμετάσχουν στην κοινότητά μας για την εφαρμογή για κινητά. Κατά τη διάρκεια της beta, αυτή θα είναι ο κεντρικός μας χώρος για σφάλματα, αιτήματα λειτουργιών, αλλαγές στην εμπειρία χρήστη και οτιδήποτε άλλο. Αν θέλετε να προστεθείτε, ενημερώστε μας μέσω της παραπάνω φόρμας.
|
||||
|
||||
Υπενθυμίζουμε ότι πρόκειται για beta λογισμικό. Πολλές λειτουργίες δεν θα είναι ακόμη διαθέσιμες και θα πρέπει να περιμένετε σφάλματα. Τα σχόλιά σας και η βοήθειά σας στον εντοπισμό σφαλμάτων θα μας βοηθήσουν να βελτιωθούμε γρήγορα.
|
||||
|
||||
Σας ευχαριστούμε ξανά,
|
||||
Η ομάδα του {{product_name}}`,
|
||||
[Locales.ES_ES]: `¡Llamamiento a todos los suscriptores de {{premium_name}}!
|
||||
|
||||
Gracias por ayudar a hacer realidad {{product_name}}. Nos alegra anunciar el lanzamiento de la beta de nuestra aplicación móvil. La beta está abierta a todos los usuarios de {{android_name}} y a los miembros de {{premium_name}} en {{ios_name}}.
|
||||
|
||||
Nuestra aplicación móvil estará disponible el 15 de junio de 2026. En el lanzamiento, los usuarios de {{android_name}} podrán descargar el APK desde {{github_link}}, y los usuarios de {{ios_name}} podrán acceder a ella a través de {{testflight_name}} de {{apple_name}}.
|
||||
|
||||
Si usas {{ios_name}}, envía la dirección de correo electrónico que utilizas para el {{app_store_name}} {{form_link_es}}. Durante la beta, la aplicación solo funcionará con tu cuenta.
|
||||
|
||||
Los miembros de {{premium_name}} también podrán unirse a nuestra comunidad móvil. Durante la beta, será nuestro punto de encuentro para errores, solicitudes de funciones, cambios de experiencia de usuario y todo lo demás. Si quieres que te añadamos, indícanoslo en el formulario anterior.
|
||||
|
||||
Como recordatorio, se trata de software en fase beta. Muchas funciones todavía no estarán disponibles y debes esperar que haya errores. Tus comentarios y tu ayuda para encontrar errores nos ayudarán a mejorar rápidamente.
|
||||
|
||||
Gracias de nuevo,
|
||||
El equipo de {{product_name}}`,
|
||||
[Locales.ES_419]: `¡Atención, suscriptores de {{premium_name}}!
|
||||
|
||||
Gracias por ayudar a hacer realidad {{product_name}}. Nos emociona anunciar el lanzamiento de la beta de nuestra app móvil. La beta está abierta a todos los usuarios de {{android_name}} y a los miembros de {{premium_name}} en {{ios_name}}.
|
||||
|
||||
Nuestra app móvil estará disponible el 15 de junio de 2026. En el lanzamiento, los usuarios de {{android_name}} podrán descargar el APK desde {{github_link}}, y los usuarios de {{ios_name}} podrán acceder a ella a través de {{testflight_name}} de {{apple_name}}.
|
||||
|
||||
Si usas {{ios_name}}, envía la dirección de correo electrónico que usas para {{app_store_name}} {{form_link_es}}. Durante la beta, la app solo funcionará con tu cuenta.
|
||||
|
||||
Los miembros de {{premium_name}} también podrán unirse a nuestra comunidad móvil. Durante la beta, será nuestro espacio central para reportar errores, solicitar funciones, proponer cambios en la experiencia de usuario y todo lo demás. Si quieres que te agreguemos, avísanos en el formulario anterior.
|
||||
|
||||
Recuerda que este es software beta. Muchas funciones todavía no estarán disponibles y es posible que encuentres errores. Tus comentarios y tu ayuda para encontrarlos nos ayudarán a mejorar rápidamente.
|
||||
|
||||
Gracias de nuevo,
|
||||
El equipo de {{product_name}}`,
|
||||
[Locales.FI]: `Huomio kaikki {{premium_name}}-tilaajat!
|
||||
|
||||
Kiitos, että olette auttaneet tekemään {{product_name}}-palvelusta totta. Olemme iloisia voidessamme ilmoittaa mobiilisovelluksemme betaversion julkaisusta. Beta on avoin kaikille {{android_name}}-käyttäjille sekä {{premium_name}}-jäsenille {{ios_name}}:ssä.
|
||||
|
||||
Mobiilisovelluksemme tulee saataville 15. kesäkuuta 2026. Julkaisun yhteydessä {{android_name}}-käyttäjät voivat ladata APK:n osoitteesta {{github_link}}, ja {{ios_name}}-käyttäjät pääsevät käyttämään sovellusta {{apple_name}}-yhtiön {{testflight_name}}-palvelun kautta.
|
||||
|
||||
Jos käytät {{ios_name}}:ää, lähetä palvelussa {{app_store_name}} käyttämäsi sähköpostiosoite {{form_link_fi}}. Betan aikana sovellus toimii vain omalla tililläsi.
|
||||
|
||||
{{premium_name}}-jäsenet voivat myös liittyä mobiiliyhteisöömme. Betan aikana se toimii keskuksenamme bugeille, ominaisuuspyynnöille, käyttökokemukseen liittyville muutoksille ja kaikelle siltä väliltä. Jos haluat tulla lisätyksi, ilmoita siitä meille yllä olevalla lomakkeella.
|
||||
|
||||
Muistutuksena: kyseessä on betaohjelmisto. Monet ominaisuudet eivät ole vielä saatavilla, ja bugeja on odotettavissa. Palautteesi ja apusi bugien löytämisessä auttavat meitä parantamaan sovellusta nopeasti.
|
||||
|
||||
Kiitos vielä kerran,
|
||||
{{product_name}}-tiimi`,
|
||||
[Locales.FR]: `À tous les abonnés {{premium_name}} !
|
||||
|
||||
Merci de nous aider à rendre {{product_name}} possible. Nous sommes ravis d’annoncer le lancement de la bêta de notre application mobile. La bêta est ouverte à tous les utilisateurs {{android_name}} ainsi qu’aux membres {{premium_name}} sur {{ios_name}}.
|
||||
|
||||
Notre application mobile sera disponible le 15 juin 2026. Au lancement, les utilisateurs {{android_name}} pourront télécharger l’APK depuis {{github_link}}, et les utilisateurs {{ios_name}} pourront y accéder via {{testflight_name}} d’{{apple_name}}.
|
||||
|
||||
Si vous utilisez {{ios_name}}, veuillez envoyer l’adresse e-mail que vous utilisez pour l’{{app_store_name}} {{form_link_fr}}. Pendant la bêta, l’application ne fonctionnera qu’avec votre compte.
|
||||
|
||||
Les membres {{premium_name}} pourront également rejoindre notre communauté mobile. Pendant la bêta, ce sera notre espace central pour les bugs, les demandes de fonctionnalités, les changements liés à l’expérience utilisateur et tout le reste. Si vous souhaitez être ajouté, faites-le-nous savoir dans le formulaire ci-dessus.
|
||||
|
||||
Pour rappel, il s’agit d’un logiciel en version bêta. De nombreuses fonctionnalités ne seront pas encore présentes, et vous devez vous attendre à rencontrer des bugs. Vos retours et votre aide pour trouver les bugs nous aideront à nous améliorer rapidement.
|
||||
|
||||
Encore merci,
|
||||
L’équipe {{product_name}}`,
|
||||
[Locales.HE]: `לכל מנויי {{premium_name}}!
|
||||
|
||||
תודה שאתם עוזרים להפוך את {{product_name}} למציאות. אנחנו שמחים להכריז על השקת גרסת הבטא של אפליקציית המובייל שלנו. הבטא פתוחה לכל משתמשי {{android_name}} ולחברי {{premium_name}} ב-{{ios_name}}.
|
||||
|
||||
אפליקציית המובייל שלנו תהיה זמינה ב-15 ביוני 2026. עם ההשקה, משתמשי {{android_name}} יוכלו להוריד את קובץ ה-APK מ-{{github_link}}, ומשתמשי {{ios_name}} יוכלו לגשת אליה דרך {{testflight_name}} של {{apple_name}}.
|
||||
|
||||
אם אתם משתמשים ב-{{ios_name}}, אנא שלחו את כתובת הדוא״ל שבה אתם משתמשים עבור {{app_store_name}} {{form_link_he}}. במהלך הבטא, האפליקציה תפעל רק עם החשבון שלכם.
|
||||
|
||||
חברי {{premium_name}} יוכלו גם להצטרף לקהילת המובייל שלנו. במהלך הבטא, היא תהיה המרכז שלנו לדיווח על באגים, בקשות לפיצ׳רים, שינויים בחוויית המשתמש וכל מה שביניהם. אם תרצו שנוסיף אתכם, אנא הודיעו לנו בטופס שלמעלה.
|
||||
|
||||
נזכיר שמדובר בתוכנת בטא. תכונות רבות עדיין לא יהיו זמינות, ויש לצפות לבאגים. המשוב שלכם והעזרה שלכם באיתור באגים יעזרו לנו להשתפר במהירות.
|
||||
|
||||
שוב תודה,
|
||||
צוות {{product_name}}`,
|
||||
[Locales.HI]: `{{premium_name}} के सभी सब्सक्राइबर ध्यान दें!
|
||||
|
||||
{{product_name}} को संभव बनाने में मदद करने के लिए धन्यवाद। हमें अपने मोबाइल ऐप बीटा के लॉन्च की घोषणा करते हुए खुशी हो रही है। बीटा सभी {{android_name}} उपयोगकर्ताओं और {{ios_name}} पर {{premium_name}} सदस्यों के लिए उपलब्ध है।
|
||||
|
||||
हमारा मोबाइल ऐप 15 जून 2026 को उपलब्ध होगा। लॉन्च के समय, {{android_name}} उपयोगकर्ता {{github_link}} से APK डाउनलोड कर सकेंगे, और {{ios_name}} उपयोगकर्ता {{apple_name}} के {{testflight_name}} के ज़रिए ऐप एक्सेस कर सकेंगे।
|
||||
|
||||
यदि आप {{ios_name}} उपयोगकर्ता हैं, तो कृपया अपना {{app_store_name}} ईमेल पता {{form_link_hi}} सबमिट करें। बीटा के दौरान ऐप केवल आपके खाते के साथ ही काम करेगा।
|
||||
|
||||
{{premium_name}} सदस्य हमारे मोबाइल समुदाय में भी शामिल हो सकेंगे। बीटा के दौरान, यह बग्स, फीचर अनुरोधों, UX बदलावों और बाकी सभी बातों के लिए हमारा मुख्य केंद्र होगा। यदि आप जोड़े जाना चाहते हैं, तो कृपया ऊपर दिए गए फ़ॉर्म में हमें बताएं।
|
||||
|
||||
याद रखें, यह बीटा सॉफ़्टवेयर है। कई फीचर्स अभी उपलब्ध नहीं होंगे, और आपको बग्स मिलने की उम्मीद रखनी चाहिए। आपका फ़ीडबैक और बग्स खोजने में आपकी मदद हमें तेज़ी से सुधार करने में मदद करेगी।
|
||||
|
||||
एक बार फिर धन्यवाद,
|
||||
{{product_name}} टीम`,
|
||||
[Locales.HR]: `Poziv svim pretplatnicima programa {{premium_name}}!
|
||||
|
||||
Hvala vam što pomažete da {{product_name}} postane stvarnost. S veseljem najavljujemo pokretanje beta verzije naše mobilne aplikacije. Beta je otvorena za sve korisnike sustava {{android_name}} i za članove programa {{premium_name}} na platformi {{ios_name}}.
|
||||
|
||||
Naša mobilna aplikacija bit će dostupna 15. lipnja 2026. Pri pokretanju će korisnici sustava {{android_name}} moći preuzeti APK s {{github_link}}, a korisnici platforme {{ios_name}} moći će joj pristupiti putem servisa {{testflight_name}} od {{apple_name}}.
|
||||
|
||||
Ako ste korisnik platforme {{ios_name}}, pošaljite svoju adresu e-pošte za {{app_store_name}} {{form_link_hr}}. Tijekom bete aplikacija će raditi samo s vašim računom.
|
||||
|
||||
Članovi programa {{premium_name}} također će se moći pridružiti našoj mobilnoj zajednici. Tijekom bete to će biti naše središnje mjesto za bugove, zahtjeve za značajke, promjene korisničkog iskustva i sve ostalo. Ako želite biti dodani, obavijestite nas putem gore navedenog obrasca.
|
||||
|
||||
Podsjećamo, ovo je beta softver. Mnoge značajke još neće biti dostupne i trebate očekivati bugove. Vaše povratne informacije i pomoć u pronalaženju bugova pomoći će nam da se brzo poboljšavamo.
|
||||
|
||||
Još jednom hvala,
|
||||
{{product_name}} tim`,
|
||||
[Locales.HU]: `Figyelem, {{premium_name}}-előfizetők!
|
||||
|
||||
Köszönjük, hogy segítetek abban, hogy a {{product_name}} valósággá váljon. Örömmel jelentjük be mobilalkalmazásunk bétaverziójának indulását. A béta minden {{android_name}}-felhasználó, valamint az {{ios_name}}-t használó {{premium_name}}-tagok számára elérhető.
|
||||
|
||||
Mobilalkalmazásunk 2026. június 15-én válik elérhetővé. Az induláskor az {{android_name}}-felhasználók innen tölthetik le az APK-t: {{github_link}}, az {{ios_name}}-felhasználók pedig a {{testflight_name}} szolgáltatáson keresztül férhetnek hozzá az {{apple_name}} jóvoltából.
|
||||
|
||||
Ha {{ios_name}}-t használsz, kérjük, add meg az {{app_store_name}} szolgáltatásban használt e-mail-címedet {{form_link_hu}}. A béta ideje alatt az alkalmazás csak a te fiókoddal fog működni.
|
||||
|
||||
A {{premium_name}}-tagok csatlakozhatnak mobilos közösségünkhöz is. A béta ideje alatt ez lesz a központi helyünk a hibák, funkciókérések, UX-módosítások és minden egyéb téma számára. Ha szeretnéd, hogy hozzáadjunk, jelezd a fenti űrlapon.
|
||||
|
||||
Emlékeztetőül: ez bétaszoftver. Sok funkció még nem lesz elérhető, és hibákra is számítani kell. A visszajelzéseitek és a hibák megtalálásában nyújtott segítségetek segít nekünk gyorsan fejlődni.
|
||||
|
||||
Még egyszer köszönjük,
|
||||
A {{product_name}} csapata`,
|
||||
[Locales.ID]: `Untuk semua pelanggan {{premium_name}}!
|
||||
|
||||
Terima kasih telah membantu mewujudkan {{product_name}}. Kami senang mengumumkan peluncuran beta aplikasi seluler kami. Beta ini terbuka untuk semua pengguna {{android_name}} dan anggota {{premium_name}} di {{ios_name}}.
|
||||
|
||||
Aplikasi seluler kami akan tersedia pada 15 Juni 2026. Saat peluncuran, pengguna {{android_name}} dapat mengunduh APK dari {{github_link}}, dan pengguna {{ios_name}} dapat mengaksesnya melalui {{testflight_name}} dari {{apple_name}}.
|
||||
|
||||
Jika Anda pengguna {{ios_name}}, harap kirimkan alamat email {{app_store_name}} Anda {{form_link_id}}. Selama beta, aplikasi hanya akan berfungsi dengan akun Anda.
|
||||
|
||||
Anggota {{premium_name}} juga dapat bergabung dengan komunitas seluler kami. Selama beta, komunitas ini akan menjadi pusat kami untuk bug, permintaan fitur, perubahan UX, dan berbagai hal lainnya. Jika Anda ingin ditambahkan, beri tahu kami melalui formulir di atas.
|
||||
|
||||
Sebagai pengingat, ini adalah perangkat lunak beta. Banyak fitur belum akan tersedia, dan Anda sebaiknya mengantisipasi adanya bug. Masukan Anda dan bantuan Anda dalam menemukan bug akan membantu kami meningkatkan aplikasi dengan cepat.
|
||||
|
||||
Terima kasih sekali lagi,
|
||||
Tim {{product_name}}`,
|
||||
[Locales.IT]: `A tutti gli abbonati {{premium_name}}!
|
||||
|
||||
Grazie per aver contribuito a rendere {{product_name}} realtà. Siamo felici di annunciare il lancio della beta della nostra app mobile. La beta è aperta a tutti gli utenti {{android_name}} e ai membri {{premium_name}} su {{ios_name}}.
|
||||
|
||||
La nostra app mobile sarà disponibile dal 15 giugno 2026. Al lancio, gli utenti {{android_name}} potranno scaricare l’APK da {{github_link}}, mentre gli utenti {{ios_name}} potranno accedervi tramite {{testflight_name}} di {{apple_name}}.
|
||||
|
||||
Se usi {{ios_name}}, invia l’indirizzo email che utilizzi per l’{{app_store_name}} {{form_link_it}}. Durante la beta, l’app funzionerà solo con il tuo account.
|
||||
|
||||
I membri {{premium_name}} potranno anche unirsi alla nostra community mobile. Durante la beta, sarà il nostro punto di riferimento per bug, richieste di funzionalità, modifiche all’esperienza utente e tutto il resto. Se vuoi essere aggiunto, faccelo sapere nel modulo qui sopra.
|
||||
|
||||
Ricordiamo che si tratta di software beta. Molte funzionalità non saranno ancora disponibili e dovresti aspettarti la presenza di bug. Il tuo feedback e il tuo aiuto nel trovare bug ci aiuteranno a migliorare rapidamente.
|
||||
|
||||
Grazie ancora,
|
||||
Il team {{product_name}}`,
|
||||
[Locales.JA]: `{{premium_name}}サブスクライバーの皆さまへ
|
||||
|
||||
{{product_name}}の実現にご協力いただき、ありがとうございます。モバイルアプリのベータ版をリリースすることをお知らせします。ベータ版は、すべての{{android_name}}ユーザーと、{{ios_name}}をご利用の{{premium_name}}メンバーが対象です。
|
||||
|
||||
モバイルアプリは2026年6月15日に利用可能になります。リリース時には、{{android_name}}ユーザーは{{github_link}}からAPKをダウンロードでき、{{ios_name}}ユーザーは{{apple_name}}の{{testflight_name}}を通じてアクセスできます。
|
||||
|
||||
{{ios_name}}をご利用の方は、{{app_store_name}}で使用しているメールアドレスを{{form_link_ja}}からご送信ください。ベータ期間中、アプリはお客様のアカウントでのみ動作します。
|
||||
|
||||
{{premium_name}}メンバーは、モバイルコミュニティにも参加できます。ベータ期間中、このコミュニティはバグ、機能リクエスト、UX変更、その他あらゆるフィードバックの窓口となります。追加を希望される場合は、上記のフォームでお知らせください。
|
||||
|
||||
なお、これはベータ版ソフトウェアです。まだ多くの機能は搭載されておらず、不具合が発生する可能性があります。皆さまからのフィードバックやバグ発見へのご協力は、迅速な改善に大きく役立ちます。
|
||||
|
||||
改めてありがとうございます。
|
||||
{{product_name}}チーム`,
|
||||
[Locales.KO]: `{{premium_name}} 구독자 여러분께!
|
||||
|
||||
{{product_name}}를 실현할 수 있도록 도와주셔서 감사합니다. 모바일 앱 베타 출시 소식을 전하게 되어 기쁩니다. 베타는 모든 {{android_name}} 사용자와 {{ios_name}}의 {{premium_name}} 회원에게 열려 있습니다.
|
||||
|
||||
모바일 앱은 2026년 6월 15일에 제공될 예정입니다. 출시 후 {{android_name}} 사용자는 {{github_link}}에서 APK를 다운로드할 수 있으며, {{ios_name}} 사용자는 {{apple_name}}의 {{testflight_name}}를 통해 이용할 수 있습니다.
|
||||
|
||||
{{ios_name}} 사용자라면 {{app_store_name}}에서 사용하는 이메일 주소를 {{form_link_ko}}에 제출해 주세요. 베타 기간 동안 앱은 제출하신 계정에서만 작동합니다.
|
||||
|
||||
{{premium_name}} 회원은 모바일 커뮤니티에도 참여할 수 있습니다. 베타 기간 동안 이곳은 버그, 기능 요청, UX 변경 사항 등 모든 의견을 다루는 중심 공간이 될 것입니다. 추가를 원하시면 위 양식을 통해 알려 주세요.
|
||||
|
||||
다시 알려드리지만, 이는 베타 소프트웨어입니다. 아직 많은 기능이 포함되지 않았으며, 버그가 있을 수 있습니다. 여러분의 피드백과 버그 발견에 대한 도움은 저희가 빠르게 개선하는 데 큰 도움이 됩니다.
|
||||
|
||||
다시 한 번 감사드립니다.
|
||||
{{product_name}} 팀`,
|
||||
[Locales.LT]: `Kviečiame visus {{premium_name}} prenumeratorius!
|
||||
|
||||
Dėkojame, kad padedate paversti {{product_name}} realybe. Džiaugiamės galėdami pranešti apie mūsų mobiliosios programėlės beta versijos paleidimą. Beta versija atvira visiems {{android_name}} naudotojams ir {{premium_name}} nariams, naudojantiems {{ios_name}}.
|
||||
|
||||
Mūsų mobilioji programėlė bus pasiekiama 2026 m. birželio 15 d. Paleidimo metu {{android_name}} naudotojai galės atsisiųsti APK iš {{github_link}}, o {{ios_name}} naudotojai galės ją pasiekti per {{apple_name}} {{testflight_name}}.
|
||||
|
||||
Jei naudojate {{ios_name}}, pateikite savo {{app_store_name}} el. pašto adresą {{form_link_lt}}. Beta laikotarpiu programėlė veiks tik su jūsų paskyra.
|
||||
|
||||
{{premium_name}} nariai taip pat galės prisijungti prie mūsų mobiliosios bendruomenės. Beta laikotarpiu ji bus pagrindinė vieta pranešti apie klaidas, teikti funkcijų prašymus, siūlyti UX pakeitimus ir aptarti visa kita. Jei norėtumėte būti pridėti, praneškite mums aukščiau pateiktoje formoje.
|
||||
|
||||
Primename, kad tai yra beta programinė įranga. Daugelio funkcijų dar nebus, todėl reikėtų tikėtis klaidų. Jūsų atsiliepimai ir pagalba ieškant klaidų padės mums greitai tobulėti.
|
||||
|
||||
Dar kartą dėkojame,
|
||||
{{product_name}} komanda`,
|
||||
[Locales.NL]: `Aan alle {{premium_name}}-abonnees!
|
||||
|
||||
Bedankt dat jullie helpen om {{product_name}} mogelijk te maken. We zijn blij de lancering van de bèta van onze mobiele app aan te kondigen. De bèta staat open voor alle {{android_name}}-gebruikers en voor {{premium_name}}-leden op {{ios_name}}.
|
||||
|
||||
Onze mobiele app komt beschikbaar op 15 juni 2026. Bij de lancering kunnen {{android_name}}-gebruikers de APK downloaden via {{github_link}}, en {{ios_name}}-gebruikers krijgen toegang via {{testflight_name}} van {{apple_name}}.
|
||||
|
||||
Als je {{ios_name}} gebruikt, dien dan {{form_link_nl}} het e-mailadres in dat je voor de {{app_store_name}} gebruikt. Tijdens de bèta werkt de app alleen met jouw account.
|
||||
|
||||
{{premium_name}}-leden kunnen ook lid worden van onze mobiele community. Tijdens de bèta is dit onze centrale plek voor bugs, functieverzoeken, UX-wijzigingen en alles daartussenin. Als je wilt worden toegevoegd, laat het ons weten via het bovenstaande formulier.
|
||||
|
||||
Ter herinnering: dit is bètasoftware. Veel functies zullen nog niet aanwezig zijn en je moet rekening houden met bugs. Jullie feedback en hulp bij het vinden van bugs helpen ons om snel te verbeteren.
|
||||
|
||||
Nogmaals bedankt,
|
||||
Het {{product_name}}-team`,
|
||||
[Locales.NO]: `Til alle {{premium_name}}-abonnenter!
|
||||
|
||||
Takk for at dere hjelper med å gjøre {{product_name}} mulig. Vi er glade for å kunne kunngjøre lanseringen av betaen for mobilappen vår. Betaen er åpen for alle {{android_name}}-brukere og for {{premium_name}}-medlemmer på {{ios_name}}.
|
||||
|
||||
Mobilappen vår blir tilgjengelig 15. juni 2026. Ved lansering vil {{android_name}}-brukere kunne laste ned APK-en fra {{github_link}}, og {{ios_name}}-brukere vil få tilgang via {{testflight_name}} fra {{apple_name}}.
|
||||
|
||||
Hvis du bruker {{ios_name}}, ber vi deg sende inn e-postadressen du bruker for {{app_store_name}} {{form_link_no}}. Under betaen vil appen bare fungere med kontoen din.
|
||||
|
||||
{{premium_name}}-medlemmer vil også kunne bli med i mobilfellesskapet vårt. Under betaen blir dette samlingspunktet vårt for feil, funksjonsønsker, UX-endringer og alt imellom. Hvis du vil bli lagt til, gi oss beskjed i skjemaet ovenfor.
|
||||
|
||||
Som en påminnelse er dette betaprogramvare. Mange funksjoner vil ikke være på plass ennå, og du bør forvente feil. Tilbakemeldingene dine og hjelpen din med å finne feil vil hjelpe oss med å forbedre oss raskt.
|
||||
|
||||
Takk igjen,
|
||||
{{product_name}}-teamet`,
|
||||
[Locales.PL]: `Do wszystkich subskrybentów {{premium_name}}!
|
||||
|
||||
Dziękujemy, że pomagacie urzeczywistnić {{product_name}}. Z przyjemnością ogłaszamy uruchomienie wersji beta naszej aplikacji mobilnej. Beta jest dostępna dla wszystkich użytkowników systemu {{android_name}} oraz członków {{premium_name}} korzystających z {{ios_name}}.
|
||||
|
||||
Nasza aplikacja mobilna będzie dostępna 15 czerwca 2026 r. W dniu premiery użytkownicy systemu {{android_name}} będą mogli pobrać plik APK z {{github_link}}, a użytkownicy {{ios_name}} uzyskają dostęp przez {{testflight_name}} firmy {{apple_name}}.
|
||||
|
||||
Jeśli korzystasz z {{ios_name}}, prześlij adres e-mail używany w {{app_store_name}} {{form_link_pl}}. W trakcie bety aplikacja będzie działać wyłącznie z Twoim kontem.
|
||||
|
||||
Członkowie {{premium_name}} będą mogli również dołączyć do naszej społeczności mobilnej. W trakcie bety będzie to nasze centrum zgłaszania błędów, próśb o nowe funkcje, zmian w doświadczeniu użytkownika i wszystkiego pomiędzy. Jeśli chcesz zostać dodany, daj nam znać w powyższym formularzu.
|
||||
|
||||
Przypominamy, że jest to oprogramowanie w wersji beta. Wiele funkcji nie będzie jeszcze dostępnych i należy spodziewać się błędów. Wasze opinie i pomoc w znajdowaniu błędów pomogą nam szybko ulepszać aplikację.
|
||||
|
||||
Jeszcze raz dziękujemy,
|
||||
Zespół {{product_name}}`,
|
||||
[Locales.PT_BR]: `A todos os assinantes {{premium_name}}!
|
||||
|
||||
Obrigado por ajudar a tornar o {{product_name}} realidade. Temos o prazer de anunciar o lançamento da versão beta do nosso app móvel. A versão beta está aberta a todos os usuários de {{android_name}} e aos membros {{premium_name}} no {{ios_name}}.
|
||||
|
||||
Nosso app móvel estará disponível em 15 de junho de 2026. No lançamento, os usuários de {{android_name}} poderão baixar o APK pelo {{github_link}}, e os usuários de {{ios_name}} poderão acessá-lo pelo {{testflight_name}} da {{apple_name}}.
|
||||
|
||||
Se você usa {{ios_name}}, envie o endereço de e-mail que você usa na {{app_store_name}} {{form_link_pt_br}}. Durante a versão beta, o app funcionará apenas com a sua conta.
|
||||
|
||||
Os membros {{premium_name}} também poderão entrar na nossa comunidade móvel. Durante a versão beta, ela será o nosso espaço central para bugs, solicitações de recursos, mudanças de UX e tudo mais. Se quiser ser adicionado, avise-nos no formulário acima.
|
||||
|
||||
Como lembrete, este é um software beta. Muitos recursos ainda não estarão disponíveis, e você deve esperar encontrar bugs. Seus comentários e sua ajuda para encontrar bugs nos ajudarão a melhorar rapidamente.
|
||||
|
||||
Obrigado mais uma vez,
|
||||
Equipe {{product_name}}`,
|
||||
[Locales.RO]: `Către toți abonații {{premium_name}}!
|
||||
|
||||
Vă mulțumim că ne ajutați să transformăm {{product_name}} în realitate. Suntem încântați să anunțăm lansarea versiunii beta a aplicației noastre mobile. Beta este deschisă tuturor utilizatorilor {{android_name}} și membrilor {{premium_name}} pe {{ios_name}}.
|
||||
|
||||
Aplicația noastră mobilă va fi disponibilă pe 15 iunie 2026. La lansare, utilizatorii {{android_name}} vor putea descărca APK-ul de pe {{github_link}}, iar utilizatorii {{ios_name}} o vor putea accesa prin {{testflight_name}} de la {{apple_name}}.
|
||||
|
||||
Dacă folosiți {{ios_name}}, vă rugăm să trimiteți adresa de e-mail pe care o utilizați pentru {{app_store_name}} {{form_link_ro}}. În timpul perioadei beta, aplicația va funcționa doar cu contul dumneavoastră.
|
||||
|
||||
Membrii {{premium_name}} vor putea, de asemenea, să se alăture comunității noastre mobile. În timpul perioadei beta, aceasta va fi centrul nostru pentru buguri, solicitări de funcționalități, modificări de UX și orice altceva. Dacă doriți să fiți adăugat, vă rugăm să ne anunțați în formularul de mai sus.
|
||||
|
||||
Ca reamintire, acesta este software beta. Multe funcționalități nu vor fi încă disponibile și ar trebui să vă așteptați la buguri. Feedbackul dumneavoastră și ajutorul în găsirea bugurilor ne vor ajuta să îmbunătățim rapid aplicația.
|
||||
|
||||
Vă mulțumim încă o dată,
|
||||
Echipa {{product_name}}`,
|
||||
[Locales.RU]: `Обращаемся ко всем подписчикам {{premium_name}}!
|
||||
|
||||
Спасибо, что помогаете воплотить {{product_name}} в жизнь. Мы рады объявить о запуске бета-версии нашего мобильного приложения. Бета доступна всем пользователям {{android_name}} и участникам {{premium_name}} на {{ios_name}}.
|
||||
|
||||
Наше мобильное приложение станет доступно 15 июня 2026 года. После запуска пользователи {{android_name}} смогут скачать APK с {{github_link}}, а пользователи {{ios_name}} смогут получить доступ через {{testflight_name}} от {{apple_name}}.
|
||||
|
||||
Если вы пользуетесь {{ios_name}}, пожалуйста, отправьте адрес электронной почты, который вы используете для {{app_store_name}}, {{form_link_ru}}. Во время бета-тестирования приложение будет работать только с вашей учетной записью.
|
||||
|
||||
Участники {{premium_name}} также смогут присоединиться к нашему мобильному сообществу. Во время бета-тестирования оно станет нашим центром для обсуждения ошибок, запросов на новые функции, изменений UX и всего остального. Если вы хотите, чтобы вас добавили, сообщите нам об этом в форме выше.
|
||||
|
||||
Напоминаем, что это бета-версия программного обеспечения. Многие функции пока будут отсутствовать, и следует ожидать ошибок. Ваши отзывы и помощь в поиске ошибок помогут нам быстро улучшать приложение.
|
||||
|
||||
Еще раз спасибо,
|
||||
Команда {{product_name}}`,
|
||||
[Locales.SV_SE]: `Till alla {{premium_name}}-prenumeranter!
|
||||
|
||||
Tack för att ni hjälper till att göra {{product_name}} möjligt. Vi är glada att kunna meddela lanseringen av betaversionen av vår mobilapp. Betan är öppen för alla {{android_name}}-användare och för {{premium_name}}-medlemmar på {{ios_name}}.
|
||||
|
||||
Vår mobilapp blir tillgänglig den 15 juni 2026. Vid lanseringen kommer {{android_name}}-användare att kunna ladda ned APK-filen från {{github_link}}, och {{ios_name}}-användare kommer att kunna få tillgång till appen via {{testflight_name}} från {{apple_name}}.
|
||||
|
||||
Om du använder {{ios_name}} ber vi dig skicka in e-postadressen du använder för {{app_store_name}} {{form_link_sv}}. Under betan fungerar appen endast med ditt konto.
|
||||
|
||||
{{premium_name}}-medlemmar kommer också att kunna gå med i vår mobilcommunity. Under betan blir detta vår samlingsplats för buggar, funktionsönskemål, UX-ändringar och allt däremellan. Om du vill bli tillagd, meddela oss i formuläret ovan.
|
||||
|
||||
Som en påminnelse är detta betaprogramvara. Många funktioner kommer ännu inte att finnas på plats, och du bör räkna med buggar. Din feedback och hjälp med att hitta buggar kommer att hjälpa oss att förbättra appen snabbt.
|
||||
|
||||
Tack än en gång,
|
||||
{{product_name}}-teamet`,
|
||||
[Locales.TH]: `ขอเชิญสมาชิก {{premium_name}} ทุกคน!
|
||||
|
||||
ขอบคุณที่ช่วยให้ {{product_name}} เกิดขึ้นได้จริง เรารู้สึกยินดีที่จะประกาศการเปิดตัวเบตาของแอปมือถือของเรา เบตานี้เปิดให้ผู้ใช้ {{android_name}} ทุกคนและสมาชิก {{premium_name}} บน {{ios_name}} เข้าร่วมได้
|
||||
|
||||
แอปมือถือของเราจะพร้อมให้ใช้งานในวันที่ 15 มิถุนายน 2026 เมื่อเปิดตัว ผู้ใช้ {{android_name}} จะสามารถดาวน์โหลด APK ได้จาก {{github_link}} และผู้ใช้ {{ios_name}} จะสามารถเข้าถึงได้ผ่าน {{testflight_name}} ของ {{apple_name}}
|
||||
|
||||
หากคุณเป็นผู้ใช้ {{ios_name}} โปรดส่งที่อยู่อีเมลที่คุณใช้กับ {{app_store_name}} {{form_link_th}} ในช่วงเบตา แอปจะใช้งานได้เฉพาะกับบัญชีของคุณเท่านั้น
|
||||
|
||||
สมาชิก {{premium_name}} จะสามารถเข้าร่วมชุมชนมือถือของเราได้ด้วย ในช่วงเบตา ชุมชนนี้จะเป็นศูนย์กลางสำหรับบั๊ก คำขอฟีเจอร์ การเปลี่ยนแปลงด้าน UX และเรื่องอื่น ๆ ทั้งหมด หากคุณต้องการให้เพิ่มคุณเข้าไป โปรดแจ้งให้เราทราบในแบบฟอร์มด้านบน
|
||||
|
||||
โปรดทราบว่านี่คือซอฟต์แวร์เบตา หลายฟีเจอร์จะยังไม่พร้อมใช้งาน และคุณควรคาดว่าจะพบข้อผิดพลาด ความคิดเห็นของคุณและความช่วยเหลือในการค้นหาบั๊กจะช่วยให้เราปรับปรุงได้อย่างรวดเร็ว
|
||||
|
||||
ขอบคุณอีกครั้ง
|
||||
ทีม {{product_name}}`,
|
||||
[Locales.TR]: `Tüm {{premium_name}} abonelerine çağrımızdır!
|
||||
|
||||
{{product_name}}’ın hayata geçmesine yardımcı olduğunuz için teşekkür ederiz. Mobil uygulama betamızı başlatacağımızı duyurmaktan heyecan duyuyoruz. Beta, tüm {{android_name}} kullanıcılarına ve {{ios_name}}’taki {{premium_name}} üyelerine açıktır.
|
||||
|
||||
Mobil uygulamamız 15 Haziran 2026’da kullanıma sunulacaktır. Lansmanla birlikte {{android_name}} kullanıcıları APK’yı {{github_link}} üzerinden indirebilecek, {{ios_name}} kullanıcıları ise {{apple_name}}’ın {{testflight_name}} uygulaması üzerinden erişim sağlayabilecektir.
|
||||
|
||||
{{ios_name}} kullanıcısıysanız, lütfen {{app_store_name}}’da kullandığınız e-posta adresinizi {{form_link_tr}} gönderin. Beta süresince uygulama yalnızca hesabınızla çalışacaktır.
|
||||
|
||||
{{premium_name}} üyeleri ayrıca mobil topluluğumuza katılabilecektir. Beta süresince burası; hatalar, özellik istekleri, UX değişiklikleri ve bunlarla ilgili diğer her şey için merkezimiz olacaktır. Eklenmek isterseniz, lütfen yukarıdaki formda bize bildirin.
|
||||
|
||||
Hatırlatmak isteriz ki bu bir beta yazılımdır. Pek çok özellik henüz mevcut olmayacak ve hatalarla karşılaşmayı beklemelisiniz. Geri bildirimleriniz ve hataları bulmamıza yardımcı olmanız, hızlıca gelişmemize katkı sağlayacaktır.
|
||||
|
||||
Tekrar teşekkürler,
|
||||
{{product_name}} Ekibi`,
|
||||
[Locales.UK]: `Звертаємося до всіх підписників {{premium_name}}!
|
||||
|
||||
Дякуємо, що допомагаєте втілити {{product_name}} у життя. Ми раді оголосити про запуск бета-версії нашого мобільного застосунку. Бета відкрита для всіх користувачів {{android_name}} і для учасників {{premium_name}} на {{ios_name}}.
|
||||
|
||||
Наш мобільний застосунок стане доступним 15 червня 2026 року. Після запуску користувачі {{android_name}} зможуть завантажити APK з {{github_link}}, а користувачі {{ios_name}} зможуть отримати доступ через {{testflight_name}} від {{apple_name}}.
|
||||
|
||||
Якщо ви користуєтеся {{ios_name}}, будь ласка, надішліть адресу електронної пошти, яку ви використовуєте для {{app_store_name}}, {{form_link_uk}}. Під час бети застосунок працюватиме лише з вашим обліковим записом.
|
||||
|
||||
Учасники {{premium_name}} також зможуть приєднатися до нашої мобільної спільноти. Під час бети вона буде нашим осередком для багів, запитів на функції, змін UX і всього іншого. Якщо хочете, щоб вас додали, повідомте нам у формі вище.
|
||||
|
||||
Нагадуємо, що це бета-програмне забезпечення. Багатьох функцій ще не буде, і слід очікувати помилок. Ваші відгуки та допомога у виявленні багів допоможуть нам швидко покращувати застосунок.
|
||||
|
||||
Ще раз дякуємо,
|
||||
Команда {{product_name}}`,
|
||||
[Locales.VI]: `Kêu gọi tất cả người đăng ký {{premium_name}}!
|
||||
|
||||
Cảm ơn bạn đã giúp {{product_name}} trở thành hiện thực. Chúng tôi rất vui được thông báo ra mắt bản beta của ứng dụng di động. Bản beta mở cho tất cả người dùng {{android_name}} và các thành viên {{premium_name}} trên {{ios_name}}.
|
||||
|
||||
Ứng dụng di động của chúng tôi sẽ có mặt vào ngày 15 tháng 6 năm 2026. Khi ra mắt, người dùng {{android_name}} sẽ có thể tải APK từ {{github_link}}, còn người dùng {{ios_name}} sẽ có thể truy cập thông qua {{testflight_name}} của {{apple_name}}.
|
||||
|
||||
Nếu bạn là người dùng {{ios_name}}, vui lòng gửi địa chỉ email {{app_store_name}} của bạn {{form_link_vi}}. Trong thời gian beta, ứng dụng sẽ chỉ hoạt động với tài khoản của bạn.
|
||||
|
||||
Các thành viên {{premium_name}} cũng sẽ có thể tham gia cộng đồng di động của chúng tôi. Trong thời gian beta, đây sẽ là trung tâm của chúng tôi dành cho lỗi, yêu cầu tính năng, thay đổi UX và mọi vấn đề liên quan. Nếu bạn muốn được thêm vào, vui lòng cho chúng tôi biết trong biểu mẫu ở trên.
|
||||
|
||||
Xin nhắc lại, đây là phần mềm beta. Nhiều tính năng sẽ chưa có mặt và bạn nên chuẩn bị tinh thần sẽ gặp lỗi. Phản hồi của bạn và sự giúp đỡ của bạn trong việc tìm lỗi sẽ giúp chúng tôi cải thiện nhanh chóng.
|
||||
|
||||
Một lần nữa xin cảm ơn,
|
||||
Đội ngũ {{product_name}}`,
|
||||
[Locales.ZH_CN]: `致所有 {{premium_name}} 订阅者!
|
||||
|
||||
感谢你们帮助 {{product_name}} 变为现实。我们很高兴地宣布,我们的移动应用 Beta 版即将发布。Beta 版面向所有 {{android_name}} 用户以及 {{ios_name}} 上的 {{premium_name}} 会员开放。
|
||||
|
||||
我们的移动应用将于 2026 年 6 月 15 日开放使用。发布后,{{android_name}} 用户可以从 {{github_link}} 下载 APK,{{ios_name}} 用户则可以通过 {{apple_name}} 的 {{testflight_name}} 访问。
|
||||
|
||||
如果你是 {{ios_name}} 用户,请在{{form_link_zh_cn}}提交你用于 {{app_store_name}} 的电子邮件地址。在 Beta 期间,该应用只会与你的账号配合使用。
|
||||
|
||||
{{premium_name}} 会员还可以加入我们的移动社区。在 Beta 期间,这里将作为我们处理错误反馈、功能请求、用户体验改进以及其他相关事项的中心。如果你希望加入,请在上方表单中告知我们。
|
||||
|
||||
请注意,这是 Beta 版软件。许多功能尚未提供,也可能会遇到错误。你们的反馈以及帮助我们发现错误,将帮助我们快速改进。
|
||||
|
||||
再次感谢,
|
||||
{{product_name}} 团队`,
|
||||
[Locales.ZH_TW]: `致所有 {{premium_name}} 訂閱者!
|
||||
|
||||
感謝你們協助讓 {{product_name}} 成真。我們很高興宣布,我們的行動應用程式 Beta 版即將推出。Beta 版開放給所有 {{android_name}} 使用者,以及 {{ios_name}} 上的 {{premium_name}} 會員。
|
||||
|
||||
我們的行動應用程式將於 2026 年 6 月 15 日開放使用。推出時,{{android_name}} 使用者可以從 {{github_link}} 下載 APK,{{ios_name}} 使用者則可透過 {{apple_name}} 的 {{testflight_name}} 存取。
|
||||
|
||||
如果你是 {{ios_name}} 使用者,請在{{form_link_zh_tw}}提交你用於 {{app_store_name}} 的電子郵件地址。在 Beta 期間,應用程式只會與你的帳號搭配使用。
|
||||
|
||||
{{premium_name}} 會員也可以加入我們的行動社群。在 Beta 期間,這裡將作為我們處理錯誤回報、功能請求、UX 變更以及其他相關事項的中心。如果你希望加入,請在上方表單中告知我們。
|
||||
|
||||
提醒你,這是 Beta 版軟體。許多功能尚未提供,也可能會遇到錯誤。你的意見回饋以及協助我們找出錯誤,將幫助我們快速改進。
|
||||
|
||||
再次感謝,
|
||||
{{product_name}} 團隊`,
|
||||
};
|
||||
|
||||
const MOBILE_COMMUNITY_FORM_CLARIFICATION_TEMPLATES: Partial<Record<LocaleCode, string>> = {
|
||||
[Locales.AR]:
|
||||
'النموذج مخصص أيضًا للوصول إلى مجتمع الهاتف المحمول على {{android_name}} و{{ios_name}}. لإضافتك إلى مجتمع الهاتف المحمول، نحتاج فقط إلى معرّف مستخدم {{product_name}} الخاص بك في النموذج: {{user_id}}.',
|
||||
[Locales.BG]:
|
||||
'Формулярът е и за достъп до мобилната общност на {{android_name}} и {{ios_name}}. За да бъдете добавени към мобилната общност, във формуляра ни е нужен само вашият потребителски ID в {{product_name}}: {{user_id}}.',
|
||||
[Locales.CS]:
|
||||
'Formulář slouží také pro přístup do mobilní komunity na {{android_name}} a {{ios_name}}. Pro přidání do mobilní komunity potřebujeme ve formuláři pouze vaše uživatelské ID v {{product_name}}: {{user_id}}.',
|
||||
[Locales.DA]:
|
||||
'Formularen bruges også til adgang til mobilfællesskabet på {{android_name}} og {{ios_name}}. For at blive tilføjet til mobilfællesskabet behøver vi kun dit {{product_name}}-bruger-id i formularen: {{user_id}}.',
|
||||
[Locales.DE]:
|
||||
'Das Formular ist auch für den Zugang zur mobilen Community auf {{android_name}} und {{ios_name}} gedacht. Um zur mobilen Community hinzugefügt zu werden, benötigen wir im Formular nur deine {{product_name}}-Benutzer-ID: {{user_id}}.',
|
||||
[Locales.EL]:
|
||||
'Η φόρμα είναι επίσης για πρόσβαση στην κοινότητα κινητών σε {{android_name}} και {{ios_name}}. Για να προστεθείτε στην κοινότητα κινητών, χρειαζόμαστε στη φόρμα μόνο το αναγνωριστικό χρήστη σας στο {{product_name}}: {{user_id}}.',
|
||||
[Locales.EN_GB]:
|
||||
'The form is also for mobile community access on {{android_name}} and {{ios_name}}. To be added to the mobile community, we only need your {{product_name}} user ID in the form: {{user_id}}.',
|
||||
[Locales.EN_US]:
|
||||
'The form is also for mobile community access on {{android_name}} and {{ios_name}}. To be added to the mobile community, we only need your {{product_name}} user ID in the form: {{user_id}}.',
|
||||
[Locales.ES_ES]:
|
||||
'El formulario también sirve para acceder a la comunidad móvil en {{android_name}} y {{ios_name}}. Para añadirte a la comunidad móvil, solo necesitamos tu ID de usuario de {{product_name}} en el formulario: {{user_id}}.',
|
||||
[Locales.ES_419]:
|
||||
'El formulario también sirve para acceder a la comunidad móvil en {{android_name}} y {{ios_name}}. Para agregarte a la comunidad móvil, solo necesitamos tu ID de usuario de {{product_name}} en el formulario: {{user_id}}.',
|
||||
[Locales.FI]:
|
||||
'Lomake on tarkoitettu myös mobiiliyhteisön käyttöoikeuteen {{android_name}}- ja {{ios_name}}-käyttäjille. Jotta voimme lisätä sinut mobiiliyhteisöön, tarvitsemme lomakkeessa vain {{product_name}}-käyttäjätunnuksesi: {{user_id}}.',
|
||||
[Locales.FR]:
|
||||
'Le formulaire sert aussi à demander l’accès à la communauté mobile sur {{android_name}} et {{ios_name}}. Pour être ajouté à la communauté mobile, nous avons seulement besoin de votre identifiant utilisateur {{product_name}} dans le formulaire : {{user_id}}.',
|
||||
[Locales.HE]:
|
||||
'הטופס מיועד גם לקבלת גישה לקהילת המובייל ב-{{android_name}} וב-{{ios_name}}. כדי להוסיף אתכם לקהילת המובייל, אנחנו צריכים בטופס רק את מזהה המשתמש שלכם ב-{{product_name}}: {{user_id}}.',
|
||||
[Locales.HI]:
|
||||
'फ़ॉर्म {{android_name}} और {{ios_name}} पर मोबाइल समुदाय की पहुँच के लिए भी है। मोबाइल समुदाय में जोड़े जाने के लिए, हमें फ़ॉर्म में केवल आपका {{product_name}} उपयोगकर्ता ID चाहिए: {{user_id}}.',
|
||||
[Locales.HR]:
|
||||
'Obrazac služi i za pristup mobilnoj zajednici na {{android_name}} i {{ios_name}}. Za dodavanje u mobilnu zajednicu u obrascu nam treba samo vaš korisnički ID za {{product_name}}: {{user_id}}.',
|
||||
[Locales.HU]:
|
||||
'Az űrlap a mobilos közösséghez való hozzáférésre is szolgál {{android_name}} és {{ios_name}} esetén. Ahhoz, hogy hozzáadjunk a mobilos közösséghez, az űrlapon csak a {{product_name}} felhasználói azonosítódra van szükségünk: {{user_id}}.',
|
||||
[Locales.ID]:
|
||||
'Formulir ini juga digunakan untuk akses komunitas seluler di {{android_name}} dan {{ios_name}}. Agar Anda dapat ditambahkan ke komunitas seluler, kami hanya membutuhkan ID pengguna {{product_name}} Anda di formulir: {{user_id}}.',
|
||||
[Locales.IT]:
|
||||
'Il modulo serve anche per l’accesso alla community mobile su {{android_name}} e {{ios_name}}. Per aggiungerti alla community mobile, nel modulo ci serve solo il tuo ID utente {{product_name}}: {{user_id}}.',
|
||||
[Locales.JA]:
|
||||
'このフォームは、{{android_name}}と{{ios_name}}のモバイルコミュニティへのアクセス申請にも使用します。モバイルコミュニティに追加するためにフォームで必要なのは、あなたの{{product_name}}ユーザーIDだけです: {{user_id}}。',
|
||||
[Locales.KO]:
|
||||
'이 양식은 {{android_name}} 및 {{ios_name}} 모바일 커뮤니티 접근 신청에도 사용됩니다. 모바일 커뮤니티에 추가하려면 양식에 {{product_name}} 사용자 ID만 입력하면 됩니다: {{user_id}}.',
|
||||
[Locales.LT]:
|
||||
'Forma taip pat skirta prieigai prie mobiliosios bendruomenės naudojant {{android_name}} ir {{ios_name}}. Kad galėtume jus pridėti prie mobiliosios bendruomenės, formoje mums reikia tik jūsų {{product_name}} naudotojo ID: {{user_id}}.',
|
||||
[Locales.NL]:
|
||||
'Het formulier is ook bedoeld voor toegang tot de mobiele community op {{android_name}} en {{ios_name}}. Om je aan de mobiele community toe te voegen, hebben we in het formulier alleen je {{product_name}}-gebruikers-ID nodig: {{user_id}}.',
|
||||
[Locales.NO]:
|
||||
'Skjemaet brukes også for tilgang til mobilfellesskapet på {{android_name}} og {{ios_name}}. For å bli lagt til i mobilfellesskapet trenger vi bare {{product_name}}-bruker-ID-en din i skjemaet: {{user_id}}.',
|
||||
[Locales.PL]:
|
||||
'Formularz służy także do uzyskania dostępu do społeczności mobilnej na {{android_name}} i {{ios_name}}. Aby dodać Cię do społeczności mobilnej, w formularzu potrzebujemy tylko Twojego identyfikatora użytkownika {{product_name}}: {{user_id}}.',
|
||||
[Locales.PT_BR]:
|
||||
'O formulário também serve para acesso à comunidade móvel no {{android_name}} e no {{ios_name}}. Para adicionar você à comunidade móvel, precisamos apenas do seu ID de usuário do {{product_name}} no formulário: {{user_id}}.',
|
||||
[Locales.RO]:
|
||||
'Formularul este și pentru accesul la comunitatea mobilă pe {{android_name}} și {{ios_name}}. Pentru a vă adăuga în comunitatea mobilă, avem nevoie în formular doar de ID-ul dvs. de utilizator {{product_name}}: {{user_id}}.',
|
||||
[Locales.RU]:
|
||||
'Форма также предназначена для доступа к мобильному сообществу на {{android_name}} и {{ios_name}}. Чтобы добавить вас в мобильное сообщество, в форме нам нужен только ваш пользовательский ID в {{product_name}}: {{user_id}}.',
|
||||
[Locales.SV_SE]:
|
||||
'Formuläret används också för åtkomst till mobilcommunityn på {{android_name}} och {{ios_name}}. För att läggas till i mobilcommunityn behöver vi bara ditt {{product_name}}-användar-ID i formuläret: {{user_id}}.',
|
||||
[Locales.TH]:
|
||||
'แบบฟอร์มนี้ใช้สำหรับการเข้าถึงชุมชนมือถือบน {{android_name}} และ {{ios_name}} ด้วย หากต้องการให้เราเพิ่มคุณในชุมชนมือถือ เราต้องการเพียง ID ผู้ใช้ {{product_name}} ของคุณในแบบฟอร์ม: {{user_id}}',
|
||||
[Locales.TR]:
|
||||
'Form, {{android_name}} ve {{ios_name}} üzerindeki mobil topluluk erişimi için de kullanılır. Mobil topluluğa eklenmeniz için formda yalnızca {{product_name}} kullanıcı ID’nize ihtiyacımız var: {{user_id}}.',
|
||||
[Locales.UK]:
|
||||
'Форма також призначена для доступу до мобільної спільноти на {{android_name}} та {{ios_name}}. Щоб додати вас до мобільної спільноти, у формі нам потрібен лише ваш ID користувача {{product_name}}: {{user_id}}.',
|
||||
[Locales.VI]:
|
||||
'Biểu mẫu cũng dùng để yêu cầu quyền truy cập cộng đồng di động trên {{android_name}} và {{ios_name}}. Để thêm bạn vào cộng đồng di động, chúng tôi chỉ cần ID người dùng {{product_name}} của bạn trong biểu mẫu: {{user_id}}.',
|
||||
[Locales.ZH_CN]:
|
||||
'该表单也用于申请 {{android_name}} 和 {{ios_name}} 移动社区访问权限。若要加入移动社区,我们在表单中只需要你的 {{product_name}} 用户 ID:{{user_id}}。',
|
||||
[Locales.ZH_TW]:
|
||||
'此表單也用於申請 {{android_name}} 和 {{ios_name}} 行動社群存取權。若要加入行動社群,我們在表單中只需要你的 {{product_name}} 使用者 ID:{{user_id}}。',
|
||||
};
|
||||
|
||||
const CORRECTION_HEADER_TEMPLATES: Partial<Record<LocaleCode, string>> = {
|
||||
[Locales.AR]: 'تصحيح سريع لرسالتنا السابقة عن النسخة التجريبية لتطبيق الهاتف المحمول:',
|
||||
[Locales.BG]: 'Кратка корекция към предишното ни съобщение за мобилната бета:',
|
||||
[Locales.CS]: 'Krátké upřesnění k naší předchozí zprávě o mobilní betě:',
|
||||
[Locales.DA]: 'En hurtig rettelse til vores tidligere besked om mobilbetaen:',
|
||||
[Locales.DE]: 'Kurze Korrektur zu unserer vorherigen Nachricht zur mobilen Beta:',
|
||||
[Locales.EL]: 'Μια γρήγορη διόρθωση στο προηγούμενο μήνυμά μας για τη beta των κινητών:',
|
||||
[Locales.EN_GB]: 'Quick correction to our previous mobile beta message:',
|
||||
[Locales.EN_US]: 'Quick correction to our previous mobile beta message:',
|
||||
[Locales.ES_ES]: 'Una corrección rápida a nuestro mensaje anterior sobre la beta móvil:',
|
||||
[Locales.ES_419]: 'Una corrección rápida a nuestro mensaje anterior sobre la beta móvil:',
|
||||
[Locales.FI]: 'Nopea korjaus aiempaan mobiilibetaa koskevaan viestiimme:',
|
||||
[Locales.FR]: 'Petite correction à notre précédent message sur la bêta mobile :',
|
||||
[Locales.HE]: 'תיקון קצר להודעה הקודמת שלנו על גרסת הבטא למובייל:',
|
||||
[Locales.HI]: 'हमारे पिछले मोबाइल बीटा संदेश में एक छोटा सुधार:',
|
||||
[Locales.HR]: 'Kratka ispravka naše prethodne poruke o mobilnoj beti:',
|
||||
[Locales.HU]: 'Rövid pontosítás a korábbi mobilbétás üzenetünkhöz:',
|
||||
[Locales.ID]: 'Koreksi singkat untuk pesan beta seluler kami sebelumnya:',
|
||||
[Locales.IT]: 'Una rapida correzione al nostro precedente messaggio sulla beta mobile:',
|
||||
[Locales.JA]: '以前のモバイルベータに関するメッセージについて、簡単な訂正です:',
|
||||
[Locales.KO]: '이전 모바일 베타 메시지에 대한 간단한 정정입니다:',
|
||||
[Locales.LT]: 'Trumpas ankstesnio pranešimo apie mobiliąją beta versiją patikslinimas:',
|
||||
[Locales.NL]: 'Een korte correctie op ons eerdere bericht over de mobiele bèta:',
|
||||
[Locales.NO]: 'En rask rettelse til den forrige meldingen vår om mobilbetaen:',
|
||||
[Locales.PL]: 'Krótka korekta do naszej poprzedniej wiadomości o becie mobilnej:',
|
||||
[Locales.PT_BR]: 'Uma correção rápida sobre a nossa mensagem anterior da beta móvel:',
|
||||
[Locales.RO]: 'O corectare rapidă la mesajul nostru anterior despre beta mobilă:',
|
||||
[Locales.RU]: 'Небольшое уточнение к нашему предыдущему сообщению о мобильной бете:',
|
||||
[Locales.SV_SE]: 'En snabb rättelse till vårt tidigare meddelande om mobilbetan:',
|
||||
[Locales.TH]: 'ขอแก้ไขสั้น ๆ เกี่ยวกับข้อความเบตาแอปมือถือก่อนหน้านี้:',
|
||||
[Locales.TR]: 'Önceki mobil beta mesajımızla ilgili kısa bir düzeltme:',
|
||||
[Locales.UK]: 'Коротке уточнення до нашого попереднього повідомлення про мобільну бету:',
|
||||
[Locales.VI]: 'Một đính chính nhanh cho thông báo beta di động trước đó của chúng tôi:',
|
||||
[Locales.ZH_CN]: '对我们之前移动 Beta 消息的简短更正:',
|
||||
[Locales.ZH_TW]: '對我們先前行動 Beta 訊息的簡短更正:',
|
||||
};
|
||||
|
||||
const FORM_LINK_SENTENCE_TEMPLATES: Partial<Record<LocaleCode, string>> = {
|
||||
[Locales.AR]: 'يمكنك استخدام النموذج {{form_link}}.',
|
||||
[Locales.BG]: 'Можете да използвате формуляра {{form_link}}.',
|
||||
[Locales.CS]: 'Formulář můžete použít {{form_link}}.',
|
||||
[Locales.DA]: 'Du kan bruge formularen {{form_link}}.',
|
||||
[Locales.DE]: 'Du kannst das Formular {{form_link}} verwenden.',
|
||||
[Locales.EL]: 'Μπορείτε να χρησιμοποιήσετε τη φόρμα {{form_link}}.',
|
||||
[Locales.EN_GB]: 'You can use the form {{form_link}}.',
|
||||
[Locales.EN_US]: 'You can use the form {{form_link}}.',
|
||||
[Locales.ES_ES]: 'Puedes usar el formulario {{form_link}}.',
|
||||
[Locales.ES_419]: 'Puedes usar el formulario {{form_link}}.',
|
||||
[Locales.FI]: 'Voit käyttää lomaketta {{form_link}}.',
|
||||
[Locales.FR]: 'Vous pouvez utiliser le formulaire {{form_link}}.',
|
||||
[Locales.HE]: 'אפשר להשתמש בטופס {{form_link}}.',
|
||||
[Locales.HI]: 'आप फ़ॉर्म {{form_link}} इस्तेमाल कर सकते हैं.',
|
||||
[Locales.HR]: 'Možete upotrijebiti obrazac {{form_link}}.',
|
||||
[Locales.HU]: 'Az űrlapot {{form_link}} használhatod.',
|
||||
[Locales.ID]: 'Anda dapat menggunakan formulir {{form_link}}.',
|
||||
[Locales.IT]: 'Puoi usare il modulo {{form_link}}.',
|
||||
[Locales.JA]: 'フォームは{{form_link}}から使用できます。',
|
||||
[Locales.KO]: '양식은 {{form_link}}에서 사용할 수 있습니다.',
|
||||
[Locales.LT]: 'Formą galite naudoti {{form_link}}.',
|
||||
[Locales.NL]: 'Je kunt het formulier {{form_link}} gebruiken.',
|
||||
[Locales.NO]: 'Du kan bruke skjemaet {{form_link}}.',
|
||||
[Locales.PL]: 'Możesz użyć formularza {{form_link}}.',
|
||||
[Locales.PT_BR]: 'Você pode usar o formulário {{form_link}}.',
|
||||
[Locales.RO]: 'Puteți folosi formularul {{form_link}}.',
|
||||
[Locales.RU]: 'Вы можете использовать форму {{form_link}}.',
|
||||
[Locales.SV_SE]: 'Du kan använda formuläret {{form_link}}.',
|
||||
[Locales.TH]: 'คุณสามารถใช้แบบฟอร์ม {{form_link}}',
|
||||
[Locales.TR]: 'Formu {{form_link}} kullanabilirsiniz.',
|
||||
[Locales.UK]: 'Ви можете скористатися формою {{form_link}}.',
|
||||
[Locales.VI]: 'Bạn có thể dùng biểu mẫu {{form_link}}.',
|
||||
[Locales.ZH_CN]: '你可以使用表单{{form_link}}。',
|
||||
[Locales.ZH_TW]: '你可以使用表單{{form_link}}。',
|
||||
};
|
||||
|
||||
const IOS_APP_ACCESS_REMINDER_TEMPLATES: Partial<Record<LocaleCode, string>> = {
|
||||
[Locales.AR]:
|
||||
'إذا كنت تحتاج أيضًا إلى الوصول إلى النسخة التجريبية من تطبيق {{ios_name}}، فيُرجى الاستمرار في تضمين عنوان بريدك الإلكتروني المستخدم في {{app_store_name}} داخل النموذج.',
|
||||
[Locales.BG]:
|
||||
'Ако ви е нужен и достъп до бета приложението за {{ios_name}}, все пак включете във формуляра имейл адреса си за {{app_store_name}}.',
|
||||
[Locales.CS]:
|
||||
'Pokud potřebujete také přístup k beta aplikaci pro {{ios_name}}, stále do formuláře uveďte svou e-mailovou adresu pro {{app_store_name}}.',
|
||||
[Locales.DA]:
|
||||
'Hvis du også har brug for adgang til {{ios_name}}-appbetaen, skal du stadig medtage din {{app_store_name}}-e-mailadresse i formularen.',
|
||||
[Locales.DE]:
|
||||
'Wenn du auch Zugriff auf die {{ios_name}}-App-Beta brauchst, gib im Formular weiterhin deine E-Mail-Adresse für den {{app_store_name}} an.',
|
||||
[Locales.EL]:
|
||||
'Αν χρειάζεστε επίσης πρόσβαση στη beta εφαρμογή για {{ios_name}}, συνεχίστε να συμπεριλαμβάνετε στη φόρμα τη διεύθυνση email που χρησιμοποιείτε για το {{app_store_name}}.',
|
||||
[Locales.EN_GB]:
|
||||
'If you also need {{ios_name}} app beta access, still include your {{app_store_name}} email address in the form.',
|
||||
[Locales.EN_US]:
|
||||
'If you also need {{ios_name}} app beta access, still include your {{app_store_name}} email address in the form.',
|
||||
[Locales.ES_ES]:
|
||||
'Si también necesitas acceso a la beta de la aplicación para {{ios_name}}, sigue incluyendo en el formulario tu dirección de correo del {{app_store_name}}.',
|
||||
[Locales.ES_419]:
|
||||
'Si también necesitas acceso a la beta de la app para {{ios_name}}, sigue incluyendo en el formulario tu dirección de correo de {{app_store_name}}.',
|
||||
[Locales.FI]:
|
||||
'Jos tarvitset myös pääsyn {{ios_name}}-sovelluksen betaan, lisää lomakkeeseen edelleen {{app_store_name}} -sähköpostiosoitteesi.',
|
||||
[Locales.FR]:
|
||||
'Si vous avez aussi besoin de l’accès à la bêta de l’application {{ios_name}}, indiquez toujours dans le formulaire l’adresse e-mail utilisée pour l’{{app_store_name}}.',
|
||||
[Locales.HE]:
|
||||
'אם אתם צריכים גם גישה לבטא של אפליקציית {{ios_name}}, עדיין יש לכלול בטופס את כתובת הדוא״ל שלכם עבור {{app_store_name}}.',
|
||||
[Locales.HI]:
|
||||
'यदि आपको {{ios_name}} ऐप बीटा एक्सेस भी चाहिए, तो फ़ॉर्म में अपना {{app_store_name}} ईमेल पता अभी भी शामिल करें.',
|
||||
[Locales.HR]:
|
||||
'Ako vam treba i pristup beta verziji aplikacije za {{ios_name}}, u obrascu i dalje navedite svoju adresu e-pošte za {{app_store_name}}.',
|
||||
[Locales.HU]:
|
||||
'Ha az {{ios_name}}-app bétájához is hozzáférést szeretnél, továbbra is add meg az űrlapon a {{app_store_name}} e-mail-címedet.',
|
||||
[Locales.ID]:
|
||||
'Jika Anda juga membutuhkan akses beta aplikasi {{ios_name}}, tetap sertakan alamat email {{app_store_name}} Anda di formulir.',
|
||||
[Locales.IT]:
|
||||
'Se ti serve anche l’accesso alla beta dell’app per {{ios_name}}, includi comunque nel modulo l’indirizzo email che usi per l’{{app_store_name}}.',
|
||||
[Locales.JA]:
|
||||
'{{ios_name}}アプリのベータアクセスも必要な場合は、{{app_store_name}}で使用しているメールアドレスもフォームに入力してください。',
|
||||
[Locales.KO]: '{{ios_name}} 앱 베타 접근도 필요하다면 양식에 {{app_store_name}} 이메일 주소도 계속 포함해 주세요.',
|
||||
[Locales.LT]:
|
||||
'Jei jums taip pat reikia prieigos prie {{ios_name}} programėlės beta versijos, formoje vis tiek nurodykite savo {{app_store_name}} el. pašto adresą.',
|
||||
[Locales.NL]:
|
||||
'Als je ook toegang tot de {{ios_name}}-appbèta nodig hebt, vermeld dan nog steeds je e-mailadres voor de {{app_store_name}} in het formulier.',
|
||||
[Locales.NO]:
|
||||
'Hvis du også trenger tilgang til {{ios_name}}-appbetaen, må du fortsatt ta med e-postadressen din for {{app_store_name}} i skjemaet.',
|
||||
[Locales.PL]:
|
||||
'Jeśli potrzebujesz także dostępu do bety aplikacji na {{ios_name}}, nadal podaj w formularzu adres e-mail używany w {{app_store_name}}.',
|
||||
[Locales.PT_BR]:
|
||||
'Se você também precisa de acesso à beta do app para {{ios_name}}, ainda inclua no formulário o endereço de e-mail que você usa na {{app_store_name}}.',
|
||||
[Locales.RO]:
|
||||
'Dacă aveți nevoie și de acces la beta aplicației pentru {{ios_name}}, includeți în continuare în formular adresa de e-mail folosită pentru {{app_store_name}}.',
|
||||
[Locales.RU]:
|
||||
'Если вам также нужен доступ к бета-версии приложения для {{ios_name}}, по-прежнему укажите в форме адрес электронной почты для {{app_store_name}}.',
|
||||
[Locales.SV_SE]:
|
||||
'Om du också behöver åtkomst till {{ios_name}}-appbetan ska du fortfarande ange e-postadressen du använder för {{app_store_name}} i formuläret.',
|
||||
[Locales.TH]: 'หากคุณต้องการเข้าถึงเบตาของแอป {{ios_name}} ด้วย โปรดยังระบุอีเมล {{app_store_name}} ของคุณในแบบฟอร์ม',
|
||||
[Locales.TR]:
|
||||
'{{ios_name}} uygulama betasına da erişim gerekiyorsa, formda {{app_store_name}} e-posta adresinizi yine de ekleyin.',
|
||||
[Locales.UK]:
|
||||
'Якщо вам також потрібен доступ до бета-версії застосунку для {{ios_name}}, усе одно вкажіть у формі адресу електронної пошти для {{app_store_name}}.',
|
||||
[Locales.VI]:
|
||||
'Nếu bạn cũng cần quyền truy cập beta ứng dụng {{ios_name}}, hãy vẫn điền địa chỉ email {{app_store_name}} của bạn trong biểu mẫu.',
|
||||
[Locales.ZH_CN]:
|
||||
'如果你还需要 {{ios_name}} 应用 Beta 访问权限,请仍在表单中填写你的 {{app_store_name}} 电子邮件地址。',
|
||||
[Locales.ZH_TW]:
|
||||
'如果你也需要 {{ios_name}} 應用程式 Beta 存取權,請仍在表單中填寫你的 {{app_store_name}} 電子郵件地址。',
|
||||
};
|
||||
|
||||
const CORRECTION_OUTRO_TEMPLATES: Partial<Record<LocaleCode, string>> = {
|
||||
[Locales.AR]: 'نأسف على الالتباس،\nفريق {{product_name}}',
|
||||
[Locales.BG]: 'Извиняваме се за объркването,\nЕкипът на {{product_name}}',
|
||||
[Locales.CS]: 'Omlouváme se za zmatek,\nTým {{product_name}}',
|
||||
[Locales.DA]: 'Beklager forvirringen,\n{{product_name}}-teamet',
|
||||
[Locales.DE]: 'Entschuldige die Verwirrung,\nDas {{product_name}}-Team',
|
||||
[Locales.EL]: 'Συγγνώμη για τη σύγχυση,\nΗ ομάδα του {{product_name}}',
|
||||
[Locales.EN_GB]: 'Sorry for the confusion,\nThe {{product_name}} Team',
|
||||
[Locales.EN_US]: 'Sorry for the confusion,\nThe {{product_name}} Team',
|
||||
[Locales.ES_ES]: 'Perdón por la confusión,\nEl equipo de {{product_name}}',
|
||||
[Locales.ES_419]: 'Perdón por la confusión,\nEl equipo de {{product_name}}',
|
||||
[Locales.FI]: 'Pahoittelut epäselvyydestä,\n{{product_name}}-tiimi',
|
||||
[Locales.FR]: 'Désolé pour la confusion,\nL’équipe {{product_name}}',
|
||||
[Locales.HE]: 'סליחה על הבלבול,\nצוות {{product_name}}',
|
||||
[Locales.HI]: 'भ्रम के लिए क्षमा करें,\n{{product_name}} टीम',
|
||||
[Locales.HR]: 'Ispričavamo se zbog zabune,\n{{product_name}} tim',
|
||||
[Locales.HU]: 'Elnézést a félreértésért,\nA {{product_name}} csapata',
|
||||
[Locales.ID]: 'Mohon maaf atas kebingungannya,\nTim {{product_name}}',
|
||||
[Locales.IT]: 'Ci scusiamo per la confusione,\nIl team {{product_name}}',
|
||||
[Locales.JA]: '混乱させてしまい申し訳ありません。\n{{product_name}}チーム',
|
||||
[Locales.KO]: '혼란을 드려 죄송합니다.\n{{product_name}} 팀',
|
||||
[Locales.LT]: 'Atsiprašome už neaiškumą,\n{{product_name}} komanda',
|
||||
[Locales.NL]: 'Sorry voor de verwarring,\nHet {{product_name}}-team',
|
||||
[Locales.NO]: 'Beklager forvirringen,\n{{product_name}}-teamet',
|
||||
[Locales.PL]: 'Przepraszamy za zamieszanie,\nZespół {{product_name}}',
|
||||
[Locales.PT_BR]: 'Desculpe pela confusão,\nEquipe {{product_name}}',
|
||||
[Locales.RO]: 'Ne pare rău pentru confuzie,\nEchipa {{product_name}}',
|
||||
[Locales.RU]: 'Извините за путаницу,\nКоманда {{product_name}}',
|
||||
[Locales.SV_SE]: 'Ursäkta förvirringen,\n{{product_name}}-teamet',
|
||||
[Locales.TH]: 'ขออภัยสำหรับความสับสน\nทีม {{product_name}}',
|
||||
[Locales.TR]: 'Karışıklık için özür dileriz,\n{{product_name}} Ekibi',
|
||||
[Locales.UK]: 'Вибачте за плутанину,\nКоманда {{product_name}}',
|
||||
[Locales.VI]: 'Xin lỗi vì sự nhầm lẫn,\nĐội ngũ {{product_name}}',
|
||||
[Locales.ZH_CN]: '很抱歉造成混淆,\n{{product_name}} 团队',
|
||||
[Locales.ZH_TW]: '很抱歉造成混淆,\n{{product_name}} 團隊',
|
||||
};
|
||||
|
||||
function getTemplate(
|
||||
templates: Partial<Record<LocaleCode, string>>,
|
||||
locale: LocaleCode | string | null | undefined,
|
||||
): string {
|
||||
return templates[locale as LocaleCode] ?? templates[Locales.EN_US] ?? '';
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, locale: LocaleCode | string | null | undefined, userId: string): string {
|
||||
const tokenValues = buildTokenValues(locale, userId);
|
||||
return template.replace(/\{\{([a-z0-9_]+)\}\}/g, (match, key: string) => tokenValues[key] ?? match);
|
||||
}
|
||||
|
||||
function insertBeforeBetaReminder(template: string, paragraph: string): string {
|
||||
const paragraphs = template.split('\n\n');
|
||||
if (paragraphs.length < 3) {
|
||||
return `${template}\n\n${paragraph}`;
|
||||
}
|
||||
return [...paragraphs.slice(0, -2), paragraph, ...paragraphs.slice(-2)].join('\n\n');
|
||||
}
|
||||
|
||||
export function resolvePlutoniumMobileBetaDispatchBody(
|
||||
locale: LocaleCode | string | null | undefined,
|
||||
userId: string,
|
||||
): string {
|
||||
const template =
|
||||
locale === Locales.EN_GB || locale === Locales.EN_US
|
||||
? ENGLISH_TEMPLATE
|
||||
: (LOCALIZED_TEMPLATES[locale as LocaleCode] ?? ENGLISH_TEMPLATE);
|
||||
const clarification = getTemplate(MOBILE_COMMUNITY_FORM_CLARIFICATION_TEMPLATES, locale);
|
||||
return renderTemplate(insertBeforeBetaReminder(template, clarification), locale, userId);
|
||||
}
|
||||
|
||||
export function resolvePlutoniumMobileBetaCorrectionBody(
|
||||
locale: LocaleCode | string | null | undefined,
|
||||
userId: string,
|
||||
): string {
|
||||
const template = [
|
||||
getTemplate(CORRECTION_HEADER_TEMPLATES, locale),
|
||||
getTemplate(MOBILE_COMMUNITY_FORM_CLARIFICATION_TEMPLATES, locale),
|
||||
getTemplate(FORM_LINK_SENTENCE_TEMPLATES, locale),
|
||||
getTemplate(IOS_APP_ACCESS_REMINDER_TEMPLATES, locale),
|
||||
getTemplate(CORRECTION_OUTRO_TEMPLATES, locale),
|
||||
].join('\n\n');
|
||||
return renderTemplate(template, locale, userId);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '../BrandedTypes';
|
||||
import {executeQuery, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import {Db} from '../database/CassandraTypes';
|
||||
import type {PneumaticPostDeliveryRow} from '../database/types/PneumaticPostTypes';
|
||||
import {PneumaticPostDeliveries} from '../Tables';
|
||||
|
||||
const FIND_DELIVERY = PneumaticPostDeliveries.select({
|
||||
where: [PneumaticPostDeliveries.where.eq('user_id'), PneumaticPostDeliveries.where.eq('dispatch_key')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
interface ConditionalInsertResult {
|
||||
'[applied]': boolean;
|
||||
}
|
||||
|
||||
export interface PneumaticPostDeliveryRecord {
|
||||
dispatchKey: string;
|
||||
userId: UserID;
|
||||
claimedAt: Date;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface PneumaticPostSentDeliveryRecord extends PneumaticPostDeliveryRecord {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
sentAt: Date;
|
||||
}
|
||||
|
||||
interface PneumaticPostFailedDeliveryRecord extends PneumaticPostDeliveryRecord {
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export class PneumaticPostRepository {
|
||||
async findDelivery(userId: UserID, dispatchKey: string): Promise<PneumaticPostDeliveryRow | null> {
|
||||
return await fetchOne<PneumaticPostDeliveryRow>(
|
||||
FIND_DELIVERY.bind({
|
||||
user_id: userId,
|
||||
dispatch_key: dispatchKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async tryCreateDeliveryClaim(record: PneumaticPostDeliveryRecord): Promise<boolean> {
|
||||
const row: PneumaticPostDeliveryRow = {
|
||||
user_id: record.userId,
|
||||
dispatch_key: record.dispatchKey,
|
||||
status: 'claimed',
|
||||
claimed_at: record.claimedAt,
|
||||
sent_at: null,
|
||||
channel_id: null,
|
||||
message_id: null,
|
||||
locale: record.locale,
|
||||
error_message: null,
|
||||
};
|
||||
const [result] = await executeQuery<ConditionalInsertResult>(PneumaticPostDeliveries.insertIfNotExists(row));
|
||||
if (!result || typeof result['[applied]'] !== 'boolean') {
|
||||
throw new Error('Unexpected database response for Pneumatic Post delivery claim');
|
||||
}
|
||||
return result['[applied]'];
|
||||
}
|
||||
|
||||
async markDeliverySent(record: PneumaticPostSentDeliveryRecord): Promise<void> {
|
||||
await upsertOne(
|
||||
PneumaticPostDeliveries.patchByPk(
|
||||
{user_id: record.userId, dispatch_key: record.dispatchKey},
|
||||
{
|
||||
status: Db.set('sent'),
|
||||
claimed_at: Db.set(record.claimedAt),
|
||||
sent_at: Db.set(record.sentAt),
|
||||
channel_id: Db.set(record.channelId),
|
||||
message_id: Db.set(record.messageId),
|
||||
locale: Db.set(record.locale),
|
||||
error_message: Db.clear(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async markDeliveryFailed(record: PneumaticPostFailedDeliveryRecord): Promise<void> {
|
||||
await upsertOne(
|
||||
PneumaticPostDeliveries.patchByPk(
|
||||
{user_id: record.userId, dispatch_key: record.dispatchKey},
|
||||
{
|
||||
status: Db.set('failed'),
|
||||
claimed_at: Db.set(record.claimedAt),
|
||||
sent_at: Db.clear(),
|
||||
channel_id: Db.clear(),
|
||||
message_id: Db.clear(),
|
||||
locale: Db.set(record.locale),
|
||||
error_message: Db.set(record.errorMessage),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {ChannelID, MessageID} from '../BrandedTypes';
|
||||
import type {ChannelService} from '../channel/services/ChannelService';
|
||||
import {SYSTEM_USER_ID} from '../constants/Core';
|
||||
import type {PneumaticPostDeliveryRow} from '../database/types/PneumaticPostTypes';
|
||||
import type {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import {Logger} from '../Logger';
|
||||
import {createRequestCache, type RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import type {User} from '../models/User';
|
||||
import type {UserSettings} from '../models/UserSettings';
|
||||
import type {IUserRepository} from '../user/IUserRepository';
|
||||
import type {UserChannelService} from '../user/services/UserChannelService';
|
||||
import {
|
||||
PLUTONIUM_MOBILE_BETA_CORRECTION_DISPATCH,
|
||||
PLUTONIUM_MOBILE_BETA_DISPATCH,
|
||||
PNEUMATIC_POST_SYSTEM_NAME,
|
||||
resolvePlutoniumMobileBetaCorrectionBody,
|
||||
resolvePlutoniumMobileBetaDispatchBody,
|
||||
} from './PneumaticPostNotices';
|
||||
import type {PneumaticPostDeliveryRecord, PneumaticPostRepository} from './PneumaticPostRepository';
|
||||
|
||||
const DELIVERY_ERROR_MESSAGE_MAX_LENGTH = 1000;
|
||||
|
||||
interface PneumaticPostServiceDeps {
|
||||
repository: PneumaticPostRepository;
|
||||
userRepository: Pick<IUserRepository, 'findUnique'>;
|
||||
userChannelService: Pick<UserChannelService, 'ensureDmOpenForBothUsers'>;
|
||||
channelService: Pick<ChannelService, 'messages'>;
|
||||
userCacheService: UserCacheService;
|
||||
}
|
||||
|
||||
interface SentDelivery {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}
|
||||
|
||||
function deliveryErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.slice(0, DELIVERY_ERROR_MESSAGE_MAX_LENGTH);
|
||||
}
|
||||
|
||||
export class PneumaticPostService {
|
||||
constructor(private readonly deps: PneumaticPostServiceDeps) {}
|
||||
|
||||
async considerPlutoniumMobileBetaDispatch(user: User, settings: UserSettings | null): Promise<void> {
|
||||
if (!this.isCurrentPlutoniumUser(user)) {
|
||||
return;
|
||||
}
|
||||
const locale = settings?.locale ?? user.locale ?? null;
|
||||
const originalDelivery = await this.findDelivery(user, PLUTONIUM_MOBILE_BETA_DISPATCH.key);
|
||||
if (originalDelivery === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalDelivery?.status === 'sent') {
|
||||
await this.considerPlutoniumMobileBetaCorrection(user, locale);
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalDelivery?.status === 'claimed') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendRevisedPlutoniumMobileBetaDispatch(user, locale, originalDelivery);
|
||||
}
|
||||
|
||||
private async findDelivery(user: User, dispatchKey: string): Promise<PneumaticPostDeliveryRow | null | undefined> {
|
||||
try {
|
||||
return await this.deps.repository.findDelivery(user.id, dispatchKey);
|
||||
} catch (error) {
|
||||
Logger.warn({dispatchKey, userId: user.id.toString(), error}, 'Failed to read Pneumatic Post delivery state');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private makeDeliveryRecord(user: User, dispatchKey: string, locale: string | null): PneumaticPostDeliveryRecord {
|
||||
return {
|
||||
dispatchKey,
|
||||
userId: user.id,
|
||||
claimedAt: new Date(),
|
||||
locale: locale ?? 'en-US',
|
||||
};
|
||||
}
|
||||
|
||||
private async considerPlutoniumMobileBetaCorrection(user: User, locale: string | null): Promise<void> {
|
||||
const dispatchKey = PLUTONIUM_MOBILE_BETA_CORRECTION_DISPATCH.key;
|
||||
const correctionDelivery = await this.findDelivery(user, dispatchKey);
|
||||
if (
|
||||
correctionDelivery === undefined ||
|
||||
correctionDelivery?.status === 'sent' ||
|
||||
correctionDelivery?.status === 'claimed'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const deliveryRecord = this.makeDeliveryRecord(user, dispatchKey, locale);
|
||||
if (correctionDelivery === null && !(await this.tryCreateDeliveryClaim(deliveryRecord))) {
|
||||
return;
|
||||
}
|
||||
const content = resolvePlutoniumMobileBetaCorrectionBody(locale, user.id.toString());
|
||||
await this.sendClaimedDelivery({recipient: user, deliveryRecord, content});
|
||||
}
|
||||
|
||||
private async sendRevisedPlutoniumMobileBetaDispatch(
|
||||
user: User,
|
||||
locale: string | null,
|
||||
existingDelivery: PneumaticPostDeliveryRow | null,
|
||||
): Promise<void> {
|
||||
const deliveryRecord = this.makeDeliveryRecord(user, PLUTONIUM_MOBILE_BETA_DISPATCH.key, locale);
|
||||
if (existingDelivery === null && !(await this.tryCreateDeliveryClaim(deliveryRecord))) {
|
||||
return;
|
||||
}
|
||||
const content = resolvePlutoniumMobileBetaDispatchBody(locale, user.id.toString());
|
||||
const sent = await this.sendClaimedDelivery({recipient: user, deliveryRecord, content});
|
||||
if (sent) {
|
||||
await this.markCorrectionImplicitlySent(user, locale, sent);
|
||||
}
|
||||
}
|
||||
|
||||
private async markCorrectionImplicitlySent(user: User, locale: string | null, sent: SentDelivery): Promise<void> {
|
||||
const deliveryRecord = this.makeDeliveryRecord(user, PLUTONIUM_MOBILE_BETA_CORRECTION_DISPATCH.key, locale);
|
||||
if (!(await this.tryCreateDeliveryClaim(deliveryRecord))) {
|
||||
return;
|
||||
}
|
||||
await this.markDeliverySent(deliveryRecord, sent);
|
||||
}
|
||||
|
||||
private async sendClaimedDelivery({
|
||||
recipient,
|
||||
deliveryRecord,
|
||||
content,
|
||||
}: {
|
||||
recipient: User;
|
||||
deliveryRecord: PneumaticPostDeliveryRecord;
|
||||
content: string;
|
||||
}): Promise<SentDelivery | null> {
|
||||
const requestCache = createRequestCache();
|
||||
try {
|
||||
const sent = await this.sendDelivery({recipient, content, requestCache});
|
||||
await this.markDeliverySent(deliveryRecord, sent);
|
||||
Logger.info(
|
||||
{
|
||||
system: PNEUMATIC_POST_SYSTEM_NAME,
|
||||
dispatchKey: deliveryRecord.dispatchKey,
|
||||
userId: recipient.id.toString(),
|
||||
locale: deliveryRecord.locale,
|
||||
messageId: sent.messageId.toString(),
|
||||
},
|
||||
'Pneumatic Post dispatch sent',
|
||||
);
|
||||
return sent;
|
||||
} catch (error) {
|
||||
await this.markDeliveryFailed(deliveryRecord, error);
|
||||
Logger.warn(
|
||||
{dispatchKey: deliveryRecord.dispatchKey, userId: recipient.id.toString(), error},
|
||||
'Failed to send Pneumatic Post dispatch',
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private async tryCreateDeliveryClaim(record: PneumaticPostDeliveryRecord): Promise<boolean> {
|
||||
try {
|
||||
return await this.deps.repository.tryCreateDeliveryClaim(record);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{dispatchKey: record.dispatchKey, userId: record.userId.toString(), error},
|
||||
'Failed to claim Pneumatic Post delivery',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendDelivery({
|
||||
recipient,
|
||||
content,
|
||||
requestCache,
|
||||
}: {
|
||||
recipient: User;
|
||||
content: string;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<SentDelivery> {
|
||||
const systemUser = await this.deps.userRepository.findUnique(SYSTEM_USER_ID);
|
||||
if (!systemUser) {
|
||||
throw new Error('System user (id=0) not found');
|
||||
}
|
||||
const channel = await this.deps.userChannelService.ensureDmOpenForBothUsers({
|
||||
userId: systemUser.id,
|
||||
recipientId: recipient.id,
|
||||
userCacheService: this.deps.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
const message = await this.deps.channelService.messages.send.sendMessage({
|
||||
user: systemUser,
|
||||
channelId: channel.id,
|
||||
data: {content},
|
||||
requestCache,
|
||||
});
|
||||
return {channelId: channel.id, messageId: message.id};
|
||||
}
|
||||
|
||||
private async markDeliverySent(record: PneumaticPostDeliveryRecord, sent: SentDelivery): Promise<void> {
|
||||
try {
|
||||
await this.deps.repository.markDeliverySent({...record, ...sent, sentAt: new Date()});
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{
|
||||
system: PNEUMATIC_POST_SYSTEM_NAME,
|
||||
dispatchKey: record.dispatchKey,
|
||||
userId: record.userId.toString(),
|
||||
messageId: sent.messageId.toString(),
|
||||
error,
|
||||
},
|
||||
'Pneumatic Post dispatch sent but delivery record was not finalized',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async markDeliveryFailed(record: PneumaticPostDeliveryRecord, error: unknown): Promise<void> {
|
||||
await this.deps.repository
|
||||
.markDeliveryFailed({...record, errorMessage: deliveryErrorMessage(error)})
|
||||
.catch((recordError) => {
|
||||
Logger.warn(
|
||||
{dispatchKey: record.dispatchKey, userId: record.userId.toString(), recordError},
|
||||
'Failed to record Pneumatic Post delivery failure',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private isCurrentPlutoniumUser(user: User): boolean {
|
||||
return user.premiumType !== null && user.premiumType !== UserPremiumTypes.LIFETIME && user.isPremium();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {UserContentServiceTestHooks} from './UserContentService';
|
||||
|
||||
const {isUnreachableEntityError} = UserContentServiceTestHooks;
|
||||
|
||||
describe('isUnreachableEntityError', () => {
|
||||
it('treats a deleted or left community as unreachable rather than fatal', () => {
|
||||
expect(isUnreachableEntityError(new UnknownGuildError())).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a gone channel and a lost permission as unreachable', () => {
|
||||
expect(isUnreachableEntityError(new UnknownChannelError())).toBe(true);
|
||||
expect(isUnreachableEntityError(new MissingPermissionsError())).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a deleted message to the delete path instead of marking it unavailable', () => {
|
||||
expect(isUnreachableEntityError(new UnknownMessageError())).toBe(false);
|
||||
});
|
||||
|
||||
it('still lets unexpected failures surface', () => {
|
||||
expect(isUnreachableEntityError(new Error('database is on fire'))).toBe(false);
|
||||
expect(isUnreachableEntityError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
// 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';
|
||||
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
|
||||
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
|
||||
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
|
||||
@@ -23,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';
|
||||
@@ -47,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;
|
||||
@@ -94,6 +98,13 @@ function normalizeProviderEnvironment(
|
||||
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
||||
}
|
||||
|
||||
const isUnreachableEntityError = (error: unknown): boolean =>
|
||||
error instanceof MissingPermissionsError ||
|
||||
error instanceof UnknownChannelError ||
|
||||
error instanceof UnknownGuildError;
|
||||
|
||||
export const UserContentServiceTestHooks = {isUnreachableEntityError};
|
||||
|
||||
export class UserContentService {
|
||||
private readonly updatePropagator: BaseUserUpdatePropagator;
|
||||
private readonly userRepository: UserContentRepository;
|
||||
@@ -138,11 +149,7 @@ export class UserContentService {
|
||||
messageId: mention.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof UnknownMessageError ||
|
||||
error instanceof MissingPermissionsError ||
|
||||
error instanceof UnknownChannelError
|
||||
) {
|
||||
if (error instanceof UnknownMessageError || isUnreachableEntityError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -188,7 +195,7 @@ export class UserContentService {
|
||||
await this.userRepository.deleteSavedMessage(userId, savedMessage.messageId);
|
||||
return null;
|
||||
}
|
||||
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
|
||||
if (isUnreachableEntityError(error)) {
|
||||
status = 'missing_permissions';
|
||||
} else {
|
||||
throw error;
|
||||
@@ -468,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 {
|
||||
@@ -480,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",
|
||||
|
||||
@@ -52,6 +52,15 @@ source edits in this package.
|
||||
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
|
||||
510 kbps maximum average bitrate in local offers and remote answers.
|
||||
|
||||
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
|
||||
`elementVolume` with a truthiness check, so a track deliberately held at `0`
|
||||
came back at full volume whenever it was re-attached or its Web Audio graph
|
||||
was rebuilt. All three guards now test `!== undefined`. Note that remote
|
||||
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
|
||||
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
|
||||
`webAudioMix` must stay unconditional.
|
||||
|
||||
## Updating from upstream
|
||||
|
||||
1. Check the upstream changelog for the target version.
|
||||
|
||||
@@ -64,7 +64,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
}
|
||||
|
||||
getVolume(): number {
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
return this.elementVolume;
|
||||
}
|
||||
if (isReactNative()) {
|
||||
@@ -113,7 +113,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
element.muted = true;
|
||||
}
|
||||
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
this.setVolume(this.elementVolume);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
lastNode.connect(this.gainNode);
|
||||
this.gainNode.connect(context.destination);
|
||||
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
this.gainNode.gain.setTargetAtTime(this.elementVolume, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,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,7 +3,3 @@
|
||||
.nativeDragRegion {
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .nativeDragRegion {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
+4
-2
@@ -313,6 +313,7 @@ export const GuildListItemPresentation = forwardRef<
|
||||
[backgroundImage, draggingCursor],
|
||||
);
|
||||
const guildIconTarget = useMemo(() => ({borderRadius: iconBorderRadius}), [iconBorderRadius]);
|
||||
const hasPaintedIcon = backgroundImage != null;
|
||||
return (
|
||||
<LongPressable
|
||||
{...domProps}
|
||||
@@ -332,6 +333,7 @@ export const GuildListItemPresentation = forwardRef<
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-guild-list-focus-item="true"
|
||||
data-guild-id={guild.id}
|
||||
onLongPress={onLongPress}
|
||||
disabled={false}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-list-item.click"
|
||||
@@ -363,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}
|
||||
@@ -373,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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user